Two related editor polish fixes.
(A) Autosave-refresh focus preservation
paintVariables() replaces every input via innerHTML, blowing away
the focused-input reference and dropping the cursor mid-edit. Fix:
capture the active variable input's data-var key + selectionStart/
End/Direction before the repaint, restore on the new element after
(by data-var lookup + setSelectionRange). Cursor stays put across
autosave, rename, and reset cycles. Works for <input> and
<textarea> via the shared selectionRange contract.
(B) Click variable in preview → jump to sidebar input
Go renderer wraps every substituted placeholder value in the HTML
preview with <span class="draft-var" data-var="key">…</span>.
Implemented via a valueWrapperFn plumbed through
substituteInDocumentXML → substituteInTextNodes /
substituteAcrossRuns → replacePlaceholders. RenderHTML passes
htmlPreviewWrapper which marks values with three PUA sentinels
(U+E100/U+E101/U+E102) that emitTextWithDraftVars converts to the
span pair inside docXMLToHTML. Missing-marker text is wrapped too
so a clicked [KEIN WERT: foo] jumps to the empty field.
Render() (.docx export) passes nil for wrap → output is byte-
identical to pre-261. New test
TestRender_DocxOutputUnchangedByPreviewWrap asserts the .docx never
carries draft-var/data-var markup or PUA sentinels.
Client wireDraftVars() adds .draft-var--has-input only to spans
whose key resolves to a sidebar input — derived variables (e.g.
today.iso) stay non-clickable. Click handler:
scrollIntoView(smooth, center) → focus + select after 50ms →
1.2s lime flash on the row.
Keyboard accessible (Enter / Space) with role=button + aria-label.
CSS adds a subtle lime tint to every .draft-var so the user sees
what was substituted; --has-input layers cursor: pointer + brighter
hover background. Flash animation respects prefers-reduced-motion
via a steps(1, end) fallback.
Tests: TestRenderHTML_ExtractsParagraphsAndFormatting updated to
assert the new span wrap. New tests for missing-marker wrap +
.docx-path-untouched. Go + frontend builds clean.
1105 lines
44 KiB
TypeScript
1105 lines
44 KiB
TypeScript
import { initI18n, t } from "./i18n";
|
||
import { initSidebar } from "./sidebar";
|
||
|
||
// t-paliad-238 Slice A — client bundle for the dedicated
|
||
// Submissions/Schriftsätze editor at
|
||
// /projects/{id}/submissions/{code}/draft
|
||
// /projects/{id}/submissions/{code}/draft/{draft_id}
|
||
//
|
||
// Reads (project_id, submission_code, optional draft_id) from the URL,
|
||
// loads the editor payload (draft row + resolved bag + merged bag +
|
||
// HTML preview), and wires the sidebar / preview / autosave / export.
|
||
//
|
||
// Autosave is debounced 500ms after the lawyer stops typing in any
|
||
// variable input. Each PATCH returns a fresh editor payload, so the
|
||
// preview pane stays in lockstep with the variable overrides.
|
||
|
||
interface SubmissionDraftJSON {
|
||
id: string;
|
||
project_id: string | null;
|
||
submission_code: string;
|
||
user_id: string;
|
||
name: string;
|
||
variables: Record<string, string>;
|
||
last_exported_at?: string | null;
|
||
last_exported_sha?: string | null;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
interface SubmissionRuleSummary {
|
||
name: string;
|
||
name_en: string;
|
||
submission_code: string;
|
||
primary_party?: string;
|
||
event_type?: string;
|
||
legal_source?: string;
|
||
legal_source_pretty?: string;
|
||
}
|
||
|
||
interface SubmissionDraftView {
|
||
draft: SubmissionDraftJSON;
|
||
rule?: SubmissionRuleSummary;
|
||
resolved_bag: Record<string, string>;
|
||
merged_bag: Record<string, string>;
|
||
preview_html: string;
|
||
lang: string;
|
||
has_template: boolean;
|
||
template_missing?: boolean;
|
||
}
|
||
|
||
interface SubmissionDraftListResponse {
|
||
project_id: string;
|
||
submission_code: string;
|
||
drafts: SubmissionDraftJSON[];
|
||
}
|
||
|
||
interface ParsedPath {
|
||
// Project-scoped path: /projects/{id}/submissions/{code}/draft[/{draft_id}].
|
||
// Global path: /submissions/draft/{draft_id} — projectID + submissionCode are derived
|
||
// from the loaded draft row after fetch.
|
||
projectID: string | null;
|
||
submissionCode: string | null;
|
||
draftID?: string;
|
||
// mode tracks the URL shape we were entered from. Affects redirect
|
||
// semantics when we create a new draft or navigate away.
|
||
mode: "project" | "global";
|
||
}
|
||
|
||
const PROJECT_PATH_RE = /^\/projects\/([0-9a-fA-F-]{36})\/submissions\/([^/]+)\/draft(?:\/([0-9a-fA-F-]{36}))?\/?$/;
|
||
const GLOBAL_PATH_RE = /^\/submissions\/draft\/([0-9a-fA-F-]{36})\/?$/;
|
||
|
||
function parsePath(): ParsedPath | null {
|
||
let m = PROJECT_PATH_RE.exec(window.location.pathname);
|
||
if (m) {
|
||
return {
|
||
projectID: m[1],
|
||
submissionCode: decodeURIComponent(m[2]),
|
||
draftID: m[3],
|
||
mode: "project",
|
||
};
|
||
}
|
||
m = GLOBAL_PATH_RE.exec(window.location.pathname);
|
||
if (m) {
|
||
return {
|
||
projectID: null,
|
||
submissionCode: null,
|
||
draftID: m[1],
|
||
mode: "global",
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function isEN(): boolean {
|
||
return document.documentElement.lang === "en";
|
||
}
|
||
|
||
function escapeHtml(s: string): string {
|
||
return s
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// Variable contract — DE/EN labels per dotted-path placeholder.
|
||
// Mirrors the same shape the email-template variables sidebar uses;
|
||
// keeps the lawyer's mental model anchored on the same vocabulary.
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
interface VariableLabel {
|
||
de: string;
|
||
en: string;
|
||
}
|
||
|
||
interface VariableGroup {
|
||
id: string;
|
||
label: VariableLabel;
|
||
keys: string[];
|
||
}
|
||
|
||
const VARIABLE_LABELS: Record<string, VariableLabel> = {
|
||
"firm.name": { de: "Kanzlei", en: "Firm" },
|
||
"firm.signature_block": { de: "Signatur-Block", en: "Signature block" },
|
||
"today": { de: "Heute", en: "Today" },
|
||
"today.iso": { de: "Heute (ISO)", en: "Today (ISO)" },
|
||
"today.long_de": { de: "Heute (DE lang)", en: "Today (DE long)" },
|
||
"today.long_en": { de: "Heute (EN lang)", en: "Today (EN long)" },
|
||
"user.display_name": { de: "Bearbeiter", en: "Author" },
|
||
"user.email": { de: "E-Mail", en: "Email" },
|
||
"user.office": { de: "Büro", en: "Office" },
|
||
"project.title": { de: "Projekttitel", en: "Project title" },
|
||
"project.reference": { de: "Aktenzeichen (intern)", en: "Internal reference" },
|
||
"project.case_number": { de: "Aktenzeichen (Gericht)", en: "Court case number" },
|
||
"project.court": { de: "Gericht", en: "Court" },
|
||
"project.patent_number": { de: "Patentnummer", en: "Patent number" },
|
||
"project.patent_number_upc": { de: "Patentnummer (UPC-Format)", en: "Patent number (UPC format)" },
|
||
"project.filing_date": { de: "Anmeldedatum", en: "Filing date" },
|
||
"project.grant_date": { de: "Erteilungsdatum", en: "Grant date" },
|
||
"project.our_side": { de: "Unsere Seite", en: "Our side" },
|
||
"project.our_side_de": { de: "Unsere Seite (DE)", en: "Our side (DE)" },
|
||
"project.our_side_en": { de: "Unsere Seite (EN)", en: "Our side (EN)" },
|
||
"project.instance_level": { de: "Instanz", en: "Instance" },
|
||
"project.client_number": { de: "Mandantennummer", en: "Client number" },
|
||
"project.matter_number": { de: "Matter-Nummer", en: "Matter number" },
|
||
"project.proceeding.code": { de: "Verfahrenstyp (Code)", en: "Proceeding type (code)" },
|
||
"project.proceeding.name": { de: "Verfahrenstyp", en: "Proceeding type" },
|
||
"project.proceeding.name_de": { de: "Verfahrenstyp (DE)", en: "Proceeding type (DE)" },
|
||
"project.proceeding.name_en": { de: "Verfahrenstyp (EN)", en: "Proceeding type (EN)" },
|
||
"parties.claimant.name": { de: "Klägerin", en: "Claimant" },
|
||
"parties.claimant.representative": { de: "Klägerin-Vertreter", en: "Claimant representative" },
|
||
"parties.defendant.name": { de: "Beklagte", en: "Defendant" },
|
||
"parties.defendant.representative":{ de: "Beklagten-Vertreter", en: "Defendant representative" },
|
||
"parties.other.name": { de: "Weitere Partei", en: "Other party" },
|
||
"parties.other.representative": { de: "Weitere-Partei-Vertreter", en: "Other party representative" },
|
||
"rule.submission_code": { de: "Schriftsatz-Code", en: "Submission code" },
|
||
"rule.name": { de: "Schriftsatz", en: "Submission" },
|
||
"rule.name_de": { de: "Schriftsatz (DE)", en: "Submission (DE)" },
|
||
"rule.name_en": { de: "Schriftsatz (EN)", en: "Submission (EN)" },
|
||
"rule.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
|
||
"rule.legal_source_pretty": { de: "Rechtsgrundlage", en: "Legal source" },
|
||
"rule.primary_party": { de: "Partei (typisch)", en: "Primary party" },
|
||
"rule.event_type": { de: "Schriftsatz-Typ", en: "Event type" },
|
||
"deadline.due_date": { de: "Frist (ISO)", en: "Deadline (ISO)" },
|
||
"deadline.due_date_long_de": { de: "Frist (DE lang)", en: "Deadline (DE long)" },
|
||
"deadline.due_date_long_en": { de: "Frist (EN lang)", en: "Deadline (EN long)" },
|
||
"deadline.original_due_date": { de: "Ursprüngliche Frist", en: "Original deadline" },
|
||
"deadline.computed_from": { de: "Frist berechnet aus", en: "Deadline computed from" },
|
||
"deadline.title": { de: "Frist-Titel", en: "Deadline title" },
|
||
"deadline.source": { de: "Frist-Quelle", en: "Deadline source" },
|
||
};
|
||
|
||
const VARIABLE_GROUPS: VariableGroup[] = [
|
||
{
|
||
id: "rule",
|
||
label: { de: "Schriftsatz", en: "Submission" },
|
||
keys: [
|
||
"rule.name",
|
||
"rule.legal_source_pretty",
|
||
"rule.primary_party",
|
||
"rule.event_type",
|
||
"rule.submission_code",
|
||
],
|
||
},
|
||
{
|
||
id: "parties",
|
||
label: { de: "Mandanten & Parteien", en: "Clients & parties" },
|
||
keys: [
|
||
"parties.claimant.name",
|
||
"parties.claimant.representative",
|
||
"parties.defendant.name",
|
||
"parties.defendant.representative",
|
||
"parties.other.name",
|
||
"parties.other.representative",
|
||
],
|
||
},
|
||
{
|
||
id: "project",
|
||
label: { de: "Verfahren", en: "Proceeding" },
|
||
keys: [
|
||
"project.title",
|
||
"project.case_number",
|
||
"project.court",
|
||
"project.patent_number",
|
||
"project.patent_number_upc",
|
||
"project.filing_date",
|
||
"project.grant_date",
|
||
"project.our_side",
|
||
"project.proceeding.name",
|
||
"project.client_number",
|
||
"project.matter_number",
|
||
"project.reference",
|
||
"project.instance_level",
|
||
],
|
||
},
|
||
{
|
||
id: "deadline",
|
||
label: { de: "Frist", en: "Deadline" },
|
||
keys: [
|
||
"deadline.due_date",
|
||
"deadline.due_date_long_de",
|
||
"deadline.due_date_long_en",
|
||
"deadline.computed_from",
|
||
"deadline.title",
|
||
"deadline.original_due_date",
|
||
],
|
||
},
|
||
{
|
||
id: "firm",
|
||
label: { de: "Kanzlei & Datum", en: "Firm & date" },
|
||
keys: [
|
||
"firm.name",
|
||
"user.display_name",
|
||
"user.email",
|
||
"user.office",
|
||
"today.long_de",
|
||
"today.long_en",
|
||
],
|
||
},
|
||
];
|
||
|
||
function labelFor(key: string): string {
|
||
const entry = VARIABLE_LABELS[key];
|
||
if (!entry) return key;
|
||
return isEN() ? entry.en : entry.de;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// Module state
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
interface State {
|
||
parsed: ParsedPath;
|
||
view: SubmissionDraftView | null;
|
||
drafts: SubmissionDraftJSON[];
|
||
saveTimer: number | null;
|
||
pendingOverrides: Record<string, string> | null;
|
||
inFlight: AbortController | null;
|
||
}
|
||
|
||
const state: State = {
|
||
parsed: null as unknown as ParsedPath,
|
||
view: null,
|
||
drafts: [],
|
||
saveTimer: null,
|
||
pendingOverrides: null,
|
||
inFlight: null,
|
||
};
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// Boot
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
async function boot(): Promise<void> {
|
||
initI18n();
|
||
initSidebar();
|
||
|
||
const parsed = parsePath();
|
||
if (!parsed) {
|
||
showNotFound();
|
||
return;
|
||
}
|
||
state.parsed = parsed;
|
||
|
||
try {
|
||
if (parsed.mode === "global") {
|
||
// Global path: we have a draft_id, fetch by id alone. Drafts
|
||
// list (the sidebar switcher) is scoped to the same project +
|
||
// submission_code AFTER we've loaded the draft.
|
||
if (!parsed.draftID) {
|
||
showNotFound();
|
||
return;
|
||
}
|
||
const view = await fetchGlobalView(parsed.draftID);
|
||
state.view = view;
|
||
// Backfill parsed.* from the loaded draft so the sidebar
|
||
// switcher can list peers; project-less drafts get no peer list
|
||
// beyond themselves (no useful (project, code) cross-section).
|
||
state.parsed = {
|
||
...parsed,
|
||
projectID: view.draft.project_id,
|
||
submissionCode: view.draft.submission_code,
|
||
};
|
||
if (view.draft.project_id) {
|
||
try {
|
||
const list = await fetchDrafts(state.parsed);
|
||
state.drafts = list.drafts;
|
||
} catch { state.drafts = [view.draft]; }
|
||
} else {
|
||
state.drafts = [view.draft];
|
||
}
|
||
paint();
|
||
return;
|
||
}
|
||
|
||
// Project-scoped path: same logic as before.
|
||
if (!parsed.projectID || !parsed.submissionCode) {
|
||
showNotFound();
|
||
return;
|
||
}
|
||
const list = await fetchDrafts(parsed);
|
||
state.drafts = list.drafts;
|
||
let draft: SubmissionDraftJSON | null = null;
|
||
if (parsed.draftID) {
|
||
draft = state.drafts.find((d) => d.id === parsed.draftID) ?? null;
|
||
if (!draft) {
|
||
showNotFound();
|
||
return;
|
||
}
|
||
} else if (state.drafts.length > 0) {
|
||
draft = state.drafts[0];
|
||
// Redirect to the canonical /draft/{id} URL so refresh + share
|
||
// both land on the same draft.
|
||
const url = `/projects/${parsed.projectID}/submissions/${encodeURIComponent(parsed.submissionCode)}/draft/${draft.id}`;
|
||
window.history.replaceState({}, "", url);
|
||
state.parsed = { ...parsed, draftID: draft.id };
|
||
} else {
|
||
draft = await createProjectDraft(parsed);
|
||
state.drafts = [draft];
|
||
const url = `/projects/${parsed.projectID}/submissions/${encodeURIComponent(parsed.submissionCode)}/draft/${draft.id}`;
|
||
window.history.replaceState({}, "", url);
|
||
state.parsed = { ...parsed, draftID: draft.id };
|
||
}
|
||
const view = await fetchView(state.parsed.projectID!, state.parsed.submissionCode!, draft.id);
|
||
state.view = view;
|
||
paint();
|
||
} catch (err) {
|
||
console.error("submission-draft boot:", err);
|
||
showError(isEN() ? "Failed to load draft." : "Entwurf konnte nicht geladen werden.");
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// API
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
async function fetchDrafts(p: ParsedPath): Promise<SubmissionDraftListResponse> {
|
||
if (!p.projectID || !p.submissionCode) throw new Error("no project context");
|
||
const url = `/api/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/drafts`;
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) throw new Error(`drafts list ${resp.status}`);
|
||
return resp.json();
|
||
}
|
||
|
||
async function createProjectDraft(p: ParsedPath): Promise<SubmissionDraftJSON> {
|
||
if (!p.projectID || !p.submissionCode) throw new Error("no project context");
|
||
const url = `/api/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/drafts`;
|
||
const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" } });
|
||
if (!resp.ok) throw new Error(`create draft ${resp.status}`);
|
||
const view = (await resp.json()) as SubmissionDraftView;
|
||
return view.draft;
|
||
}
|
||
|
||
async function fetchView(projectID: string, code: string, draftID: string): Promise<SubmissionDraftView> {
|
||
const url = `/api/projects/${projectID}/submissions/${encodeURIComponent(code)}/drafts/${draftID}`;
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) throw new Error(`get draft ${resp.status}`);
|
||
return resp.json();
|
||
}
|
||
|
||
async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
|
||
const resp = await fetch(`/api/submission-drafts/${draftID}`);
|
||
if (!resp.ok) throw new Error(`get draft ${resp.status}`);
|
||
return resp.json();
|
||
}
|
||
|
||
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null }): Promise<SubmissionDraftView> {
|
||
const p = state.parsed;
|
||
if (!p.draftID) throw new Error("no draft id");
|
||
if (state.inFlight) {
|
||
state.inFlight.abort();
|
||
state.inFlight = null;
|
||
}
|
||
const ctl = new AbortController();
|
||
state.inFlight = ctl;
|
||
// The global PATCH endpoint accepts both project-scoped and
|
||
// project-less drafts — route everything through it so attach (set
|
||
// project_id) works from both URL shapes.
|
||
const url = `/api/submission-drafts/${p.draftID}`;
|
||
try {
|
||
const resp = await fetch(url, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
signal: ctl.signal,
|
||
});
|
||
if (!resp.ok) throw new Error(`patch draft ${resp.status}`);
|
||
return resp.json();
|
||
} finally {
|
||
if (state.inFlight === ctl) state.inFlight = null;
|
||
}
|
||
}
|
||
|
||
async function deleteDraft(): Promise<void> {
|
||
const p = state.parsed;
|
||
if (!p.draftID) return;
|
||
const resp = await fetch(`/api/submission-drafts/${p.draftID}`, { method: "DELETE" });
|
||
if (!resp.ok && resp.status !== 204) throw new Error(`delete draft ${resp.status}`);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// Render
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
function paint(): void {
|
||
if (!state.view) return;
|
||
hide("submission-draft-loading");
|
||
hide("submission-draft-notfound");
|
||
hide("submission-draft-error");
|
||
show("submission-draft-body");
|
||
|
||
paintHeader();
|
||
paintBackLink();
|
||
paintNoProjectBanner();
|
||
paintSwitcher();
|
||
paintNameRow();
|
||
paintVariables();
|
||
paintPreview();
|
||
}
|
||
|
||
function paintHeader(): void {
|
||
const view = state.view!;
|
||
const title = document.getElementById("submission-draft-title");
|
||
if (title) {
|
||
const ruleName = view.rule?.name ?? view.draft.submission_code;
|
||
title.textContent = ruleName;
|
||
}
|
||
const subtitle = document.getElementById("submission-draft-subtitle");
|
||
if (subtitle) {
|
||
const code = view.draft.submission_code;
|
||
const source = view.rule?.legal_source_pretty ?? view.rule?.legal_source ?? "";
|
||
const parts: string[] = [code];
|
||
if (source) parts.push(source);
|
||
subtitle.textContent = parts.join(" · ");
|
||
}
|
||
}
|
||
|
||
function paintBackLink(): void {
|
||
const back = document.getElementById("submission-draft-back-link") as HTMLAnchorElement | null;
|
||
if (!back || !state.view) return;
|
||
if (state.view.draft.project_id) {
|
||
back.href = `/projects/${state.view.draft.project_id}/submissions`;
|
||
back.textContent = isEN() ? "← Back to project" : "← Zurück zum Projekt";
|
||
} else {
|
||
back.href = "/submissions";
|
||
back.textContent = isEN() ? "← Back to drafts" : "← Zurück zur Übersicht";
|
||
}
|
||
}
|
||
|
||
// paintNoProjectBanner adds (or removes) the "Kein Projekt zugeordnet"
|
||
// banner above the editor body. The banner offers a "Projekt zuweisen"
|
||
// button that opens an inline project picker — same modal pattern the
|
||
// /submissions/new page uses. Removed once the draft has a project_id.
|
||
function paintNoProjectBanner(): void {
|
||
const body = document.getElementById("submission-draft-body");
|
||
if (!body || !state.view) return;
|
||
let banner = document.getElementById("submission-draft-noproject-banner");
|
||
|
||
if (state.view.draft.project_id) {
|
||
if (banner) banner.remove();
|
||
return;
|
||
}
|
||
|
||
const msg = isEN()
|
||
? "No project assigned — all variables are filled manually."
|
||
: "Kein Projekt zugeordnet — alle Variablen werden manuell befüllt.";
|
||
const cta = isEN() ? "Assign project…" : "Projekt zuweisen…";
|
||
const html = `<p class="submission-draft-noproject-banner-msg">${escapeHtml(msg)}</p>
|
||
<button type="button" id="submission-draft-noproject-assign"
|
||
class="btn-secondary btn-small">${escapeHtml(cta)}</button>`;
|
||
|
||
if (banner) {
|
||
banner.innerHTML = html;
|
||
} else {
|
||
banner = document.createElement("aside");
|
||
banner.id = "submission-draft-noproject-banner";
|
||
banner.className = "submission-draft-noproject-banner";
|
||
banner.innerHTML = html;
|
||
// Insert before the header.
|
||
const header = body.querySelector(".submission-draft-header");
|
||
if (header && header.parentElement) {
|
||
header.parentElement.insertBefore(banner, header);
|
||
} else {
|
||
body.prepend(banner);
|
||
}
|
||
}
|
||
|
||
const btn = document.getElementById("submission-draft-noproject-assign") as HTMLButtonElement | null;
|
||
if (btn) btn.onclick = () => openProjectAssignPicker();
|
||
}
|
||
|
||
function paintSwitcher(): void {
|
||
const sel = document.getElementById("submission-draft-pick") as HTMLSelectElement | null;
|
||
if (!sel || !state.view) return;
|
||
sel.innerHTML = state.drafts
|
||
.map((d) => `<option value="${escapeHtml(d.id)}"${d.id === state.view!.draft.id ? " selected" : ""}>${escapeHtml(d.name)}</option>`)
|
||
.join("");
|
||
sel.onchange = () => {
|
||
const id = sel.value;
|
||
if (!id || !state.view) return;
|
||
if (id === state.view.draft.id) return;
|
||
const p = state.parsed;
|
||
const url = `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft/${id}`;
|
||
window.location.href = url;
|
||
};
|
||
}
|
||
|
||
function paintNameRow(): void {
|
||
const input = document.getElementById("submission-draft-name") as HTMLInputElement | null;
|
||
const del = document.getElementById("submission-draft-delete-btn") as HTMLButtonElement | null;
|
||
if (input && state.view) {
|
||
input.value = state.view.draft.name;
|
||
input.onchange = () => {
|
||
const newName = input.value.trim();
|
||
if (!newName || newName === state.view!.draft.name) return;
|
||
void renameDraft(newName);
|
||
};
|
||
}
|
||
if (del) del.onclick = () => onDelete();
|
||
|
||
const newBtn = document.getElementById("submission-draft-new-btn") as HTMLButtonElement | null;
|
||
if (newBtn) newBtn.onclick = () => onCreateNew();
|
||
|
||
const exportBtn = document.getElementById("submission-draft-export-btn") as HTMLButtonElement | null;
|
||
if (exportBtn) exportBtn.onclick = () => onExport(exportBtn);
|
||
}
|
||
|
||
function paintVariables(): void {
|
||
const host = document.getElementById("submission-draft-variables");
|
||
if (!host || !state.view) return;
|
||
const overrides = state.view.draft.variables ?? {};
|
||
const resolved = state.view.resolved_bag ?? {};
|
||
const merged = state.view.merged_bag ?? {};
|
||
|
||
let html = "";
|
||
for (const group of VARIABLE_GROUPS) {
|
||
const groupLabel = isEN() ? group.label.en : group.label.de;
|
||
html += `<section class="submission-draft-var-group" data-group="${group.id}">`;
|
||
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
|
||
for (const key of group.keys) {
|
||
const label = labelFor(key);
|
||
const override = overrides[key];
|
||
const resolvedVal = resolved[key] ?? "";
|
||
const mergedVal = merged[key] ?? "";
|
||
const overridden = Object.prototype.hasOwnProperty.call(overrides, key);
|
||
html += `<label class="submission-draft-var-row" data-key="${escapeHtml(key)}">`;
|
||
html += `<span class="submission-draft-var-label">${escapeHtml(label)}</span>`;
|
||
html += `<input type="text" class="submission-draft-var-input entity-form-input"`;
|
||
html += ` data-var="${escapeHtml(key)}"`;
|
||
html += ` value="${escapeHtml(overridden ? override : resolvedVal)}"`;
|
||
html += ` data-resolved="${escapeHtml(resolvedVal)}" />`;
|
||
const hintParts: string[] = [];
|
||
hintParts.push(`<code>{{${escapeHtml(key)}}}</code>`);
|
||
if (overridden) {
|
||
if (override === "") {
|
||
hintParts.push(`<span class="submission-draft-var-marker">${escapeHtml(isEN() ? "→ [NO VALUE: " + key + "]" : "→ [KEIN WERT: " + key + "]")}</span>`);
|
||
} else if (override !== resolvedVal) {
|
||
const original = resolvedVal === "" ? (isEN() ? "(empty)" : "(leer)") : resolvedVal;
|
||
hintParts.push(`<span class="submission-draft-var-was">${escapeHtml((isEN() ? "Project: " : "Projekt: ") + original)}</span>`);
|
||
}
|
||
}
|
||
html += `<span class="submission-draft-var-hint">${hintParts.join(" · ")}</span>`;
|
||
if (overridden && override !== resolvedVal) {
|
||
html += `<button type="button" class="submission-draft-var-reset btn-small btn-link" data-reset-key="${escapeHtml(key)}">${escapeHtml(isEN() ? "Reset" : "Zurücksetzen")}</button>`;
|
||
}
|
||
html += `</label>`;
|
||
// Visual hint: marker text appears in preview when override is "".
|
||
void mergedVal;
|
||
}
|
||
html += `</section>`;
|
||
}
|
||
host.innerHTML = html;
|
||
|
||
host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
|
||
inp.addEventListener("input", () => onVarChange(inp));
|
||
});
|
||
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-reset").forEach((btn) => {
|
||
btn.addEventListener("click", () => onVarReset(btn.dataset.resetKey ?? ""));
|
||
});
|
||
}
|
||
|
||
function paintPreview(): void {
|
||
const host = document.getElementById("submission-draft-preview");
|
||
if (!host || !state.view) return;
|
||
host.innerHTML = state.view.preview_html ?? "";
|
||
wireDraftVars(host);
|
||
}
|
||
|
||
// t-paliad-261 (B) — click a substituted variable in the preview to
|
||
// jump to the matching sidebar input. Re-wires on every paintPreview
|
||
// since the preview HTML is replaced wholesale. The server side wraps
|
||
// each substituted placeholder (resolved OR missing marker) in
|
||
// <span class="draft-var" data-var="<key>">…</span>; clicks here scroll
|
||
// the corresponding input into view, focus + select, and flash the row.
|
||
// If the key has no matching sidebar input (derived variables not
|
||
// exposed in VARIABLE_GROUPS), the click is a silent no-op — the span
|
||
// is still rendered so the user gets the visible hint that this is a
|
||
// resolved variable.
|
||
function wireDraftVars(previewHost: HTMLElement): void {
|
||
previewHost.querySelectorAll<HTMLElement>(".draft-var").forEach((el) => {
|
||
const key = el.dataset.var;
|
||
if (!key) return;
|
||
if (findVarInput(key)) {
|
||
el.classList.add("draft-var--has-input");
|
||
el.setAttribute("role", "button");
|
||
el.setAttribute("tabindex", "0");
|
||
el.setAttribute(
|
||
"aria-label",
|
||
(isEN() ? "Edit variable " : "Variable bearbeiten: ") + labelFor(key),
|
||
);
|
||
}
|
||
el.addEventListener("click", (ev) => onDraftVarClick(key, ev));
|
||
el.addEventListener("keydown", (ev) => {
|
||
if (ev.key === "Enter" || ev.key === " ") {
|
||
ev.preventDefault();
|
||
onDraftVarClick(key, ev);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function findVarInput(key: string): HTMLInputElement | null {
|
||
const host = document.getElementById("submission-draft-variables");
|
||
if (!host) return null;
|
||
return host.querySelector<HTMLInputElement>(
|
||
`.submission-draft-var-input[data-var="${cssEscape(key)}"]`,
|
||
);
|
||
}
|
||
|
||
function cssEscape(s: string): string {
|
||
// CSS.escape covers our placeholder keys ([A-Za-z][A-Za-z0-9_.]*) but
|
||
// older browsers may lack it; defensive fallback escapes characters
|
||
// CSS treats as special. Placeholder keys never carry whitespace or
|
||
// quotes so escaping is straightforward.
|
||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||
return CSS.escape(s);
|
||
}
|
||
return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
|
||
}
|
||
|
||
function onDraftVarClick(key: string, ev: Event): void {
|
||
const input = findVarInput(key);
|
||
if (!input) return;
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
// Smooth-scroll the input into view, then focus on the next tick so
|
||
// the scroll animation has started and the focus call doesn't trigger
|
||
// a second jarring jump.
|
||
input.scrollIntoView({ behavior: "smooth", block: "center" });
|
||
window.setTimeout(() => {
|
||
input.focus();
|
||
try {
|
||
input.select();
|
||
} catch {
|
||
/* select() throws on number/email inputs; safe to ignore */
|
||
}
|
||
}, 50);
|
||
flashVarRow(input);
|
||
}
|
||
|
||
function flashVarRow(input: HTMLElement): void {
|
||
const row = input.closest<HTMLElement>(".submission-draft-var-row");
|
||
if (!row) return;
|
||
row.classList.remove("submission-draft-var-row--flash");
|
||
// Force reflow so removing+re-adding the class restarts the animation
|
||
// even on rapid successive clicks.
|
||
void row.offsetWidth;
|
||
row.classList.add("submission-draft-var-row--flash");
|
||
window.setTimeout(() => row.classList.remove("submission-draft-var-row--flash"), 1200);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// Event handlers
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
function onVarChange(input: HTMLInputElement): void {
|
||
const key = input.dataset.var;
|
||
if (!key || !state.view) return;
|
||
// Stage the override on the draft view so paintPreview reflects.
|
||
const overrides = { ...state.view.draft.variables };
|
||
overrides[key] = input.value;
|
||
state.view.draft.variables = overrides;
|
||
state.pendingOverrides = overrides;
|
||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||
if (state.saveTimer) window.clearTimeout(state.saveTimer);
|
||
state.saveTimer = window.setTimeout(() => {
|
||
void flushAutosave();
|
||
}, 500);
|
||
}
|
||
|
||
function onVarReset(key: string): void {
|
||
if (!state.view) return;
|
||
const overrides = { ...state.view.draft.variables };
|
||
delete overrides[key];
|
||
state.view.draft.variables = overrides;
|
||
state.pendingOverrides = overrides;
|
||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||
if (state.saveTimer) window.clearTimeout(state.saveTimer);
|
||
state.saveTimer = window.setTimeout(() => {
|
||
void flushAutosave();
|
||
}, 500);
|
||
}
|
||
|
||
async function flushAutosave(): Promise<void> {
|
||
if (!state.pendingOverrides) return;
|
||
const payload = { variables: state.pendingOverrides };
|
||
state.pendingOverrides = null;
|
||
// t-paliad-261 (A) — paintVariables() below replaces every input in
|
||
// the sidebar via innerHTML, which blows away the active-element
|
||
// reference. Capture the focused input's key + selection range before
|
||
// the repaint and restore on the new element after, so the user can
|
||
// keep typing without clicking back into the field.
|
||
const focusSnap = captureVarFocus();
|
||
try {
|
||
const view = await patchDraft(payload);
|
||
state.view = view;
|
||
paintVariables();
|
||
paintPreview();
|
||
restoreVarFocus(focusSnap);
|
||
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
|
||
} catch (err) {
|
||
if ((err as Error).name === "AbortError") return;
|
||
console.error("submission-draft autosave:", err);
|
||
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
|
||
}
|
||
}
|
||
|
||
// captureVarFocus / restoreVarFocus — focus-preservation across the
|
||
// paintVariables() innerHTML-replace cycle (t-paliad-261 part A).
|
||
// Tracks selection start/end/direction so the cursor lands exactly
|
||
// where it was before the repaint, including any active selection
|
||
// range. Handles both <input> and <textarea> via the shared
|
||
// HTMLInputElement|HTMLTextAreaElement contract for selectionStart /
|
||
// selectionEnd / selectionDirection / setSelectionRange.
|
||
|
||
interface VarFocusSnapshot {
|
||
key: string;
|
||
start: number | null;
|
||
end: number | null;
|
||
dir: "forward" | "backward" | "none";
|
||
}
|
||
|
||
type SelectableEl = HTMLInputElement | HTMLTextAreaElement;
|
||
|
||
function isVarField(el: Element | null): el is SelectableEl {
|
||
if (!el) return false;
|
||
if (!(el instanceof HTMLInputElement) && !(el instanceof HTMLTextAreaElement)) {
|
||
return false;
|
||
}
|
||
return el.classList.contains("submission-draft-var-input");
|
||
}
|
||
|
||
function captureVarFocus(): VarFocusSnapshot | null {
|
||
const active = document.activeElement;
|
||
if (!isVarField(active)) return null;
|
||
const key = active.dataset.var;
|
||
if (!key) return null;
|
||
return {
|
||
key,
|
||
start: active.selectionStart,
|
||
end: active.selectionEnd,
|
||
dir: (active.selectionDirection as "forward" | "backward" | "none" | null) ?? "forward",
|
||
};
|
||
}
|
||
|
||
function restoreVarFocus(snap: VarFocusSnapshot | null): void {
|
||
if (!snap) return;
|
||
const host = document.getElementById("submission-draft-variables");
|
||
if (!host) return;
|
||
const next = host.querySelector<SelectableEl>(
|
||
`.submission-draft-var-input[data-var="${cssEscape(snap.key)}"]`,
|
||
);
|
||
if (!next) return;
|
||
next.focus();
|
||
if (snap.start !== null && snap.end !== null) {
|
||
try {
|
||
next.setSelectionRange(snap.start, snap.end, snap.dir);
|
||
} catch {
|
||
/* setSelectionRange throws on inputs whose type doesn't support
|
||
selection ranges (number, email, etc.); safe to ignore — the
|
||
focus() call above is enough for those. */
|
||
}
|
||
}
|
||
}
|
||
|
||
async function renameDraft(newName: string): Promise<void> {
|
||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||
try {
|
||
const view = await patchDraft({ name: newName });
|
||
state.view = view;
|
||
// Refresh the draft list cache.
|
||
const idx = state.drafts.findIndex((d) => d.id === view.draft.id);
|
||
if (idx >= 0) state.drafts[idx].name = view.draft.name;
|
||
paintSwitcher();
|
||
setSaveStatus(isEN() ? "Renamed" : "Umbenannt");
|
||
} catch (err) {
|
||
if ((err as Error).name === "AbortError") return;
|
||
console.error("submission-draft rename:", err);
|
||
const msg = (err as Error).message?.includes("409")
|
||
? (isEN() ? "Name already in use" : "Name bereits vergeben")
|
||
: (isEN() ? "Rename failed" : "Umbenennen fehlgeschlagen");
|
||
setSaveStatus(msg, true);
|
||
}
|
||
}
|
||
|
||
async function onCreateNew(): Promise<void> {
|
||
const p = state.parsed;
|
||
// From a project-less draft, "Neuer Entwurf" can't auto-pick a
|
||
// (project, code) cross-section — kick the user out to the global
|
||
// picker instead.
|
||
if (!p.projectID || !p.submissionCode) {
|
||
window.location.href = "/submissions/new";
|
||
return;
|
||
}
|
||
try {
|
||
const fresh = await createProjectDraft(p);
|
||
const url = `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft/${fresh.id}`;
|
||
window.location.href = url;
|
||
} catch (err) {
|
||
console.error("submission-draft new:", err);
|
||
setSaveStatus(isEN() ? "Create failed" : "Anlegen fehlgeschlagen", true);
|
||
}
|
||
}
|
||
|
||
async function onDelete(): Promise<void> {
|
||
if (!state.view) return;
|
||
const msg = isEN()
|
||
? `Delete draft "${state.view.draft.name}"? This cannot be undone.`
|
||
: `Entwurf "${state.view.draft.name}" löschen? Das kann nicht rückgängig gemacht werden.`;
|
||
if (!window.confirm(msg)) return;
|
||
try {
|
||
await deleteDraft();
|
||
const p = state.parsed;
|
||
const url = p.projectID && p.submissionCode
|
||
? `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft`
|
||
: "/submissions";
|
||
window.location.href = url;
|
||
} catch (err) {
|
||
console.error("submission-draft delete:", err);
|
||
setSaveStatus(isEN() ? "Delete failed" : "Löschen fehlgeschlagen", true);
|
||
}
|
||
}
|
||
|
||
async function onExport(btn: HTMLButtonElement): Promise<void> {
|
||
if (!state.view) return;
|
||
const p = state.parsed;
|
||
if (!p.draftID) return;
|
||
const originalLabel = btn.textContent ?? "";
|
||
btn.disabled = true;
|
||
btn.textContent = isEN() ? "Exporting…" : "Exportiert…";
|
||
try {
|
||
// Use the global export endpoint for both project-scoped and
|
||
// project-less drafts; the handler routes audit + project_events
|
||
// writes based on the draft row's project_id.
|
||
const url = `/api/submission-drafts/${p.draftID}/export`;
|
||
const resp = await fetch(url, { method: "POST" });
|
||
if (!resp.ok) {
|
||
let detail = "";
|
||
try {
|
||
const data = (await resp.json()) as { error?: string };
|
||
detail = data.error ?? "";
|
||
} catch { /* fallthrough */ }
|
||
alert((isEN() ? "Export failed." : "Export fehlgeschlagen.") + (detail ? `\n\n${detail}` : ""));
|
||
return;
|
||
}
|
||
const blob = await resp.blob();
|
||
const filename = parseFilename(resp.headers.get("Content-Disposition") ?? "") ?? `${state.view.draft.submission_code}.docx`;
|
||
triggerDownload(blob, filename);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = originalLabel;
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// Project assign picker (project-less → project-scoped)
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
interface PickerProjectRow {
|
||
id: string;
|
||
title: string;
|
||
reference?: string | null;
|
||
}
|
||
|
||
let assignPickerProjects: PickerProjectRow[] = [];
|
||
let assignPickerLoaded = false;
|
||
|
||
function openProjectAssignPicker(): void {
|
||
ensureAssignPickerDOM();
|
||
const modal = document.getElementById("submission-draft-assign-modal");
|
||
if (modal) modal.style.display = "";
|
||
if (!assignPickerLoaded) {
|
||
void loadAssignPickerProjects();
|
||
} else {
|
||
renderAssignPickerList();
|
||
}
|
||
const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
|
||
if (searchInput) {
|
||
searchInput.value = "";
|
||
setTimeout(() => searchInput.focus(), 50);
|
||
}
|
||
}
|
||
|
||
function closeProjectAssignPicker(): void {
|
||
const modal = document.getElementById("submission-draft-assign-modal");
|
||
if (modal) modal.style.display = "none";
|
||
}
|
||
|
||
function ensureAssignPickerDOM(): void {
|
||
if (document.getElementById("submission-draft-assign-modal")) return;
|
||
const titleTxt = isEN() ? "Assign project" : "Projekt zuweisen";
|
||
const placeholder = isEN()
|
||
? "Search project (title or reference)…"
|
||
: "Projekt suchen (Titel oder Aktenzeichen)…";
|
||
const loadingTxt = isEN() ? "Loading projects…" : "Lädt Projekte…";
|
||
const emptyTxt = isEN() ? "No visible projects." : "Keine sichtbaren Projekte.";
|
||
|
||
const modal = document.createElement("div");
|
||
modal.id = "submission-draft-assign-modal";
|
||
modal.className = "modal-overlay";
|
||
modal.setAttribute("role", "dialog");
|
||
modal.setAttribute("aria-modal", "true");
|
||
modal.style.display = "none";
|
||
modal.innerHTML = `
|
||
<div class="modal-card">
|
||
<header class="modal-header">
|
||
<h2>${escapeHtml(titleTxt)}</h2>
|
||
<button type="button" id="submission-draft-assign-close" class="modal-close" aria-label="Close">×</button>
|
||
</header>
|
||
<div class="modal-body">
|
||
<input type="search" id="submission-draft-assign-search" class="entity-form-input" placeholder="${escapeHtml(placeholder)}" />
|
||
<ul id="submission-draft-assign-list" class="submissions-new-project-list"></ul>
|
||
<p id="submission-draft-assign-loading" class="entity-events-empty" style="display:none">${escapeHtml(loadingTxt)}</p>
|
||
<p id="submission-draft-assign-empty" class="entity-empty" style="display:none">${escapeHtml(emptyTxt)}</p>
|
||
</div>
|
||
</div>`;
|
||
document.body.appendChild(modal);
|
||
|
||
modal.addEventListener("click", (e) => {
|
||
if (e.target === modal) closeProjectAssignPicker();
|
||
});
|
||
const closeBtn = document.getElementById("submission-draft-assign-close");
|
||
if (closeBtn) closeBtn.addEventListener("click", () => closeProjectAssignPicker());
|
||
const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
|
||
if (searchInput) searchInput.addEventListener("input", () => renderAssignPickerList());
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && modal.style.display !== "none") closeProjectAssignPicker();
|
||
});
|
||
}
|
||
|
||
async function loadAssignPickerProjects(): Promise<void> {
|
||
const loadingEl = document.getElementById("submission-draft-assign-loading");
|
||
if (loadingEl) loadingEl.style.display = "";
|
||
try {
|
||
const resp = await fetch("/api/projects?status=active");
|
||
if (!resp.ok) throw new Error(`projects list ${resp.status}`);
|
||
const rows = (await resp.json()) as PickerProjectRow[];
|
||
assignPickerProjects = rows ?? [];
|
||
assignPickerLoaded = true;
|
||
} catch (err) {
|
||
console.error("submission-draft assignPicker:", err);
|
||
assignPickerProjects = [];
|
||
} finally {
|
||
if (loadingEl) loadingEl.style.display = "none";
|
||
}
|
||
renderAssignPickerList();
|
||
}
|
||
|
||
function renderAssignPickerList(): void {
|
||
const list = document.getElementById("submission-draft-assign-list");
|
||
const empty = document.getElementById("submission-draft-assign-empty");
|
||
if (!list || !empty) return;
|
||
|
||
const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
|
||
const term = (searchInput?.value ?? "").trim().toLowerCase();
|
||
|
||
const matches = assignPickerProjects.filter((p) => {
|
||
if (term === "") return true;
|
||
const hay = [p.title, p.reference ?? ""].join(" ").toLowerCase();
|
||
return hay.includes(term);
|
||
}).slice(0, 50);
|
||
|
||
if (matches.length === 0) {
|
||
list.innerHTML = "";
|
||
empty.style.display = "";
|
||
return;
|
||
}
|
||
empty.style.display = "none";
|
||
|
||
list.innerHTML = matches.map((p) => {
|
||
const ref = p.reference ? `<span class="entity-ref">${escapeHtml(p.reference)}</span> ` : "";
|
||
return `<li class="submissions-new-project-item" data-id="${escapeHtml(p.id)}">${ref}<span class="submissions-new-project-title">${escapeHtml(p.title)}</span></li>`;
|
||
}).join("");
|
||
|
||
list.querySelectorAll<HTMLLIElement>(".submissions-new-project-item").forEach((li) => {
|
||
li.addEventListener("click", () => {
|
||
const pid = li.dataset.id;
|
||
if (pid) void onAssignProject(pid);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function onAssignProject(projectID: string): Promise<void> {
|
||
closeProjectAssignPicker();
|
||
setSaveStatus(isEN() ? "Assigning…" : "Wird zugewiesen…");
|
||
try {
|
||
const view = await patchDraft({ project_id: projectID });
|
||
state.view = view;
|
||
setSaveStatus(isEN() ? "Project assigned" : "Projekt zugewiesen");
|
||
// Redirect to the project-scoped URL so the editor's URL matches the
|
||
// attached project and the project-scoped draft list (sidebar
|
||
// switcher) loads on refresh.
|
||
const code = view.draft.submission_code;
|
||
window.location.href = `/projects/${projectID}/submissions/${encodeURIComponent(code)}/draft/${view.draft.id}`;
|
||
} catch (err) {
|
||
console.error("submission-draft assign:", err);
|
||
setSaveStatus(isEN() ? "Assign failed" : "Zuweisung fehlgeschlagen", true);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// Helpers
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
function show(id: string): void {
|
||
const el = document.getElementById(id);
|
||
if (el) el.style.display = "";
|
||
}
|
||
|
||
function hide(id: string): void {
|
||
const el = document.getElementById(id);
|
||
if (el) el.style.display = "none";
|
||
}
|
||
|
||
function showNotFound(): void {
|
||
hide("submission-draft-loading");
|
||
hide("submission-draft-body");
|
||
show("submission-draft-notfound");
|
||
}
|
||
|
||
function showError(msg: string): void {
|
||
hide("submission-draft-loading");
|
||
hide("submission-draft-body");
|
||
const el = document.getElementById("submission-draft-error");
|
||
if (el) {
|
||
el.textContent = msg;
|
||
el.style.display = "";
|
||
}
|
||
}
|
||
|
||
function setSaveStatus(msg: string, errorState: boolean = false): void {
|
||
const el = document.getElementById("submission-draft-savestatus");
|
||
if (!el) return;
|
||
el.textContent = msg;
|
||
el.classList.toggle("submission-draft-savestatus--error", errorState);
|
||
}
|
||
|
||
function parseFilename(header: string): string | null {
|
||
const m = /filename\s*=\s*"?([^";]+)"?/i.exec(header);
|
||
return m ? m[1] : null;
|
||
}
|
||
|
||
function triggerDownload(blob: Blob, filename: string): void {
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||
}
|
||
|
||
// Keep t() referenced so the bundler doesn't tree-shake it; future
|
||
// affordances will use the per-page i18n keys.
|
||
void t;
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", () => { void boot(); });
|
||
} else {
|
||
void boot();
|
||
}
|