feat(docforge): slice 6c — template authoring page (frontend) (t-paliad-349)

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
This commit is contained in:
mAi
2026-05-29 16:07:43 +02:00
parent 31e15d4b20
commit 68fcbc6fbf
7 changed files with 474 additions and 0 deletions

View File

@@ -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.

View File

@@ -1697,6 +1697,19 @@ const translations: Record<Lang, Record<string, string>> = {
"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<Lang, Record<string, string>> = {
"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",

View File

@@ -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 (<span class="docforge-run" data-run="N">) → 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<void> {
initI18n();
initSidebar();
try {
state.catalogue = await fetchVariableCatalogue();
} catch (err) {
console.warn("templates-authoring: catalogue fetch failed", err);
}
wireUploadForm();
await loadList();
}
async function loadList(): Promise<void> {
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 = `<li class="docforge-template-empty">${escapeHtml(isEN() ? "No templates yet." : "Noch keine Vorlagen.")}</li>`;
return;
}
host.innerHTML = metas
.map((m) => {
const name = isEN() ? m.name_en : m.name_de;
const firm = m.firm ? ` · ${escapeHtml(m.firm)}` : "";
return `<li class="docforge-template-row" data-template-id="${escapeHtml(m.id)}">
<span class="docforge-template-name">${escapeHtml(name)}</span>
<span class="docforge-template-meta">v${m.version}${firm}</span>
</li>`;
})
.join("");
host.querySelectorAll<HTMLLIElement>(".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<void> {
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 = `<p class="docforge-palette-empty">${escapeHtml(isEN() ? "No variables." : "Keine Variablen.")}</p>`;
return;
}
const groups = new Map<string, VariableEntry[]>();
for (const e of state.catalogue) {
const arr = groups.get(e.group) ?? [];
arr.push(e);
groups.set(e.group, arr);
}
let html = `<h3>${escapeHtml(isEN() ? "Variables" : "Variablen")}</h3>`;
for (const [group, entries] of groups) {
html += `<div class="docforge-palette-group"><h4>${escapeHtml(group)}</h4>`;
for (const e of entries) {
html += `<button type="button" class="docforge-palette-var" data-slot-key="${escapeHtml(e.key)}" title="{{${escapeHtml(e.key)}}}">${escapeHtml(labelOf(e))}</button>`;
}
html += `</div>`;
}
host.innerHTML = html;
host.querySelectorAll<HTMLButtonElement>(".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<void> {
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 = `<li class="docforge-slot-empty">${escapeHtml(isEN() ? "No slots yet." : "Noch keine Platzhalter.")}</li>`;
return;
}
host.innerHTML = slots
.map((s) => `<li class="docforge-slot-row" data-slot="${escapeHtml(s.key)}"><code>{{${escapeHtml(s.key)}}}</code></li>`)
.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();
}

View File

@@ -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"

View File

@@ -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 "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<PWAHead />
<title data-i18n="templates.authoring.title">Vorlagen &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar page-templates-authoring">
<Sidebar currentPath="/admin" />
<BottomNav currentPath="/admin" />
<main>
<section className="tool-page docforge-templates-page">
<div className="container">
<header className="docforge-templates-header">
<h1 data-i18n="templates.authoring.heading">Vorlagen</h1>
<p
className="docforge-templates-intro"
data-i18n="templates.authoring.intro">
Lade eine Word-Vorlage hoch, markiere Stellen und setze Variablen ein.
</p>
</header>
{/* Upload a new base .docx */}
<section className="docforge-upload" id="docforge-upload">
<h2 data-i18n="templates.authoring.upload.title">Neue Vorlage hochladen</h2>
<form id="docforge-upload-form" className="entity-form">
<label className="entity-form-row">
<span data-i18n="templates.authoring.upload.file">Word-Datei (.docx)</span>
<input type="file" name="file" accept=".docx,.dotx,.docm,.dotm" required />
</label>
<label className="entity-form-row">
<span data-i18n="templates.authoring.upload.name_de">Name (DE)</span>
<input type="text" name="name_de" className="entity-form-input" required />
</label>
<label className="entity-form-row">
<span data-i18n="templates.authoring.upload.name_en">Name (EN)</span>
<input type="text" name="name_en" className="entity-form-input" required />
</label>
<label className="entity-form-row">
<span data-i18n="templates.authoring.upload.firm">Kanzlei (optional)</span>
<input type="text" name="firm" className="entity-form-input" />
</label>
<button type="submit" className="btn-primary" data-i18n="templates.authoring.upload.submit">
Hochladen
</button>
<span className="docforge-upload-status" id="docforge-upload-status" />
</form>
</section>
{/* Existing templates */}
<section className="docforge-template-list-wrap">
<h2 data-i18n="templates.authoring.list.title">Vorhandene Vorlagen</h2>
<ul className="entity-table docforge-template-list" id="docforge-template-list" />
</section>
{/* Authoring workspace — hidden until a template is opened. */}
<section className="docforge-workspace" id="docforge-workspace" hidden>
<header className="docforge-workspace-header">
<h2 id="docforge-workspace-title" />
<span className="docforge-workspace-hint" data-i18n="templates.authoring.workspace.hint">
Text markieren, dann eine Variable wählen, um einen Platzhalter zu setzen.
</span>
<span className="docforge-workspace-status" id="docforge-workspace-status" />
</header>
<div className="docforge-workspace-grid">
{/* Variable palette (left) — populated from the catalogue. */}
<aside className="docforge-palette" id="docforge-palette" />
{/* Run-addressable preview (center) — selection target. */}
<div className="docforge-preview" id="docforge-preview" />
{/* Placed slots (right). */}
<aside className="docforge-slots">
<h3 data-i18n="templates.authoring.slots.title">Platzhalter</h3>
<ul className="docforge-slot-list" id="docforge-slot-list" />
</aside>
</div>
</section>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/templates-authoring.js"></script>
</body>
</html>
);
}

View File

@@ -758,6 +758,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// t-paliad-349 docforge slice 6 — template authoring surface
// (upload base .docx → place variable slots → save). Admin-only,
// firm-shared catalog like submission_bases.
protected.HandleFunc("GET /admin/templates", adminGate(users, gateOnboarded(handleTemplatesAuthoringPage)))
protected.HandleFunc("GET /api/admin/templates", adminGate(users, handleListTemplates))
protected.HandleFunc("POST /api/admin/templates", adminGate(users, handleUploadTemplate))
protected.HandleFunc("GET /api/admin/templates/{id}", adminGate(users, handleGetTemplateAuthoring))

View File

@@ -103,6 +103,12 @@ func requireTemplateStore(w http.ResponseWriter) bool {
return true
}
// handleTemplatesAuthoringPage serves the authoring page shell. The client
// bundle hydrates the list, upload, preview, palette, and slots.
func handleTemplatesAuthoringPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/templates-authoring.html")
}
// handleListTemplates backs GET /api/admin/templates.
func handleListTemplates(w http.ResponseWriter, r *http.Request) {
if !requireTemplateStore(w) {