feat(submissions): t-paliad-370 S3 — Vorschau base-preview modal scaffold
docforge UX slice S3 (PRD §2.1 / §3 S3). A shared, body-attached modal lets
the lawyer browse template bases, flip output language + data-mode, and — in
the editor — commit a base with 'Diese Basis verwenden', replacing the blind
<select> as the chooser. Opened from the editor 👁 button AND both catalog row
kebabs (the S1/S2 stubs are now live).
Body renders the EXISTING structural preview on cheap rails; the body region is
swappable so S4 drops a truthful .docx→image render behind the same modal +
endpoint shape. Spinner + pager are the shell S4 expects.
Backend (minimal, no truthful render — that's S4):
- GET /api/submission-preview?base&code&lang&data&draft?&project? → {preview_html}.
Draft+mine uses the draft's resolved bag (previews a base the draft has NOT
committed to — no persistence); else a fresh context bag (project-less fill,
t-paliad-364). data=sample swaps a sample missing-marker so unresolved keys
render readable sample text, not [KEIN WERT].
- SubmissionDraftService.RenderPreviewWithMarker + RenderContextPreviewHTML
(thin: reuse BuildRenderBag / vars.Build + RenderHTML).
- resolvePreviewTemplateBytes: tpl:→carrier, base_id→bytes IFF it carries merge
placeholders, else fall back to the code's merge template (anchors-only
Composer bases can't be shown as structural HTML — their letterhead/styling
surfaces in S4's truthful render). sampleMissingMarker by key suffix.
- Unit tests: sampleMissingMarker + normalizePreviewLang.
Frontend:
- New shared client/base-preview-modal.ts (built once, body-attached; serves
editor + project tab + global picker). Base-switcher, DE/EN + meine/Beispiel
toggles, swappable body, spinner, pager stub, conditional 'Diese Basis
verwenden' (editor commits via onBaseChange; catalog = look-only).
- Editor 👁 button un-stubbed + wired; both catalog kebab 'Vorschau' items
un-stubbed + wired. global.css: .base-preview-* modal styles.
bun build (i18n scan clean) + go vet ./... + go test ./... (15 ok, 0 fail).
This commit is contained in:
278
frontend/src/client/base-preview-modal.ts
Normal file
278
frontend/src/client/base-preview-modal.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
// Shared base-preview modal (t-paliad-370 S3, PRD §2.1).
|
||||
//
|
||||
// Opened from the editor's 👁 Vorschau button and from the catalog row
|
||||
// kebabs ("Vorschau Vorlagenbasis"). Lets the lawyer browse template bases,
|
||||
// flip the output language + data-mode, and — in the editor — commit a base
|
||||
// with "Diese Basis verwenden", replacing the blind <select> as the chooser.
|
||||
//
|
||||
// S3 renders the EXISTING structural preview HTML (GET /api/submission-preview)
|
||||
// in a swappable body region. S4 swaps that region for a truthful .docx→image
|
||||
// render behind the same modal + endpoint shape; the pager + spinner are the
|
||||
// shell that work already expects.
|
||||
//
|
||||
// Built once and body-attached so a single modal serves all three host pages
|
||||
// (editor, project Schriftsätze tab, global picker) without triplicated SSR.
|
||||
|
||||
interface BaseRow { id: string; label_de: string; label_en: string }
|
||||
interface TemplateRow { version_id?: string; name_de: string; name_en: string }
|
||||
|
||||
export interface BasePreviewOpts {
|
||||
/** submission_code — drives caption + the fallback template. */
|
||||
code: string;
|
||||
/** Initial output language. */
|
||||
lang: string;
|
||||
/** Editor context: preview against this draft's resolved data. */
|
||||
draftId?: string;
|
||||
/** Catalog (project tab) context: scope sample/own data to this project. */
|
||||
projectId?: string | null;
|
||||
/** Current selection (base_id | "tpl:"+version_id | ""), preselected. */
|
||||
currentBase?: string;
|
||||
/** Default data-mode; falls back to "mine" with a draft, else "sample". */
|
||||
defaultData?: "mine" | "sample";
|
||||
/** Editor: commit the chosen base. Omitted ⇒ catalog (preview-only). */
|
||||
onApply?: (baseValue: string) => void;
|
||||
}
|
||||
|
||||
interface ModalState {
|
||||
opts: BasePreviewOpts;
|
||||
selectedBase: string;
|
||||
lang: string;
|
||||
data: "mine" | "sample";
|
||||
}
|
||||
|
||||
let modal: HTMLElement | null = null;
|
||||
let bodyRegion: HTMLElement | null = null;
|
||||
let baseSelect: HTMLSelectElement | null = null;
|
||||
let applyBtn: HTMLButtonElement | null = null;
|
||||
let pager: HTMLElement | null = null;
|
||||
let state: ModalState | null = null;
|
||||
|
||||
// Base/template catalog, fetched once and reused across opens.
|
||||
let bases: BaseRow[] = [];
|
||||
let templates: TemplateRow[] = [];
|
||||
let catalogLoaded = false;
|
||||
let reqSeq = 0; // guards against out-of-order preview responses
|
||||
|
||||
function isEN(): boolean {
|
||||
return document.documentElement.lang === "en";
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function ensureBuilt(): void {
|
||||
if (modal) return;
|
||||
|
||||
modal = document.createElement("div");
|
||||
modal.className = "base-preview-overlay";
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.style.display = "none";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "base-preview-card";
|
||||
modal.appendChild(card);
|
||||
|
||||
// Header: base switcher + language toggle + data toggle + close.
|
||||
const header = document.createElement("header");
|
||||
header.className = "base-preview-header";
|
||||
header.innerHTML = `
|
||||
<div class="base-preview-controls">
|
||||
<label class="base-preview-field">
|
||||
<span>${esc(isEN() ? "Template base" : "Vorlagenbasis")}</span>
|
||||
<select class="base-preview-base"></select>
|
||||
</label>
|
||||
<div class="base-preview-toggle base-preview-lang" role="group" aria-label="${esc(isEN() ? "Language" : "Sprache")}">
|
||||
<button type="button" data-lang="de">DE</button>
|
||||
<button type="button" data-lang="en">EN</button>
|
||||
</div>
|
||||
<div class="base-preview-toggle base-preview-data" role="group" aria-label="${esc(isEN() ? "Data" : "Daten")}">
|
||||
<button type="button" data-data="mine">${esc(isEN() ? "My data" : "Meine Daten")}</button>
|
||||
<button type="button" data-data="sample">${esc(isEN() ? "Sample" : "Beispiel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="base-preview-close" aria-label="${esc(isEN() ? "Close" : "Schließen")}">×</button>
|
||||
`;
|
||||
card.appendChild(header);
|
||||
|
||||
// Swappable body region — S3 structural HTML, S4 truthful page image.
|
||||
bodyRegion = document.createElement("div");
|
||||
bodyRegion.className = "base-preview-body";
|
||||
card.appendChild(bodyRegion);
|
||||
|
||||
// Footer: pager (S4) + apply.
|
||||
const footer = document.createElement("footer");
|
||||
footer.className = "base-preview-footer";
|
||||
pager = document.createElement("div");
|
||||
pager.className = "base-preview-pager";
|
||||
footer.appendChild(pager);
|
||||
applyBtn = document.createElement("button");
|
||||
applyBtn.type = "button";
|
||||
applyBtn.className = "btn-primary btn-cta-lime btn-small base-preview-apply";
|
||||
applyBtn.textContent = isEN() ? "Use this base" : "Diese Basis verwenden";
|
||||
footer.appendChild(applyBtn);
|
||||
card.appendChild(footer);
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
baseSelect = header.querySelector<HTMLSelectElement>(".base-preview-base");
|
||||
|
||||
// Wiring.
|
||||
baseSelect?.addEventListener("change", () => {
|
||||
if (!state || !baseSelect) return;
|
||||
state.selectedBase = baseSelect.value;
|
||||
renderPreview();
|
||||
});
|
||||
header.querySelectorAll<HTMLButtonElement>(".base-preview-lang button").forEach((b) => {
|
||||
b.addEventListener("click", () => {
|
||||
if (!state) return;
|
||||
state.lang = b.dataset.lang === "en" ? "en" : "de";
|
||||
paintToggles();
|
||||
renderPreview();
|
||||
});
|
||||
});
|
||||
header.querySelectorAll<HTMLButtonElement>(".base-preview-data button").forEach((b) => {
|
||||
b.addEventListener("click", () => {
|
||||
if (!state) return;
|
||||
state.data = b.dataset.data === "sample" ? "sample" : "mine";
|
||||
paintToggles();
|
||||
renderPreview();
|
||||
});
|
||||
});
|
||||
header.querySelector<HTMLButtonElement>(".base-preview-close")?.addEventListener("click", close);
|
||||
applyBtn.addEventListener("click", () => {
|
||||
if (!state || !state.opts.onApply) return;
|
||||
state.opts.onApply(state.selectedBase);
|
||||
close();
|
||||
});
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) close();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && modal && modal.style.display !== "none") close();
|
||||
});
|
||||
}
|
||||
|
||||
function paintToggles(): void {
|
||||
if (!modal || !state) return;
|
||||
modal.querySelectorAll<HTMLButtonElement>(".base-preview-lang button").forEach((b) => {
|
||||
b.classList.toggle("is-active", b.dataset.lang === state!.lang);
|
||||
});
|
||||
modal.querySelectorAll<HTMLButtonElement>(".base-preview-data button").forEach((b) => {
|
||||
b.classList.toggle("is-active", b.dataset.data === state!.data);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCatalog(): Promise<void> {
|
||||
if (catalogLoaded) return;
|
||||
try {
|
||||
const [bRes, tRes] = await Promise.all([
|
||||
fetch("/api/submission-bases", { credentials: "include" }),
|
||||
fetch("/api/templates", { credentials: "include" }),
|
||||
]);
|
||||
if (bRes.ok) bases = ((await bRes.json()) as { bases?: BaseRow[] }).bases ?? [];
|
||||
if (tRes.ok) {
|
||||
templates = (((await tRes.json()) as { templates?: TemplateRow[] }).templates ?? [])
|
||||
.filter((t) => !!t.version_id);
|
||||
}
|
||||
} catch {
|
||||
/* leave catalog empty — the modal still previews the current/fallback base */
|
||||
}
|
||||
catalogLoaded = true;
|
||||
}
|
||||
|
||||
function paintBaseSelect(): void {
|
||||
if (!baseSelect || !state) return;
|
||||
baseSelect.innerHTML = "";
|
||||
|
||||
const fallback = document.createElement("option");
|
||||
fallback.value = "";
|
||||
fallback.textContent = isEN() ? "— Standard template —" : "— Standardvorlage —";
|
||||
baseSelect.appendChild(fallback);
|
||||
|
||||
for (const b of bases) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = b.id;
|
||||
opt.textContent = isEN() ? b.label_en : b.label_de;
|
||||
baseSelect.appendChild(opt);
|
||||
}
|
||||
if (templates.length > 0) {
|
||||
const group = document.createElement("optgroup");
|
||||
group.label = isEN() ? "Uploaded templates" : "Hochgeladene Vorlagen";
|
||||
for (const t of templates) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "tpl:" + t.version_id;
|
||||
opt.textContent = isEN() ? t.name_en : t.name_de;
|
||||
group.appendChild(opt);
|
||||
}
|
||||
baseSelect.appendChild(group);
|
||||
}
|
||||
baseSelect.value = state.selectedBase;
|
||||
}
|
||||
|
||||
async function renderPreview(): Promise<void> {
|
||||
if (!bodyRegion || !state) return;
|
||||
const seq = ++reqSeq;
|
||||
bodyRegion.innerHTML = `<div class="base-preview-spinner">${esc(isEN() ? "Rendering…" : "Wird gerendert…")}</div>`;
|
||||
if (pager) pager.textContent = "";
|
||||
|
||||
const params = new URLSearchParams({
|
||||
base: state.selectedBase,
|
||||
code: state.opts.code,
|
||||
lang: state.lang,
|
||||
data: state.data,
|
||||
});
|
||||
if (state.opts.draftId) {
|
||||
params.set("draft", state.opts.draftId);
|
||||
} else if (state.opts.projectId) {
|
||||
params.set("project", state.opts.projectId);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/submission-preview?${params.toString()}`, { credentials: "include" });
|
||||
if (seq !== reqSeq) return; // a newer request superseded this one
|
||||
if (!res.ok) {
|
||||
bodyRegion.innerHTML = `<div class="base-preview-error">${esc(isEN() ? "Preview unavailable." : "Vorschau nicht verfügbar.")}</div>`;
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as { preview_html?: string };
|
||||
if (seq !== reqSeq) return;
|
||||
bodyRegion.innerHTML = `<div class="base-preview-page">${data.preview_html ?? ""}</div>`;
|
||||
// S3 renders one flowing HTML block; the pager is the S4 page-image shell.
|
||||
if (pager) pager.textContent = isEN() ? "Structural preview" : "Struktur-Vorschau";
|
||||
} catch {
|
||||
if (seq !== reqSeq) return;
|
||||
bodyRegion.innerHTML = `<div class="base-preview-error">${esc(isEN() ? "Preview failed." : "Vorschau fehlgeschlagen.")}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
if (modal) modal.style.display = "none";
|
||||
state = null;
|
||||
}
|
||||
|
||||
/** Open the base-preview modal. See BasePreviewOpts. */
|
||||
export async function openBasePreview(opts: BasePreviewOpts): Promise<void> {
|
||||
ensureBuilt();
|
||||
if (!modal || !applyBtn) return;
|
||||
|
||||
state = {
|
||||
opts,
|
||||
selectedBase: opts.currentBase ?? "",
|
||||
lang: opts.lang === "en" ? "en" : "de",
|
||||
data: opts.defaultData ?? (opts.draftId ? "mine" : "sample"),
|
||||
};
|
||||
|
||||
// Apply button only when the caller can commit (editor); catalog = look-only.
|
||||
applyBtn.style.display = opts.onApply ? "" : "none";
|
||||
|
||||
modal.style.display = "";
|
||||
paintToggles();
|
||||
await loadCatalog();
|
||||
if (!state) return; // closed while loading
|
||||
paintBaseSelect();
|
||||
void renderPreview();
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { openBasePreview } from "./base-preview-modal";
|
||||
import { escapeHtml, cssEscape } from "../lib/docforge-editor/dom";
|
||||
import { fetchVariableCatalogue, labelMap } from "../lib/docforge-editor/catalogue";
|
||||
|
||||
@@ -669,6 +670,29 @@ function paintNameRow(): void {
|
||||
|
||||
const exportBtn = document.getElementById("submission-draft-export-btn") as HTMLButtonElement | null;
|
||||
if (exportBtn) exportBtn.onclick = () => onExport(exportBtn);
|
||||
|
||||
// t-paliad-370 S3 — 👁 Vorschau opens the base-preview modal for the
|
||||
// current draft (its data), with the base-switcher as the chooser; picking
|
||||
// "Diese Basis verwenden" commits via the existing base-swap path.
|
||||
const previewBaseBtn = document.getElementById("submission-draft-preview-base-btn") as HTMLButtonElement | null;
|
||||
if (previewBaseBtn) {
|
||||
previewBaseBtn.onclick = () => {
|
||||
if (!state.view) return;
|
||||
const d = state.view.draft;
|
||||
const current = d.template_version_id
|
||||
? "tpl:" + d.template_version_id
|
||||
: (d.base_id ?? "");
|
||||
void openBasePreview({
|
||||
code: d.submission_code,
|
||||
lang: d.language || state.view.lang || "de",
|
||||
draftId: d.id,
|
||||
projectId: d.project_id,
|
||||
currentBase: current,
|
||||
defaultData: "mine",
|
||||
onApply: (value) => { void onBaseChange(value); },
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-277 — "Aus Projekt importieren" + last-imported-at stamp.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { initI18n, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { createRowActionMenu } from "./row-action-menu";
|
||||
import { openBasePreview } from "./base-preview-modal";
|
||||
|
||||
// t-paliad-243 — client for /submissions/new. Fetches the
|
||||
// cross-proceeding submission catalog, groups it by proceeding, filters
|
||||
@@ -197,12 +198,14 @@ function renderTable(): void {
|
||||
onSelect: () => { if (code) openProjectPicker(code); },
|
||||
},
|
||||
{
|
||||
// S3 wires this to the truthful base-preview modal (PRD §2).
|
||||
// Stubbed disabled until then so the kebab structure ships in S1.
|
||||
// t-paliad-370 S3 — eyeball the base before starting a draft.
|
||||
// Look-only (no draft, no project): sample data fills the page.
|
||||
label: isEN() ? "Preview template base" : "Vorschau Vorlagenbasis",
|
||||
disabled: true,
|
||||
title: isEN() ? "Coming soon" : "Bald verfügbar",
|
||||
onSelect: () => {},
|
||||
onSelect: () => void openBasePreview({
|
||||
code,
|
||||
lang: isEN() ? "en" : "de",
|
||||
defaultData: "sample",
|
||||
}),
|
||||
},
|
||||
],
|
||||
{ ariaLabel: isEN() ? "More actions" : "Weitere Aktionen" },
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// preview), consistent with the global picker.
|
||||
|
||||
import { createRowActionMenu } from "./row-action-menu";
|
||||
import { openBasePreview } from "./base-preview-modal";
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
@@ -163,12 +164,16 @@ function render(data: SubmissionListResponse): void {
|
||||
onSelect: () => void generateAndDownload(code, projectID),
|
||||
},
|
||||
{
|
||||
// S3 wires this to the truthful base-preview modal (PRD §2).
|
||||
// Stubbed disabled until then so the kebab structure ships in S1.
|
||||
// t-paliad-370 S3 — eyeball the base before opening the editor.
|
||||
// Look-only here (no draft yet): no onApply, scoped to the project
|
||||
// so the preview can use its data. Sample data fills the gaps.
|
||||
label: isEN ? "Preview template base" : "Vorschau Vorlagenbasis",
|
||||
disabled: true,
|
||||
title: isEN ? "Coming soon" : "Bald verfügbar",
|
||||
onSelect: () => {},
|
||||
onSelect: () => void openBasePreview({
|
||||
code,
|
||||
lang: isEN ? "en" : "de",
|
||||
projectId: projectID,
|
||||
defaultData: "sample",
|
||||
}),
|
||||
},
|
||||
],
|
||||
{ ariaLabel: isEN ? "More actions" : "Weitere Aktionen" },
|
||||
|
||||
@@ -6288,6 +6288,149 @@ dialog.modal::backdrop {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* t-paliad-370 S3 — base-preview modal (PRD §2.1). Body-attached, shared by
|
||||
the editor + both catalog surfaces. The body region is swappable: S3 shows
|
||||
structural HTML; S4 swaps in a truthful .docx page image. */
|
||||
.base-preview-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--color-overlay-modal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1100;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.base-preview-card {
|
||||
background: var(--color-surface);
|
||||
border-radius: calc(var(--radius) * 1.5);
|
||||
box-shadow: var(--shadow-lg);
|
||||
width: 100%;
|
||||
max-width: 820px;
|
||||
max-height: 92vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.base-preview-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.base-preview-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.base-preview-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.base-preview-field > span {
|
||||
font-size: 0.8em;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.base-preview-field select {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-elev-1);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.base-preview-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.base-preview-toggle button {
|
||||
padding: 0.4rem 0.7rem;
|
||||
border: none;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.base-preview-toggle button + button {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.base-preview-toggle button.is-active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.base-preview-close {
|
||||
flex: 0 0 auto;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.base-preview-body {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
/* The rendered page sits on a paper-like sheet so the structural preview
|
||||
reads as a document, prefiguring the S4 page image. */
|
||||
.base-preview-page {
|
||||
background: #fff;
|
||||
color: #111;
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 3rem;
|
||||
border-radius: 2px;
|
||||
box-shadow: var(--shadow-md);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.base-preview-spinner,
|
||||
.base-preview-error {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.base-preview-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1.25rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.base-preview-pager {
|
||||
font-size: 0.8em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.submission-draft-toolbar > .submission-draft-switcher,
|
||||
.submission-draft-toolbar > .submission-draft-name-row,
|
||||
|
||||
@@ -157,10 +157,9 @@ export function renderSubmissionDraft(): string {
|
||||
type="button"
|
||||
id="submission-draft-preview-base-btn"
|
||||
className="btn-icon submission-draft-preview-base-btn"
|
||||
disabled
|
||||
aria-label="Vorschau Vorlagenbasis"
|
||||
data-i18n-title="submissions.draft.base.preview.soon"
|
||||
title="Bald verfügbar">
|
||||
data-i18n-title="submissions.draft.base.preview"
|
||||
title="Vorschau Vorlagenbasis">
|
||||
👁
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -478,6 +478,10 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// the sidebar picker. Wide-open SELECT (any authenticated user);
|
||||
// admin mutations are not exposed yet (Slice C).
|
||||
protected.HandleFunc("GET /api/submission-bases", handleListSubmissionBases)
|
||||
// t-paliad-370 S3 — structural HTML preview of a base/template for the
|
||||
// base-preview modal (draft-optional; S4 returns a truthful page image
|
||||
// behind the same query shape).
|
||||
protected.HandleFunc("GET /api/submission-preview", handleSubmissionPreview)
|
||||
// t-paliad-349 (m/paliad#157) docforge slice 5 — the variable
|
||||
// catalogue (Go-side SSOT) the sidebar form + authoring palette read.
|
||||
protected.HandleFunc("GET /api/docforge/variables", handleDocforgeVariables)
|
||||
|
||||
@@ -560,6 +560,191 @@ func handlePreviewSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"preview_html": html})
|
||||
}
|
||||
|
||||
// handleSubmissionPreview renders a STRUCTURAL HTML preview of a chosen base
|
||||
// (or uploaded template) for the base-preview modal (t-paliad-370 S3). It is
|
||||
// the cheap-rails counterpart of the truthful .docx→image render added in S4 —
|
||||
// same query shape, so S4 can return a real page image behind the same modal.
|
||||
//
|
||||
// GET /api/submission-preview
|
||||
// ?base=<base_id uuid | "tpl:"+version_id | ""> which template to render
|
||||
// &code=<submission_code> caption + fallback template
|
||||
// &lang=de|en
|
||||
// &data=mine|sample missing-value rendering
|
||||
// &draft=<draft uuid> optional: use the draft's bag
|
||||
// &project=<project uuid> optional (no draft): project data
|
||||
//
|
||||
// No persistence. With a draft + data=mine the draft's resolved bag is used, so
|
||||
// the modal previews a base the draft has NOT committed to. Otherwise a fresh
|
||||
// context bag is built for (user, project?, code, lang). data=sample swaps the
|
||||
// missing-marker so unresolved placeholders render readable sample text instead
|
||||
// of [KEIN WERT] (a fresh/project-less draft then previews a full page).
|
||||
func handleSubmissionPreview(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionDraft == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
baseRef := strings.TrimSpace(q.Get("base"))
|
||||
code := strings.TrimSpace(q.Get("code"))
|
||||
lang := normalizePreviewLang(q.Get("lang"))
|
||||
sample := strings.EqualFold(strings.TrimSpace(q.Get("data")), "sample")
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), submissionDraftPreviewTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Editor context: load the draft so we can use its resolved bag and
|
||||
// default code/lang from it.
|
||||
var draft *services.SubmissionDraft
|
||||
if draftRef := strings.TrimSpace(q.Get("draft")); draftRef != "" {
|
||||
id, perr := uuid.Parse(draftRef)
|
||||
if perr != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid draft id"})
|
||||
return
|
||||
}
|
||||
d, derr := dbSvc.submissionDraft.Get(ctx, uid, id)
|
||||
if derr != nil {
|
||||
writeSubmissionDraftServiceError(w, derr)
|
||||
return
|
||||
}
|
||||
draft = d
|
||||
if code == "" {
|
||||
code = draft.SubmissionCode
|
||||
}
|
||||
if q.Get("lang") == "" {
|
||||
lang = normalizePreviewLang(draft.Language)
|
||||
}
|
||||
}
|
||||
if code == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "code required"})
|
||||
return
|
||||
}
|
||||
|
||||
tplBytes, err := resolvePreviewTemplateBytes(ctx, baseRef, code, lang)
|
||||
if err != nil {
|
||||
log.Printf("submission_preview: template resolve (base=%q code=%q): %v", baseRef, code, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
var missing docforge.MissingPlaceholderFn
|
||||
if sample {
|
||||
missing = sampleMissingMarker(lang)
|
||||
}
|
||||
|
||||
if draft != nil {
|
||||
html, rerr := dbSvc.submissionDraft.RenderPreviewWithMarker(ctx, draft, tplBytes, missing)
|
||||
if rerr != nil {
|
||||
log.Printf("submission_preview: render draft=%s: %v", draft.ID, rerr)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"preview_html": html})
|
||||
return
|
||||
}
|
||||
|
||||
// Catalog context (no draft): optional project scopes the data.
|
||||
var projectID *uuid.UUID
|
||||
if projectRef := strings.TrimSpace(q.Get("project")); projectRef != "" {
|
||||
if pid, perr := uuid.Parse(projectRef); perr == nil {
|
||||
projectID = &pid
|
||||
}
|
||||
}
|
||||
html, rerr := dbSvc.submissionDraft.RenderContextPreviewHTML(ctx, uid, projectID, code, lang, tplBytes, missing)
|
||||
if rerr != nil {
|
||||
log.Printf("submission_preview: render context (code=%q project=%v): %v", code, projectID, rerr)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"preview_html": html})
|
||||
}
|
||||
|
||||
// normalizePreviewLang clamps the preview lang query to "de" (default) or "en".
|
||||
func normalizePreviewLang(s string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(s), "en") {
|
||||
return "en"
|
||||
}
|
||||
return "de"
|
||||
}
|
||||
|
||||
// resolvePreviewTemplateBytes resolves the template bytes the base-preview modal
|
||||
// should render for a base reference. Uploaded templates ("tpl:<v>") render
|
||||
// their carrier directly; a Gitea base_id renders its bytes only if they carry
|
||||
// {{merge placeholders}} — anchors-only Composer bases (the styled HL skeletons)
|
||||
// can't be shown as structural HTML, so they fall back to the submission_code's
|
||||
// merge template (matching today's preview-pane semantics). The per-base
|
||||
// letterhead/styling becomes visible in S4's truthful .docx render.
|
||||
func resolvePreviewTemplateBytes(ctx context.Context, baseRef, code, lang string) ([]byte, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(baseRef, "tpl:"):
|
||||
if dbSvc.templateStore != nil {
|
||||
tmpl, err := dbSvc.templateStore.GetVersion(ctx, strings.TrimPrefix(baseRef, "tpl:"))
|
||||
switch {
|
||||
case err == nil:
|
||||
return tmpl.CarrierBytes, nil
|
||||
case !errors.Is(err, docforge.ErrTemplateNotFound):
|
||||
return nil, err
|
||||
}
|
||||
// missing pinned version → fall through to the code template
|
||||
}
|
||||
case baseRef != "":
|
||||
if dbSvc.submissionBase != nil {
|
||||
if id, perr := uuid.Parse(baseRef); perr == nil {
|
||||
if base, berr := dbSvc.submissionBase.GetByID(ctx, id); berr == nil {
|
||||
if b, _, ferr := fetchComposerBaseBytes(ctx, base); ferr == nil && docx.HasMergePlaceholders(b) {
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
b, _, _, err := resolveSubmissionTemplate(ctx, code, lang)
|
||||
return b, err
|
||||
}
|
||||
|
||||
// sampleMissingMarker renders unresolved placeholders as readable sample text
|
||||
// (the modal's "Beispiel" data-mode) instead of [KEIN WERT], so a fresh or
|
||||
// project-less draft previews a full page. Matching is by key suffix/substring
|
||||
// so it is robust to the exact namespace; unknown keys get a generic sample
|
||||
// token. Cosmetic only — S4's truthful render fills sample data end-to-end.
|
||||
func sampleMissingMarker(lang string) docforge.MissingPlaceholderFn {
|
||||
en := strings.EqualFold(lang, "en")
|
||||
return func(key string) string {
|
||||
k := strings.ToLower(key)
|
||||
switch {
|
||||
case strings.Contains(k, "case_number"):
|
||||
return "4c O 12/23"
|
||||
case strings.Contains(k, "claimant") && strings.Contains(k, "name"):
|
||||
if en {
|
||||
return "Sample Claimant Ltd."
|
||||
}
|
||||
return "Mustermandant GmbH"
|
||||
case strings.Contains(k, "defendant") && strings.Contains(k, "name"):
|
||||
if en {
|
||||
return "Sample Defendant Inc."
|
||||
}
|
||||
return "Musterbeklagte AG"
|
||||
case strings.Contains(k, "court"):
|
||||
if en {
|
||||
return "Düsseldorf Regional Court"
|
||||
}
|
||||
return "Landgericht Düsseldorf"
|
||||
default:
|
||||
if en {
|
||||
return "Sample"
|
||||
}
|
||||
return "Beispiel"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleExportSubmissionDraft merges the draft into the .docx template
|
||||
// and streams the result. Writes one system_audit_log row and one
|
||||
// project_events row per successful export.
|
||||
|
||||
50
internal/handlers/submission_preview_test.go
Normal file
50
internal/handlers/submission_preview_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package handlers
|
||||
|
||||
import "testing"
|
||||
|
||||
// t-paliad-370 S3 — the base-preview modal's "Beispiel" data-mode renders
|
||||
// unresolved placeholders as readable sample text instead of [KEIN WERT].
|
||||
// Matching is by key suffix/substring so it survives namespace changes;
|
||||
// unknown keys fall back to a generic token.
|
||||
func TestSampleMissingMarker(t *testing.T) {
|
||||
de := sampleMissingMarker("de")
|
||||
en := sampleMissingMarker("en")
|
||||
|
||||
cases := []struct {
|
||||
key string
|
||||
wantDE string
|
||||
wantEN string
|
||||
}{
|
||||
{"project.case_number", "4c O 12/23", "4c O 12/23"},
|
||||
{"parties.claimant.0.name", "Mustermandant GmbH", "Sample Claimant Ltd."},
|
||||
{"parties.defendant.0.name", "Musterbeklagte AG", "Sample Defendant Inc."},
|
||||
{"project.court", "Landgericht Düsseldorf", "Düsseldorf Regional Court"},
|
||||
{"some.unknown.key", "Beispiel", "Sample"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := de(c.key); got != c.wantDE {
|
||||
t.Errorf("de marker for %q = %q, want %q", c.key, got, c.wantDE)
|
||||
}
|
||||
if got := en(c.key); got != c.wantEN {
|
||||
t.Errorf("en marker for %q = %q, want %q", c.key, got, c.wantEN)
|
||||
}
|
||||
}
|
||||
|
||||
// A sample marker must never emit the [KEIN WERT] / [NO VALUE] form —
|
||||
// that's the whole point of the "Beispiel" mode.
|
||||
if got := de("anything.at.all"); got == "" {
|
||||
t.Error("de marker returned empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePreviewLang(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"de": "de", "DE": "de", "": "de", " fr ": "de", "garbage": "de",
|
||||
"en": "en", "EN": "en", " en ": "en",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizePreviewLang(in); got != want {
|
||||
t.Errorf("normalizePreviewLang(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1013,11 +1013,48 @@ func (s *SubmissionDraftService) BuildRenderBag(ctx context.Context, draft *Subm
|
||||
// for the draft-editor preview pane. Read-only; emits one <p> per <w:p>
|
||||
// with <strong>/<em> spans for runs flagged bold/italic.
|
||||
func (s *SubmissionDraftService) RenderPreview(ctx context.Context, draft *SubmissionDraft, templateBytes []byte) (string, error) {
|
||||
return s.RenderPreviewWithMarker(ctx, draft, templateBytes, nil)
|
||||
}
|
||||
|
||||
// RenderPreviewWithMarker is RenderPreview with a pluggable missing-value
|
||||
// marker. The base-preview modal (t-paliad-370 S3) passes a sample marker for
|
||||
// its "Beispiel" data-mode so a draft's unresolved placeholders render readable
|
||||
// sample text instead of [KEIN WERT]. nil marker == the default DE/EN marker.
|
||||
// It renders arbitrary templateBytes against the draft's resolved bag WITHOUT
|
||||
// persisting, so the modal can preview a base the draft hasn't committed to.
|
||||
func (s *SubmissionDraftService) RenderPreviewWithMarker(ctx context.Context, draft *SubmissionDraft, templateBytes []byte, missing MissingPlaceholderFn) (string, error) {
|
||||
bag, resolved, err := s.BuildRenderBag(ctx, draft)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.renderer.RenderHTML(templateBytes, bag, DefaultMissingMarker(resolved.Lang))
|
||||
if missing == nil {
|
||||
missing = DefaultMissingMarker(resolved.Lang)
|
||||
}
|
||||
return s.renderer.RenderHTML(templateBytes, bag, missing)
|
||||
}
|
||||
|
||||
// RenderContextPreviewHTML builds a fresh variable bag for (user, project?,
|
||||
// code, lang) and renders templateBytes to structural preview HTML — the
|
||||
// draft-less counterpart of RenderPreviewWithMarker. Mirrors
|
||||
// RenderProjectSubmission but (a) returns HTML, (b) tolerates a nil project
|
||||
// (project-less fill: caption resolves from the submission_code's proceeding,
|
||||
// t-paliad-364), and (c) takes a pluggable missing marker. Used by the
|
||||
// base-preview modal opened from the catalog, where no draft exists yet
|
||||
// (t-paliad-370 S3). No persistence.
|
||||
func (s *SubmissionDraftService) RenderContextPreviewHTML(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, code, lang string, templateBytes []byte, missing MissingPlaceholderFn) (string, error) {
|
||||
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
|
||||
UserID: userID,
|
||||
ProjectID: projectID,
|
||||
SubmissionCode: code,
|
||||
Lang: normalizeDraftLanguage(lang),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if missing == nil {
|
||||
missing = DefaultMissingMarker(resolved.Lang)
|
||||
}
|
||||
return s.renderer.RenderHTML(templateBytes, resolved.Placeholders, missing)
|
||||
}
|
||||
|
||||
// Export renders the merged .docx for download. Returns the bytes, the
|
||||
|
||||
Reference in New Issue
Block a user