From 68fcbc6fbf378f54764c454a1f21b292fdc91b1f Mon Sep 17 00:00:00 2001 From: mAi Date: Fri, 29 May 2026 16:07:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(docforge):=20slice=206c=20=E2=80=94=20temp?= =?UTF-8?q?late=20authoring=20page=20(frontend)=20(t-paliad-349)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WYSIWYG authoring surface at /admin/templates (admin-gated page route): - templates-authoring.tsx — page shell (upload form, template list, workspace: palette / run-addressable preview / placed slots). - client/templates-authoring.ts — hydrates it: lists templates, uploads a .docx (multipart), renders the run-span preview, builds the variable palette from the Go catalogue (GET /api/docforge/variables), and wires the select-then-pick gesture: select text within one .docforge-run, click a palette variable → POST the slot → re-render with the response. Reuses the docforge-editor lib (escapeHtml, catalogue client). Cross-run selections rejected with a hint (v1: single-run text slots). - build.ts emits dist/templates-authoring.html + bundles the client. - handleTemplatesAuthoringPage serves the shell; GET /admin/templates registered under adminGate. - 12 i18n keys (DE+EN) for the page; i18n-keys.ts regenerated (3079). Verification: go build/vet/test green (13 pkgs); bun run build.ts clean (i18n scan passes); bun test 274/274; gofmt-clean. The docx surgery + store + catalogue are unit/live-tested. VERIFICATION CEILING: the integrated live flow (upload→render→select→inject→save in a browser) needs the app running with DATABASE_URL + Supabase auth + Playwright — verified post-merge, not in this env. m/paliad#157 --- frontend/build.ts | 3 + frontend/src/client/i18n.ts | 26 ++ frontend/src/client/templates-authoring.ts | 314 +++++++++++++++++++++ frontend/src/i18n-keys.ts | 12 + frontend/src/templates-authoring.tsx | 112 ++++++++ internal/handlers/handlers.go | 1 + internal/handlers/templates.go | 6 + 7 files changed, 474 insertions(+) create mode 100644 frontend/src/client/templates-authoring.ts create mode 100644 frontend/src/templates-authoring.tsx diff --git a/frontend/build.ts b/frontend/build.ts index 383555f..840e7ab 100644 --- a/frontend/build.ts +++ b/frontend/build.ts @@ -18,6 +18,7 @@ import { renderProjectsNew } from "./src/projects-new"; import { renderProjectsDetail } from "./src/projects-detail"; import { renderProjectsChart } from "./src/projects-chart"; import { renderSubmissionDraft } from "./src/submission-draft"; +import { renderTemplatesAuthoring } from "./src/templates-authoring"; import { renderSubmissionsIndex } from "./src/submissions-index"; import { renderSubmissionsNew } from "./src/submissions-new"; import { renderEvents } from "./src/events"; @@ -255,6 +256,7 @@ async function build() { join(import.meta.dir, "src/client/projects-detail.ts"), join(import.meta.dir, "src/client/projects-chart.ts"), join(import.meta.dir, "src/client/submission-draft.ts"), + join(import.meta.dir, "src/client/templates-authoring.ts"), join(import.meta.dir, "src/client/submissions-index.ts"), join(import.meta.dir, "src/client/submissions-new.ts"), join(import.meta.dir, "src/client/events.ts"), @@ -382,6 +384,7 @@ async function build() { await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail()); await Bun.write(join(DIST, "projects-chart.html"), renderProjectsChart()); await Bun.write(join(DIST, "submission-draft.html"), renderSubmissionDraft()); + await Bun.write(join(DIST, "templates-authoring.html"), renderTemplatesAuthoring()); await Bun.write(join(DIST, "submissions-index.html"), renderSubmissionsIndex()); await Bun.write(join(DIST, "submissions-new.html"), renderSubmissionsNew()); // t-paliad-115 — shared EventsPage at the canonical /events URL. diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 193ee55..192a40f 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -1697,6 +1697,19 @@ const translations: Record> = { "submissions.draft.base.hint": "Steuert Schriftarten, Briefkopf und Abschnitts-Defaults.", "submissions.draft.sections.title": "Abschnitte", "submissions.draft.sections.hint": "Inhalt pro Abschnitt — Autosave nach 500 ms. Letztes Layout in Word.", + // t-paliad-349 (m/paliad#157) docforge slice 6 — template authoring page. + "templates.authoring.title": "Vorlagen — Paliad", + "templates.authoring.heading": "Vorlagen", + "templates.authoring.intro": "Lade eine Word-Vorlage hoch, markiere Stellen und setze Variablen ein.", + "templates.authoring.upload.title": "Neue Vorlage hochladen", + "templates.authoring.upload.file": "Word-Datei (.docx)", + "templates.authoring.upload.name_de": "Name (DE)", + "templates.authoring.upload.name_en": "Name (EN)", + "templates.authoring.upload.firm": "Kanzlei (optional)", + "templates.authoring.upload.submit": "Hochladen", + "templates.authoring.list.title": "Vorhandene Vorlagen", + "templates.authoring.workspace.hint": "Text markieren, dann eine Variable wählen, um einen Platzhalter zu setzen.", + "templates.authoring.slots.title": "Platzhalter", // t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin. "admin.building_blocks.title": "Bausteine — Paliad", "admin.building_blocks.heading": "Bausteine", @@ -4962,6 +4975,19 @@ const translations: Record> = { "submissions.draft.base.hint": "Drives fonts, letterhead, and section defaults.", "submissions.draft.sections.title": "Sections", "submissions.draft.sections.hint": "Edit per section — autosaves after 500ms. Final layout in Word.", + // t-paliad-349 (m/paliad#157) docforge slice 6 — template authoring page. + "templates.authoring.title": "Templates — Paliad", + "templates.authoring.heading": "Templates", + "templates.authoring.intro": "Upload a Word template, highlight spots and insert variables.", + "templates.authoring.upload.title": "Upload a new template", + "templates.authoring.upload.file": "Word file (.docx)", + "templates.authoring.upload.name_de": "Name (DE)", + "templates.authoring.upload.name_en": "Name (EN)", + "templates.authoring.upload.firm": "Firm (optional)", + "templates.authoring.upload.submit": "Upload", + "templates.authoring.list.title": "Existing templates", + "templates.authoring.workspace.hint": "Highlight text, then pick a variable to place a placeholder.", + "templates.authoring.slots.title": "Placeholders", // t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin. "admin.building_blocks.title": "Building blocks — Paliad", "admin.building_blocks.heading": "Building blocks", diff --git a/frontend/src/client/templates-authoring.ts b/frontend/src/client/templates-authoring.ts new file mode 100644 index 0000000..43596cd --- /dev/null +++ b/frontend/src/client/templates-authoring.ts @@ -0,0 +1,314 @@ +import { initI18n } from "./i18n"; +import { initSidebar } from "./sidebar"; +import { escapeHtml } from "../lib/docforge-editor/dom"; +import { fetchVariableCatalogue, type VariableEntry } from "../lib/docforge-editor/catalogue"; + +// t-paliad-349 docforge slice 6 — client for the template authoring page. +// +// Flow: list templates → upload a .docx (or open one) → the carrier renders +// as run spans () → the admin +// selects text within one run, then clicks a variable in the palette → the +// server injects {{slot}} at the selection and returns the updated view. +// +// The select-then-pick gesture keys on the run index (data-run) + the +// selected text, matching the server's text-based InjectSlot so umlauts +// can't desync the selection from the slice. Selections that span more than +// one run are rejected with a hint (v1 scope: single-run text slots). + +interface TemplateMeta { + id: string; + slug?: string; + name_de: string; + name_en: string; + kind: string; + source_format: string; + firm?: string; + is_active: boolean; + version: number; +} + +interface TemplateSlot { + key: string; + anchor: string; + label?: string; + order_index: number; +} + +interface AuthoringView { + template: TemplateMeta; + preview_html: string; + slots: TemplateSlot[]; +} + +interface Selection1Run { + runIndex: number; + text: string; +} + +interface State { + catalogue: VariableEntry[]; + openID: string | null; + activeSlotKey: string | null; + selection: Selection1Run | null; +} + +const state: State = { + catalogue: [], + openID: null, + activeSlotKey: null, + selection: null, +}; + +function isEN(): boolean { + return (document.documentElement.lang || "de").toLowerCase().startsWith("en"); +} + +function labelOf(e: VariableEntry): string { + return isEN() ? e.label_en : e.label_de; +} + +async function boot(): Promise { + initI18n(); + initSidebar(); + + try { + state.catalogue = await fetchVariableCatalogue(); + } catch (err) { + console.warn("templates-authoring: catalogue fetch failed", err); + } + + wireUploadForm(); + await loadList(); +} + +async function loadList(): Promise { + const host = document.getElementById("docforge-template-list"); + if (!host) return; + let metas: TemplateMeta[] = []; + try { + const res = await fetch("/api/admin/templates", { headers: { Accept: "application/json" } }); + if (res.ok) { + const body = (await res.json()) as { templates: TemplateMeta[] }; + metas = body.templates ?? []; + } + } catch (err) { + console.warn("templates-authoring: list fetch failed", err); + } + if (metas.length === 0) { + host.innerHTML = `
  • ${escapeHtml(isEN() ? "No templates yet." : "Noch keine Vorlagen.")}
  • `; + return; + } + host.innerHTML = metas + .map((m) => { + const name = isEN() ? m.name_en : m.name_de; + const firm = m.firm ? ` · ${escapeHtml(m.firm)}` : ""; + return `
  • + ${escapeHtml(name)} + v${m.version}${firm} +
  • `; + }) + .join(""); + host.querySelectorAll(".docforge-template-row").forEach((li) => { + li.addEventListener("click", () => { + const id = li.dataset.templateId; + if (id) void openTemplate(id); + }); + }); +} + +function wireUploadForm(): void { + const form = document.getElementById("docforge-upload-form") as HTMLFormElement | null; + if (!form) return; + form.addEventListener("submit", async (ev) => { + ev.preventDefault(); + const status = document.getElementById("docforge-upload-status"); + const data = new FormData(form); + setText(status, isEN() ? "Uploading…" : "Lädt hoch…"); + try { + const res = await fetch("/api/admin/templates", { method: "POST", body: data }); + if (!res.ok) { + const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` })); + setText(status, (isEN() ? "Error: " : "Fehler: ") + (body.error ?? res.status)); + return; + } + const view = (await res.json()) as AuthoringView; + setText(status, ""); + form.reset(); + await loadList(); + openView(view); + } catch (err) { + setText(status, (isEN() ? "Error: " : "Fehler: ") + String(err)); + } + }); +} + +async function openTemplate(id: string): Promise { + try { + const res = await fetch(`/api/admin/templates/${encodeURIComponent(id)}`, { + headers: { Accept: "application/json" }, + }); + if (!res.ok) return; + openView((await res.json()) as AuthoringView); + } catch (err) { + console.warn("templates-authoring: open failed", err); + } +} + +function openView(view: AuthoringView): void { + state.openID = view.template.id; + state.activeSlotKey = null; + state.selection = null; + + const workspace = document.getElementById("docforge-workspace"); + if (workspace) workspace.hidden = false; + + const title = document.getElementById("docforge-workspace-title"); + if (title) { + const name = isEN() ? view.template.name_en : view.template.name_de; + title.textContent = `${name} · v${view.template.version}`; + } + + renderPreview(view.preview_html); + renderSlots(view.slots); + renderPalette(); + setWorkspaceStatus(""); +} + +function renderPreview(html: string): void { + const host = document.getElementById("docforge-preview"); + if (!host) return; + host.innerHTML = html; + host.addEventListener("mouseup", onPreviewSelect); +} + +// onPreviewSelect captures a selection that lies entirely within one run +// span; otherwise it clears the pending selection and hints. +function onPreviewSelect(): void { + const sel = window.getSelection(); + if (!sel || sel.isCollapsed || sel.rangeCount === 0) { + state.selection = null; + return; + } + const text = sel.toString(); + if (text === "") { + state.selection = null; + return; + } + const anchorRun = closestRun(sel.anchorNode); + const focusRun = closestRun(sel.focusNode); + if (!anchorRun || anchorRun !== focusRun) { + state.selection = null; + setWorkspaceStatus(isEN() + ? "Select within a single text span." + : "Bitte innerhalb einer Textstelle markieren."); + return; + } + const runIndex = Number(anchorRun.dataset.run); + if (Number.isNaN(runIndex)) { + state.selection = null; + return; + } + state.selection = { runIndex, text }; + setWorkspaceStatus(state.activeSlotKey + ? (isEN() ? `Click to bind “${text}” → ${state.activeSlotKey}` : `Variable wählen, um „${text}“ zu setzen`) + : (isEN() ? `Selected “${text}” — now pick a variable.` : `„${text}" markiert — jetzt Variable wählen.`)); +} + +function closestRun(node: Node | null): HTMLElement | null { + let el: Node | null = node; + while (el && el !== document.body) { + if (el instanceof HTMLElement && el.classList.contains("docforge-run")) return el; + el = el.parentNode; + } + return null; +} + +// renderPalette groups catalogue entries by their namespace group and wires +// each as a click-to-place control. +function renderPalette(): void { + const host = document.getElementById("docforge-palette"); + if (!host) return; + if (state.catalogue.length === 0) { + host.innerHTML = `

    ${escapeHtml(isEN() ? "No variables." : "Keine Variablen.")}

    `; + return; + } + const groups = new Map(); + for (const e of state.catalogue) { + const arr = groups.get(e.group) ?? []; + arr.push(e); + groups.set(e.group, arr); + } + let html = `

    ${escapeHtml(isEN() ? "Variables" : "Variablen")}

    `; + for (const [group, entries] of groups) { + html += `

    ${escapeHtml(group)}

    `; + for (const e of entries) { + html += ``; + } + html += `
    `; + } + host.innerHTML = html; + host.querySelectorAll(".docforge-palette-var").forEach((btn) => { + btn.addEventListener("click", () => onPaletteClick(btn.dataset.slotKey ?? "", btn)); + }); +} + +function onPaletteClick(slotKey: string, btn: HTMLButtonElement): void { + state.activeSlotKey = slotKey; + const host = document.getElementById("docforge-palette"); + host?.querySelectorAll(".docforge-palette-var--active").forEach((el) => el.classList.remove("docforge-palette-var--active")); + btn.classList.add("docforge-palette-var--active"); + + if (state.selection) { + void placeSlot(state.selection.runIndex, state.selection.text, slotKey); + } else { + setWorkspaceStatus(isEN() + ? `${slotKey} selected — now highlight the text to replace.` + : `${slotKey} gewählt — jetzt den zu ersetzenden Text markieren.`); + } +} + +async function placeSlot(runIndex: number, selectedText: string, slotKey: string): Promise { + if (!state.openID) return; + setWorkspaceStatus(isEN() ? "Placing…" : "Setze…"); + try { + const res = await fetch(`/api/admin/templates/${encodeURIComponent(state.openID)}/slots`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ run_index: runIndex, selected_text: selectedText, slot_key: slotKey }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` })); + setWorkspaceStatus((isEN() ? "Error: " : "Fehler: ") + (body.error ?? res.status)); + return; + } + openView((await res.json()) as AuthoringView); + } catch (err) { + setWorkspaceStatus((isEN() ? "Error: " : "Fehler: ") + String(err)); + } +} + +function renderSlots(slots: TemplateSlot[]): void { + const host = document.getElementById("docforge-slot-list"); + if (!host) return; + if (slots.length === 0) { + host.innerHTML = `
  • ${escapeHtml(isEN() ? "No slots yet." : "Noch keine Platzhalter.")}
  • `; + return; + } + host.innerHTML = slots + .map((s) => `
  • {{${escapeHtml(s.key)}}}
  • `) + .join(""); +} + +function setWorkspaceStatus(msg: string): void { + setText(document.getElementById("docforge-workspace-status"), msg); +} + +function setText(el: Element | null, msg: string): void { + if (el) el.textContent = msg; +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => void boot()); +} else { + void boot(); +} diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 187f14e..70dee4b 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -2887,6 +2887,18 @@ export type I18nKey = | "team.selection.toggle_card" | "team.subtitle" | "team.title" + | "templates.authoring.heading" + | "templates.authoring.intro" + | "templates.authoring.list.title" + | "templates.authoring.slots.title" + | "templates.authoring.title" + | "templates.authoring.upload.file" + | "templates.authoring.upload.firm" + | "templates.authoring.upload.name_de" + | "templates.authoring.upload.name_en" + | "templates.authoring.upload.submit" + | "templates.authoring.upload.title" + | "templates.authoring.workspace.hint" | "theme.toggle.auto" | "theme.toggle.cycle.auto" | "theme.toggle.cycle.dark" diff --git a/frontend/src/templates-authoring.tsx b/frontend/src/templates-authoring.tsx new file mode 100644 index 0000000..357750d --- /dev/null +++ b/frontend/src/templates-authoring.tsx @@ -0,0 +1,112 @@ +import { h } from "./jsx"; +import { Sidebar } from "./components/Sidebar"; +import { PaliadinWidget } from "./components/PaliadinWidget"; +import { BottomNav } from "./components/BottomNav"; +import { Footer } from "./components/Footer"; +import { PWAHead } from "./components/PWAHead"; + +// t-paliad-349 docforge slice 6 — template authoring page at +// /admin/templates. +// +// Admin uploads a base .docx, sees it rendered as run-addressable text, +// selects a span + a variable from the palette to drop a {{slot}}, and the +// result saves as a reusable docforge template. Pure shell: +// client/templates-authoring.ts hydrates the list, upload form, preview, +// palette, and slot list after load. The palette labels come from the Go +// variable catalogue (GET /api/docforge/variables, the SSOT from slice 5). +// +// Design ref: docs/plans/prd-docforge-2026-05-29.md §2.1. + +export function renderTemplatesAuthoring(): string { + return "" + ( + + + + + + + Vorlagen — Paliad + + + + + + +
    +
    +
    +
    +

    Vorlagen

    +

    + Lade eine Word-Vorlage hoch, markiere Stellen und setze Variablen ein. +

    +
    + + {/* Upload a new base .docx */} +
    +

    Neue Vorlage hochladen

    +
    + + + + + + + +
    + + {/* Existing templates */} +
    +

    Vorhandene Vorlagen

    +
      +
    + + {/* Authoring workspace — hidden until a template is opened. */} + +
    +
    +
    + +