initial commit
This commit is contained in:
610
packages/templatebuilder/frontend.ts
Normal file
610
packages/templatebuilder/frontend.ts
Normal file
@@ -0,0 +1,610 @@
|
||||
import * as pdfjsLib from "pdfjs-dist";
|
||||
import type { PDFDocumentProxy, PageViewport } from "pdfjs-dist";
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdf.worker.mjs";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type AntwortFormat = "einzelfrage" | "mehrfachfrage_ein_antwortfeld";
|
||||
|
||||
interface FrageVorlage {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface FieldEntry {
|
||||
id: string;
|
||||
fieldName: string; // PDF AcroForm field name, or user-defined for manual
|
||||
jsonKey: string; // Target nested JSON path
|
||||
rect: [number, number, number, number]; // PDF user-space [x1, y1, x2, y2]
|
||||
page: number; // 1-indexed
|
||||
type: "acroform" | "manual";
|
||||
label?: string;
|
||||
antwortFormat?: AntwortFormat;
|
||||
fragen?: FrageVorlage[];
|
||||
}
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
let pdfDoc: PDFDocumentProxy | null = null;
|
||||
let currentPage = 1;
|
||||
let viewport: PageViewport | null = null;
|
||||
let fields: FieldEntry[] = [];
|
||||
let selectedFieldId: string | null = null;
|
||||
let isDrawMode = false;
|
||||
let drawStart: { x: number; y: number } | null = null;
|
||||
let pendingManualRect: [number, number, number, number] | null = null;
|
||||
let currentFilename = "";
|
||||
|
||||
// ─── DOM refs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const fileInput = q<HTMLInputElement>("#file-input");
|
||||
const uploadBtn = q<HTMLButtonElement>("#upload-btn");
|
||||
const drawBtn = q<HTMLButtonElement>("#draw-btn");
|
||||
const saveBtn = q<HTMLButtonElement>("#save-btn");
|
||||
const prevPageBtn = q<HTMLButtonElement>("#prev-page");
|
||||
const nextPageBtn = q<HTMLButtonElement>("#next-page");
|
||||
const pageInfo = q<HTMLSpanElement>("#page-info");
|
||||
const fieldList = q<HTMLDivElement>("#field-list");
|
||||
const fieldCount = q<HTMLSpanElement>("#field-count");
|
||||
const filenameDisp = q<HTMLSpanElement>("#filename-display");
|
||||
const uploadPrompt = q<HTMLDivElement>("#upload-prompt");
|
||||
const canvases = q<HTMLDivElement>("#canvases");
|
||||
const pdfCanvas = q<HTMLCanvasElement>("#pdf-canvas");
|
||||
const overlayCanvas = q<HTMLCanvasElement>("#overlay-canvas");
|
||||
|
||||
// Key panel
|
||||
const keyPanel = q<HTMLDivElement>("#key-panel");
|
||||
const kpFieldName = q<HTMLSpanElement>("#kp-field-name");
|
||||
const kpTypeBadge = q<HTMLSpanElement>("#kp-type-badge");
|
||||
const kpNameRow = q<HTMLDivElement>("#kp-name-row");
|
||||
const kpFieldId = q<HTMLInputElement>("#kp-field-id");
|
||||
const kpJsonKey = q<HTMLInputElement>("#kp-json-key");
|
||||
const kpLabel = q<HTMLInputElement>("#kp-label");
|
||||
const kpAntwortFormat = q<HTMLSelectElement>("#kp-antwort-format");
|
||||
const kpFragen = q<HTMLTextAreaElement>("#kp-fragen");
|
||||
const kpAssign = q<HTMLButtonElement>("#kp-assign");
|
||||
const kpCancel = q<HTMLButtonElement>("#kp-cancel");
|
||||
const kpRemove = q<HTMLButtonElement>("#kp-remove");
|
||||
|
||||
function q<T extends Element>(sel: string): T {
|
||||
return document.querySelector(sel) as T;
|
||||
}
|
||||
|
||||
function csrfHeaders(extra: Record<string, string> = {}): Record<string, string> {
|
||||
const csrf = document.cookie
|
||||
.split("; ")
|
||||
.find((part) => part.startsWith("bmp_demo_csrf="))
|
||||
?.slice("bmp_demo_csrf=".length);
|
||||
return csrf ? { ...extra, "X-BMP-CSRF": decodeURIComponent(csrf) } : extra;
|
||||
}
|
||||
|
||||
// ─── PDF Loading ──────────────────────────────────────────────────────────────
|
||||
|
||||
uploadBtn.addEventListener("click", () => fileInput.click());
|
||||
|
||||
fileInput.addEventListener("change", async () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (file) await loadPDF(file);
|
||||
});
|
||||
|
||||
async function loadPDF(file: File) {
|
||||
currentFilename = file.name;
|
||||
filenameDisp.textContent = file.name;
|
||||
selectedFieldId = null;
|
||||
hideKeyPanel();
|
||||
|
||||
const buf = await file.arrayBuffer();
|
||||
pdfDoc = await pdfjsLib.getDocument({ data: buf }).promise;
|
||||
|
||||
const acroFields = await extractAcroFields(pdfDoc);
|
||||
fields = mergeWithSaved(file.name, acroFields);
|
||||
|
||||
currentPage = 1;
|
||||
uploadPrompt.style.display = "none";
|
||||
canvases.style.display = "inline-block";
|
||||
drawBtn.disabled = false;
|
||||
saveBtn.disabled = false;
|
||||
|
||||
await renderPage(1);
|
||||
renderFieldList();
|
||||
}
|
||||
|
||||
async function extractAcroFields(doc: PDFDocumentProxy): Promise<FieldEntry[]> {
|
||||
const result: FieldEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let p = 1; p <= doc.numPages; p++) {
|
||||
const page = await doc.getPage(p);
|
||||
const annotations = await page.getAnnotations();
|
||||
|
||||
for (const ann of annotations) {
|
||||
if (ann.subtype === "Widget" && ann.fieldName) {
|
||||
const key = `${ann.fieldName}::p${p}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push({
|
||||
id: `acro-p${p}-${ann.id ?? ann.fieldName}`,
|
||||
fieldName: ann.fieldName,
|
||||
jsonKey: "",
|
||||
rect: ann.rect as [number, number, number, number],
|
||||
page: p,
|
||||
type: "acroform",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function mergeWithSaved(filename: string, acroFields: FieldEntry[]): FieldEntry[] {
|
||||
const raw = localStorage.getItem(`tb:${filename}`);
|
||||
if (!raw) return acroFields;
|
||||
|
||||
const saved: FieldEntry[] = JSON.parse(raw);
|
||||
const savedByKey = new Map(saved.map(f => [`${f.fieldName}::p${f.page}`, f]));
|
||||
|
||||
const merged = acroFields.map(f => {
|
||||
const s = savedByKey.get(`${f.fieldName}::p${f.page}`);
|
||||
return s
|
||||
? {
|
||||
...f,
|
||||
jsonKey: s.jsonKey,
|
||||
label: s.label,
|
||||
antwortFormat: s.antwortFormat,
|
||||
fragen: s.fragen,
|
||||
}
|
||||
: f;
|
||||
});
|
||||
|
||||
const manuals = saved.filter(f => f.type === "manual");
|
||||
return [...merged, ...manuals];
|
||||
}
|
||||
|
||||
// ─── Page Rendering ───────────────────────────────────────────────────────────
|
||||
|
||||
async function renderPage(pageNum: number) {
|
||||
if (!pdfDoc) return;
|
||||
|
||||
const page = await pdfDoc.getPage(pageNum);
|
||||
viewport = page.getViewport({ scale: 1.5 });
|
||||
|
||||
pdfCanvas.width = viewport.width;
|
||||
pdfCanvas.height = viewport.height;
|
||||
overlayCanvas.width = viewport.width;
|
||||
overlayCanvas.height = viewport.height;
|
||||
|
||||
const ctx = pdfCanvas.getContext("2d")!;
|
||||
await page.render({ canvasContext: ctx, viewport, canvas: pdfCanvas }).promise;
|
||||
|
||||
renderOverlay();
|
||||
updatePagination();
|
||||
}
|
||||
|
||||
function updatePagination() {
|
||||
const total = pdfDoc?.numPages ?? 0;
|
||||
pageInfo.textContent = total ? `Page ${currentPage} of ${total}` : "—";
|
||||
prevPageBtn.disabled = currentPage <= 1;
|
||||
nextPageBtn.disabled = !total || currentPage >= total;
|
||||
}
|
||||
|
||||
prevPageBtn.addEventListener("click", async () => {
|
||||
if (currentPage > 1) {
|
||||
currentPage--;
|
||||
selectedFieldId = null;
|
||||
hideKeyPanel();
|
||||
await renderPage(currentPage);
|
||||
}
|
||||
});
|
||||
|
||||
nextPageBtn.addEventListener("click", async () => {
|
||||
if (pdfDoc && currentPage < pdfDoc.numPages) {
|
||||
currentPage++;
|
||||
selectedFieldId = null;
|
||||
hideKeyPanel();
|
||||
await renderPage(currentPage);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Overlay ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderOverlay(drawPreview?: { x: number; y: number; w: number; h: number }) {
|
||||
if (!viewport) return;
|
||||
const ctx = overlayCanvas.getContext("2d")!;
|
||||
ctx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
|
||||
|
||||
for (const field of fields.filter(f => f.page === currentPage)) {
|
||||
const [l, t, w, h] = rectToCanvas(field.rect, viewport!);
|
||||
const sel = field.id === selectedFieldId;
|
||||
const mapped = field.jsonKey.trim() !== "";
|
||||
|
||||
if (sel) {
|
||||
ctx.fillStyle = "rgba(37,99,235,0.12)";
|
||||
ctx.fillRect(l, t, w, h);
|
||||
ctx.strokeStyle = "#2563eb";
|
||||
ctx.lineWidth = 2;
|
||||
} else {
|
||||
ctx.strokeStyle = mapped ? "#16a34a" : "#ea580c";
|
||||
ctx.lineWidth = 1.5;
|
||||
}
|
||||
ctx.strokeRect(l, t, w, h);
|
||||
|
||||
// Label
|
||||
const label = field.jsonKey || field.fieldName;
|
||||
ctx.font = "10px monospace";
|
||||
ctx.fillStyle = sel ? "#2563eb" : mapped ? "#16a34a" : "#ea580c";
|
||||
const labelY = t > 14 ? t - 3 : t + h + 11;
|
||||
ctx.fillText(label, l + 2, labelY);
|
||||
}
|
||||
|
||||
if (drawPreview) {
|
||||
ctx.strokeStyle = "#2563eb";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([5, 4]);
|
||||
ctx.strokeRect(drawPreview.x, drawPreview.y, drawPreview.w, drawPreview.h);
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
|
||||
function rectToCanvas(
|
||||
rect: [number, number, number, number],
|
||||
vp: PageViewport,
|
||||
): [number, number, number, number] {
|
||||
const [cx1, cy1, cx2, cy2] = vp.convertToViewportRectangle(rect);
|
||||
const l = Math.min(cx1, cx2);
|
||||
const t = Math.min(cy1, cy2);
|
||||
return [l, t, Math.abs(cx2 - cx1), Math.abs(cy2 - cy1)];
|
||||
}
|
||||
|
||||
function canvasToRect(
|
||||
x1: number, y1: number, x2: number, y2: number,
|
||||
vp: PageViewport,
|
||||
): [number, number, number, number] {
|
||||
const [a, b, c, d, e, f] = vp.transform as [number, number, number, number, number, number];
|
||||
const det = a * d - b * c;
|
||||
|
||||
function inv(vx: number, vy: number): [number, number] {
|
||||
return [
|
||||
(d * vx - c * vy + (c * f - d * e)) / det,
|
||||
(-b * vx + a * vy + (b * e - a * f)) / det,
|
||||
];
|
||||
}
|
||||
|
||||
const [px1, py1] = inv(x1, y1);
|
||||
const [px2, py2] = inv(x2, y2);
|
||||
return [
|
||||
Math.min(px1, px2), Math.min(py1, py2),
|
||||
Math.max(px1, px2), Math.max(py1, py2),
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Mouse on overlay ────────────────────────────────────────────────────────
|
||||
|
||||
function canvasPos(e: MouseEvent) {
|
||||
const r = overlayCanvas.getBoundingClientRect();
|
||||
return {
|
||||
x: (e.clientX - r.left) * (overlayCanvas.width / r.width),
|
||||
y: (e.clientY - r.top) * (overlayCanvas.height / r.height),
|
||||
};
|
||||
}
|
||||
|
||||
overlayCanvas.addEventListener("click", (e) => {
|
||||
if (isDrawMode || !viewport) return;
|
||||
const { x, y } = canvasPos(e);
|
||||
|
||||
for (const field of fields.filter(f => f.page === currentPage)) {
|
||||
const [l, t, w, h] = rectToCanvas(field.rect, viewport!);
|
||||
if (x >= l && x <= l + w && y >= t && y <= t + h) {
|
||||
selectField(field.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
selectedFieldId = null;
|
||||
renderOverlay();
|
||||
hideKeyPanel();
|
||||
});
|
||||
|
||||
overlayCanvas.addEventListener("mousedown", (e) => {
|
||||
if (!isDrawMode) return;
|
||||
drawStart = canvasPos(e);
|
||||
});
|
||||
|
||||
overlayCanvas.addEventListener("mousemove", (e) => {
|
||||
if (!isDrawMode || !drawStart) return;
|
||||
const { x, y } = canvasPos(e);
|
||||
renderOverlay({
|
||||
x: Math.min(drawStart.x, x),
|
||||
y: Math.min(drawStart.y, y),
|
||||
w: Math.abs(x - drawStart.x),
|
||||
h: Math.abs(y - drawStart.y),
|
||||
});
|
||||
});
|
||||
|
||||
overlayCanvas.addEventListener("mouseup", (e) => {
|
||||
if (!isDrawMode || !drawStart || !viewport) return;
|
||||
const { x, y } = canvasPos(e);
|
||||
const dx = Math.abs(x - drawStart.x);
|
||||
const dy = Math.abs(y - drawStart.y);
|
||||
|
||||
if (dx > 8 && dy > 8) {
|
||||
pendingManualRect = canvasToRect(
|
||||
Math.min(drawStart.x, x), Math.min(drawStart.y, y),
|
||||
Math.max(drawStart.x, x), Math.max(drawStart.y, y),
|
||||
viewport,
|
||||
);
|
||||
exitDrawMode();
|
||||
showNewManualPanel();
|
||||
} else {
|
||||
drawStart = null;
|
||||
renderOverlay();
|
||||
}
|
||||
|
||||
drawStart = null;
|
||||
});
|
||||
|
||||
// ─── Draw mode ────────────────────────────────────────────────────────────────
|
||||
|
||||
drawBtn.addEventListener("click", () => {
|
||||
if (isDrawMode) exitDrawMode();
|
||||
else enterDrawMode();
|
||||
});
|
||||
|
||||
function enterDrawMode() {
|
||||
isDrawMode = true;
|
||||
drawBtn.textContent = "Cancel Draw";
|
||||
drawBtn.classList.add("active");
|
||||
overlayCanvas.style.cursor = "crosshair";
|
||||
hideKeyPanel();
|
||||
}
|
||||
|
||||
function exitDrawMode() {
|
||||
isDrawMode = false;
|
||||
drawBtn.textContent = "Draw Field";
|
||||
drawBtn.classList.remove("active");
|
||||
overlayCanvas.style.cursor = "default";
|
||||
drawStart = null;
|
||||
renderOverlay();
|
||||
}
|
||||
|
||||
// ─── Field selection ─────────────────────────────────────────────────────────
|
||||
|
||||
function selectField(id: string) {
|
||||
selectedFieldId = id;
|
||||
renderOverlay();
|
||||
|
||||
const field = fields.find(f => f.id === id)!;
|
||||
|
||||
kpFieldName.textContent = field.fieldName;
|
||||
kpTypeBadge.textContent = field.type === "acroform" ? "AcroForm" : "Manual";
|
||||
kpTypeBadge.className = `kp-badge kp-badge-${field.type}`;
|
||||
kpJsonKey.value = field.jsonKey;
|
||||
kpLabel.value = field.label ?? "";
|
||||
kpAntwortFormat.value = field.antwortFormat ?? "einzelfrage";
|
||||
kpFragen.value = (field.fragen ?? []).map((frage) => frage.text).join("\n");
|
||||
kpNameRow.classList.add("hidden");
|
||||
kpRemove.style.display = field.type === "manual" ? "" : "none";
|
||||
keyPanel.classList.remove("hidden");
|
||||
kpJsonKey.focus();
|
||||
|
||||
// Sync sidebar highlight
|
||||
document.querySelectorAll(".field-item").forEach(el =>
|
||||
el.classList.toggle("selected", el.getAttribute("data-id") === id));
|
||||
document.querySelector(`.field-item[data-id="${id}"]`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
function showNewManualPanel() {
|
||||
kpFieldName.textContent = "(new manual field)";
|
||||
kpTypeBadge.textContent = "Manual";
|
||||
kpTypeBadge.className = "kp-badge kp-badge-manual";
|
||||
kpJsonKey.value = "";
|
||||
kpLabel.value = "";
|
||||
kpAntwortFormat.value = "einzelfrage";
|
||||
kpFragen.value = "";
|
||||
kpNameRow.classList.remove("hidden");
|
||||
kpFieldId.value = "";
|
||||
kpRemove.style.display = "none";
|
||||
keyPanel.classList.remove("hidden");
|
||||
kpFieldId.focus();
|
||||
}
|
||||
|
||||
function hideKeyPanel() {
|
||||
keyPanel.classList.add("hidden");
|
||||
pendingManualRect = null;
|
||||
}
|
||||
|
||||
// ─── Key panel actions ────────────────────────────────────────────────────────
|
||||
|
||||
function normalizeJsonKey(jsonKey: string): string {
|
||||
return jsonKey.replace(/\.antwort$/, "").trim();
|
||||
}
|
||||
|
||||
function parseFragen(lines: string, jsonKey: string): FrageVorlage[] {
|
||||
const clean = lines
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const base = jsonKey.split(".").filter(Boolean).at(-1) || "frage";
|
||||
return clean.map((text, index) => ({
|
||||
id: `${base}_${index + 1}`,
|
||||
text,
|
||||
}));
|
||||
}
|
||||
|
||||
kpAssign.addEventListener("click", () => {
|
||||
const jsonKey = normalizeJsonKey(kpJsonKey.value);
|
||||
const label = kpLabel.value.trim();
|
||||
const antwortFormat = kpAntwortFormat.value as AntwortFormat;
|
||||
const fragen = parseFragen(kpFragen.value, jsonKey);
|
||||
|
||||
kpJsonKey.value = jsonKey;
|
||||
|
||||
if (pendingManualRect) {
|
||||
const name = kpFieldId.value.trim();
|
||||
if (!name) { kpFieldId.focus(); return; }
|
||||
|
||||
const entry: FieldEntry = {
|
||||
id: `manual-${Date.now()}`,
|
||||
fieldName: name,
|
||||
jsonKey,
|
||||
rect: pendingManualRect,
|
||||
page: currentPage,
|
||||
type: "manual",
|
||||
label: label || undefined,
|
||||
antwortFormat,
|
||||
fragen,
|
||||
};
|
||||
fields.push(entry);
|
||||
selectedFieldId = entry.id;
|
||||
pendingManualRect = null;
|
||||
} else if (selectedFieldId) {
|
||||
const field = fields.find(f => f.id === selectedFieldId);
|
||||
if (field) {
|
||||
field.jsonKey = jsonKey;
|
||||
field.label = label || undefined;
|
||||
field.antwortFormat = antwortFormat;
|
||||
field.fragen = fragen;
|
||||
}
|
||||
}
|
||||
|
||||
persist();
|
||||
hideKeyPanel();
|
||||
renderOverlay();
|
||||
renderFieldList();
|
||||
});
|
||||
|
||||
kpCancel.addEventListener("click", () => {
|
||||
pendingManualRect = null;
|
||||
selectedFieldId = null;
|
||||
hideKeyPanel();
|
||||
renderOverlay();
|
||||
});
|
||||
|
||||
kpRemove.addEventListener("click", () => {
|
||||
if (!selectedFieldId) return;
|
||||
fields = fields.filter(f => f.id !== selectedFieldId);
|
||||
selectedFieldId = null;
|
||||
persist();
|
||||
hideKeyPanel();
|
||||
renderOverlay();
|
||||
renderFieldList();
|
||||
});
|
||||
|
||||
kpJsonKey.addEventListener("keydown", e => {
|
||||
if (e.key === "Enter") kpAssign.click();
|
||||
if (e.key === "Escape") kpCancel.click();
|
||||
});
|
||||
|
||||
kpFieldId.addEventListener("keydown", e => {
|
||||
if (e.key === "Enter") kpJsonKey.focus();
|
||||
});
|
||||
|
||||
// ─── Global keyboard shortcuts ────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener("keydown", e => {
|
||||
if (e.key === "Escape") {
|
||||
if (isDrawMode) { exitDrawMode(); return; }
|
||||
if (!keyPanel.classList.contains("hidden")) kpCancel.click();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Field list sidebar ───────────────────────────────────────────────────────
|
||||
|
||||
function renderFieldList() {
|
||||
const mapped = fields.filter(f => f.jsonKey).length;
|
||||
fieldCount.textContent = `${mapped} / ${fields.length} mapped`;
|
||||
|
||||
if (fields.length === 0) {
|
||||
fieldList.innerHTML = '<p class="field-empty">No fields detected.<br>Use "Draw Field" to add manually.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const byPage = new Map<number, FieldEntry[]>();
|
||||
for (const f of fields) {
|
||||
if (!byPage.has(f.page)) byPage.set(f.page, []);
|
||||
byPage.get(f.page)!.push(f);
|
||||
}
|
||||
|
||||
fieldList.innerHTML = "";
|
||||
for (const [page, pf] of [...byPage.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
const label = document.createElement("div");
|
||||
label.className = "field-page-label";
|
||||
label.textContent = `Page ${page}`;
|
||||
fieldList.appendChild(label);
|
||||
|
||||
for (const field of pf) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "field-item" +
|
||||
(field.id === selectedFieldId ? " selected" : "") +
|
||||
(field.jsonKey ? " mapped" : "");
|
||||
item.dataset.id = field.id;
|
||||
item.innerHTML = `
|
||||
<span class="fi-name">${field.fieldName}</span>
|
||||
<span class="fi-key">${field.jsonKey || "unmapped"}</span>
|
||||
`;
|
||||
item.addEventListener("click", async () => {
|
||||
if (field.page !== currentPage) {
|
||||
currentPage = field.page;
|
||||
await renderPage(currentPage);
|
||||
}
|
||||
selectField(field.id);
|
||||
});
|
||||
fieldList.appendChild(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Save ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function setNested(obj: Record<string, unknown>, path: string, value: unknown): void {
|
||||
const parts = path.split(".");
|
||||
let cur: Record<string, unknown> = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const key = parts[i]!;
|
||||
if (typeof cur[key] !== "object" || cur[key] === null) cur[key] = {};
|
||||
cur = cur[key] as Record<string, unknown>;
|
||||
}
|
||||
cur[parts[parts.length - 1]!] = value;
|
||||
}
|
||||
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
const template: Record<string, unknown> = {};
|
||||
for (const f of fields) {
|
||||
if (!f.jsonKey) continue;
|
||||
|
||||
const isAntwortBlock = f.jsonKey.startsWith("fragen.") || f.jsonKey.startsWith("kriterium.");
|
||||
setNested(
|
||||
template,
|
||||
f.jsonKey,
|
||||
isAntwortBlock
|
||||
? {
|
||||
fieldName: f.fieldName,
|
||||
label: f.label,
|
||||
antwortFormat: f.antwortFormat ?? "einzelfrage",
|
||||
fragen: f.fragen ?? [],
|
||||
}
|
||||
: f.fieldName,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch("/api/save", {
|
||||
method: "POST",
|
||||
headers: csrfHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(template),
|
||||
});
|
||||
const orig = saveBtn.textContent;
|
||||
saveBtn.textContent = "Saved ✓";
|
||||
setTimeout(() => { saveBtn.textContent = orig; }, 2000);
|
||||
} catch (err) {
|
||||
console.error("Save failed:", err);
|
||||
}
|
||||
});
|
||||
|
||||
function persist() {
|
||||
if (!currentFilename) return;
|
||||
localStorage.setItem(`tb:${currentFilename}`, JSON.stringify(fields));
|
||||
}
|
||||
Reference in New Issue
Block a user