initial commit

This commit is contained in:
syntaxbullet
2026-05-13 17:26:13 +02:00
commit 99569d2cf3
43 changed files with 8725 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
The template builder is a custom built web application designed to create a JSON mapping from the names of input fields within a PDF file, to corresponding nested JSON keys.
The Application is designed to allow the user to visually select any input field within a loaded and rendered PDF file, and assign a specific nested json key to it, e.g "information.company.name".

View 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));
}

View File

@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Template Builder</title>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<header class="header">
<div class="header-left">
<span class="app-name">Template Builder</span>
<span id="filename-display" class="filename"></span>
</div>
<div class="toolbar">
<input type="file" id="file-input" accept=".pdf" hidden>
<button id="upload-btn" class="btn">Upload PDF</button>
<button id="draw-btn" class="btn" disabled>Draw Field</button>
<button id="save-btn" class="btn btn-primary" disabled>Save Template</button>
</div>
</header>
<div class="app">
<aside class="sidebar">
<div class="sidebar-header">
<span class="sidebar-title">Fields</span>
<span id="field-count" class="field-count"></span>
</div>
<div id="field-list" class="field-list">
<p class="field-empty">Upload a PDF to begin.</p>
</div>
</aside>
<main class="viewer">
<div class="pagination">
<button id="prev-page" class="btn btn-icon" disabled>&#8592;</button>
<span id="page-info" class="page-info"></span>
<button id="next-page" class="btn btn-icon" disabled>&#8594;</button>
</div>
<div class="canvas-container" id="canvas-container">
<div id="upload-prompt" class="upload-prompt">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<p>Upload a PDF to get started</p>
</div>
<div class="canvases" id="canvases">
<canvas id="pdf-canvas"></canvas>
<canvas id="overlay-canvas"></canvas>
</div>
</div>
</main>
</div>
<!-- Key assignment panel -->
<div id="key-panel" class="key-panel hidden">
<div class="key-panel-inner">
<div class="key-panel-meta">
<span class="kp-label">Field</span>
<span id="kp-field-name" class="kp-field-name"></span>
<span id="kp-type-badge" class="kp-badge"></span>
</div>
<div id="kp-name-row" class="kp-row hidden">
<label for="kp-field-id">Field Identifier</label>
<input type="text" id="kp-field-id" class="kp-input" placeholder="e.g. company_name" autocomplete="off">
</div>
<div class="kp-row">
<label for="kp-json-key">JSON Key Path</label>
<input type="text" id="kp-json-key" class="kp-input" placeholder="e.g. information.company.name" autocomplete="off">
</div>
<div class="kp-row">
<label for="kp-label">Label / Titel</label>
<input type="text" id="kp-label" class="kp-input" placeholder="z.B. Robustheit & Resilienz" autocomplete="off">
</div>
<div class="kp-row">
<label for="kp-antwort-format">Antwort-Zuordnung</label>
<select id="kp-antwort-format" class="kp-input kp-select">
<option value="einzelfrage">1 Feld = 1 Frage</option>
<option value="mehrfachfrage_ein_antwortfeld">1 Feld = mehrere Fragen / eine gemeinsame Antwort</option>
</select>
</div>
<div class="kp-row">
<label for="kp-fragen">Fragen (eine pro Zeile)</label>
<textarea id="kp-fragen" class="kp-input kp-textarea" placeholder="Frage 1&#10;Frage 2&#10;Frage 3"></textarea>
</div>
<div class="kp-actions">
<button id="kp-assign" class="btn btn-primary">Assign</button>
<button id="kp-cancel" class="btn">Cancel</button>
<button id="kp-remove" class="btn btn-danger" style="display:none">Remove</button>
</div>
</div>
</div>
<script type="module" src="./frontend.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,96 @@
import index from "./index.html";
import { dirname, join } from "node:path";
import { mkdir } from "node:fs/promises";
import { dataPath } from "../../deployPaths";
import { isSafeStem, jsonError, withAuth } from "../../security";
export const workerPath = new URL(
import.meta.resolve("pdfjs-dist/build/pdf.worker.mjs"),
).pathname;
const TEMPLATE_PATH =
process.env.BMP_TEMPLATE_PATH ??
dataPath(join(import.meta.dir, "template.json"), "templatebuilder", "template.json");
await mkdir(dirname(TEMPLATE_PATH), { recursive: true });
async function loadTemplate(): Promise<Record<string, unknown>> {
const file = Bun.file(TEMPLATE_PATH);
if (!(await file.exists())) return {};
return file.json();
}
export const routes = {
"/builder": index,
"/api/save": {
POST: withAuth(async (req: Request) => {
let data;
try {
data = await req.json();
} catch {
return jsonError("Expected JSON body", 400);
}
if (!isSafeTemplate(data)) {
return jsonError("Template contains unsafe keys or too many fields", 400);
}
await Bun.write(TEMPLATE_PATH, JSON.stringify(data, null, 2));
return Response.json({ ok: true });
}, {
csrf: true,
limit: { key: "template-save", max: 20, windowMs: 60_000 },
}),
},
"/api/load": {
GET: withAuth(async () => {
return Response.json(await loadTemplate());
}),
},
} as const;
function isSafeTemplate(value: unknown): value is Record<string, unknown> {
let fieldCount = 0;
function visit(node: unknown, depth: number): boolean {
if (depth > 8 || typeof node !== "object" || node === null || Array.isArray(node)) {
return false;
}
for (const [key, child] of Object.entries(node as Record<string, unknown>)) {
if (!/^[a-zA-Z0-9_-]{1,80}$/.test(key)) return false;
if (typeof child === "string") {
fieldCount += 1;
if (child.length > 200 || fieldCount > 250) return false;
continue;
}
if (typeof child === "object" && child !== null && "fieldName" in child) {
fieldCount += 1;
if (fieldCount > 250 || !isSafeFieldObject(child)) return false;
continue;
}
if (!visit(child, depth + 1)) return false;
}
return true;
}
return visit(value, 0);
}
function isSafeFieldObject(value: object): boolean {
const field = value as Record<string, unknown>;
if (typeof field.fieldName !== "string" || field.fieldName.length > 200) return false;
if (field.label != null && (typeof field.label !== "string" || field.label.length > 300)) return false;
if (field.antwortFormat != null && field.antwortFormat !== "einzelfrage" && field.antwortFormat !== "mehrfachfrage_ein_antwortfeld") {
return false;
}
if (field.fragen != null) {
if (!Array.isArray(field.fragen) || field.fragen.length > 25) return false;
for (const frage of field.fragen) {
if (typeof frage !== "object" || frage === null) return false;
const item = frage as Record<string, unknown>;
if (typeof item.text !== "string" || item.text.length > 1000) return false;
if (item.id != null && (typeof item.id !== "string" || !isSafeStem(item.id))) return false;
}
}
return true;
}

View File

@@ -0,0 +1,7 @@
{
"name": "templatebuilder",
"version": "0.1.0",
"main": "index.ts",
"type": "module",
"private": true
}

View File

@@ -0,0 +1,379 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0d0d;
--surface: #161616;
--surface2: #1e1e1e;
--border: #2a2a2a;
--text: #e0e0e0;
--muted: #777;
--accent: #2563eb;
--green: #16a34a;
--orange: #ea580c;
--danger: #dc2626;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
color-scheme: dark;
}
body {
background: var(--bg);
color: var(--text);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Header ──────────────────────────────────────────────────────────────── */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 12px;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.app-name {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.filename {
font-size: 11px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbar {
display: flex;
gap: 6px;
flex-shrink: 0;
}
/* ── Buttons ──────────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 11px;
border-radius: 5px;
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text);
cursor: pointer;
font-size: 12px;
line-height: 1;
transition: background 0.12s, border-color 0.12s;
white-space: nowrap;
}
.btn:hover:not(:disabled) { background: #282828; }
.btn:disabled { opacity: 0.38; cursor: not-allowed; }
.btn-primary {
background: var(--accent);
border-color: #1d4ed8;
}
.btn-primary:hover:not(:disabled) { background: #1d4ed8; }
.btn-danger {
background: var(--danger);
border-color: #b91c1c;
}
.btn-danger:hover:not(:disabled) { background: #b91c1c; }
.btn.active {
background: var(--accent);
border-color: #1d4ed8;
}
.btn-icon {
padding: 5px 10px;
font-size: 14px;
}
/* ── Layout ───────────────────────────────────────────────────────────────── */
.app {
display: flex;
flex: 1;
overflow: hidden;
}
/* ── Sidebar ──────────────────────────────────────────────────────────────── */
.sidebar {
width: 240px;
flex-shrink: 0;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.sidebar-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
.field-count { font-size: 11px; color: var(--muted); }
.field-list {
flex: 1;
overflow-y: auto;
padding: 2px 0;
}
.field-empty {
padding: 16px 12px;
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
.field-page-label {
padding: 8px 12px 3px;
font-size: 10px;
color: var(--muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.field-item {
padding: 6px 12px;
cursor: pointer;
border-left: 2px solid transparent;
transition: background 0.1s;
}
.field-item:hover { background: #1a1a1a; }
.field-item.selected {
background: rgba(37, 99, 235, 0.1);
border-left-color: var(--accent);
}
.fi-name {
display: block;
font-size: 12px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fi-key {
display: block;
font-size: 11px;
color: var(--orange);
font-family: "SF Mono", "Fira Mono", monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 1px;
}
.field-item.mapped .fi-key { color: var(--green); }
/* ── Viewer ───────────────────────────────────────────────────────────────── */
.viewer {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.pagination {
display: flex;
align-items: center;
gap: 10px;
justify-content: center;
padding: 6px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.page-info {
font-size: 12px;
color: var(--muted);
min-width: 90px;
text-align: center;
}
.canvas-container {
flex: 1;
overflow: auto;
background: #0a0a0a;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 20px;
position: relative;
}
.upload-prompt {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: var(--muted);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
pointer-events: none;
}
.upload-prompt p { font-size: 13px; }
/* Two canvases stacked */
.canvases {
position: relative;
display: none; /* shown after PDF loads */
flex-shrink: 0;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 8px 32px rgba(0, 0, 0, 0.6);
}
#pdf-canvas { display: block; }
#overlay-canvas { position: absolute; top: 0; left: 0; cursor: default; }
/* ── Key panel ────────────────────────────────────────────────────────────── */
.key-panel {
position: fixed;
bottom: 16px;
right: 16px;
width: 300px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 10px 36px rgba(0, 0, 0, 0.55);
z-index: 200;
}
.key-panel.hidden { display: none; }
.key-panel-inner {
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.key-panel-meta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.kp-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.kp-field-name {
font-size: 12px;
font-family: "SF Mono", "Fira Mono", monospace;
color: var(--text);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kp-badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 3px;
font-weight: 500;
flex-shrink: 0;
}
.kp-badge-acroform { background: rgba(37,99,235,0.2); color: #60a5fa; }
.kp-badge-manual { background: rgba(234,88,12,0.2); color: #fb923c; }
.kp-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.kp-row.hidden { display: none; }
.kp-row label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.kp-input {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 8px;
border-radius: 4px;
font-family: "SF Mono", "Fira Mono", monospace;
font-size: 12px;
width: 100%;
transition: border-color 0.12s;
}
.kp-select {
font-family: inherit;
}
.kp-textarea {
min-height: 110px;
resize: vertical;
line-height: 1.45;
}
.kp-input:focus {
outline: none;
border-color: var(--accent);
}
.kp-actions {
display: flex;
gap: 6px;
align-items: center;
}
.kp-actions .btn-danger { margin-left: auto; }
/* ── Scrollbar ────────────────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #444; }

View File

@@ -0,0 +1,169 @@
{
"kontakt": {
"unternehmen": {
"name": "Textfeld 1",
"branche": "Textfeld 1_2",
"rechtsform": "Textfeld 1_3",
"plz": "Textfeld 1_4",
"regierungsbezirk": "Textfeld 1_5",
"adresse": "Textfeld 1_6",
"telefon": "Textfeld 1_7",
"email": "Textfeld 1_8",
"web": "Textfeld 1_9"
},
"ansprechpartner": {
"name": "Textfeld 1_10",
"funktion": "Textfeld 1_11",
"telefon": "Textfeld 1_12",
"email": "Textfeld 1_13"
}
},
"unternehmen": {
"gruendungsjahr": "Textfeld 1_14",
"anzahl_mitarbeiter": "Textfeld 1_15",
"anzahl_azubi": "Textfeld 1_16",
"standorte_deutschland": "Textfeld 1_17",
"standorte_ausland": "Textfeld 1_18",
"umsatzvolumen1": "Textfeld 1_19",
"umsatzvolumen2": "Textfeld 1_20",
"umsatzvolumen3": "Textfeld 1_21",
"referenzen": "Textfeld 1_22"
},
"fragen": {
"frage1": {
"fieldName": "Textfeld 2",
"label": "Frage 1",
"antwortFormat": "einzelfrage",
"fragen": [
{
"id": "frage1_1",
"text": "Was zeichnet Ihr Unternehmen Ihrer Meinung nach aus?"
}
]
},
"frage2": {
"fieldName": "Textfeld 2_2",
"label": "Frage 2",
"antwortFormat": "einzelfrage",
"fragen": [
{
"id": "frage2_1",
"text": "Wie definieren Sie für Ihr Unternehmen den Begriff „erfolgreich“?"
}
]
},
"frage3": {
"fieldName": "Textfeld 2_3",
"label": "Frage 3",
"antwortFormat": "einzelfrage",
"fragen": [
{
"id": "frage3_1",
"text": "Beschreiben Sie kurz, wie sich Ihr Unternehmen in den letzten 5 Jahren entwickelt hat?"
}
]
}
},
"kriterium": {
"robustheit_resilienz": {
"fieldName": "Textfeld 3",
"label": "Robustheit/Resilienz",
"antwortFormat": "mehrfachfrage_ein_antwortfeld",
"fragen": [
{
"id": "robustheit_resilienz_1",
"text": "Wie hat Ihr Unternehmen auf Veränderungen in der Vergangenheit reagiert?"
},
{
"id": "robustheit_resilienz_2",
"text": "Wie können in Ihrem Unternehmen frühzeitig neue Herausforderungen erkannt und gemanagt werden? Haben Sie hierzu beispielsweise Strukturen, Prozesse oder Instrumente (z.B. Risikomanagementsystem) im Einsatz?"
},
{
"id": "robustheit_resilienz_3",
"text": "Wie sichern Sie in Ihrem Unternehmen die Verbundenheit mit Ihren Stakeholdern, Kunden, Lieferanten und Mitarbeitenden?"
},
{
"id": "robustheit_resilienz_4",
"text": "Wie ist Ihr Unternehmen finanziell aufgestellt, um Liquiditätsengpässe zu überbrücken und den langfristigen Erfolg zu sichern?"
},
{
"id": "robustheit_resilienz_5",
"text": "Wie ist Ihr Unternehmen am Standort Bayern, aber auch über die Region hinaus vernetzt? Besteht beispielsweise ein Austausch im Rahmen von Netzwerken, die Zusammenarbeit mit öffentlichen Stellen oder gibt es sonstige Formen der Vernetzung, die helfen, den Standort Bayern zu sichern?"
}
]
},
"zukunftsfaehigkeit_innovation": {
"fieldName": "Textfeld 6",
"label": "Zukunftsfähigkeit/Innovation",
"antwortFormat": "mehrfachfrage_ein_antwortfeld",
"fragen": [
{
"id": "zukunftsfaehigkeit_innovation_1",
"text": "Welche Ziele hinsichtlich Innovationen haben Sie in Ihrem Unternehmen formuliert, um Kundenbedarfe zu erkennen?"
},
{
"id": "zukunftsfaehigkeit_innovation_2",
"text": "Wie stellen Sie sicher, dass Sie neue Trends erkennen und in Ihrem Unternehmen umsetzen, um Ihre Wettbewerbsfähigkeit zu sichern?"
},
{
"id": "zukunftsfaehigkeit_innovation_3",
"text": "Wie passiert bei Ihnen Produkt- und/oder Prozess-Innovation, d.h. wie kommen Sie z.B. zu neuen Produkten und Prozessen?"
},
{
"id": "zukunftsfaehigkeit_innovation_4",
"text": "Welche konkreten Maßnahmen bestehen zum Aufbau von innovativen Netzwerken, zur Integration von neuen Partnern und Geschäftsbeziehungen und zur Erweiterung der eigenen Fähigkeiten?"
},
{
"id": "zukunftsfaehigkeit_innovation_5",
"text": "Welche Form von Wissensmanagement und Wissensarbeit besteht bei Ihnen im Unternehmen? Wie wird beispielsweise Wissen unabhängig von Personen gespeichert, wie wird es verarbeitet und priorisiert?"
}
]
},
"nachhaltigkeit_verantwortung": {
"fieldName": "Textfeld 6_2",
"label": "Nachhaltigkeit/Verantwortung",
"antwortFormat": "mehrfachfrage_ein_antwortfeld",
"fragen": [
{
"id": "nachhaltigkeit_verantwortung_1",
"text": "Welche Aktivitäten zeigen Sie in Bezug auf den nachhaltigen Einsatz von Ressourcen, Rohstoffen und Materialien?"
},
{
"id": "nachhaltigkeit_verantwortung_2",
"text": "Welche Rolle spielen regionale Lieferanten und regionale Wertschöpfungsketten in Ihrem Unternehmen? Die sog. „Corporate Social Responsibility“ kann sich durch unterschiedliche Aktivitäten im Unternehmen äußern. Mit welchen Aktivitäten kommen Sie der „sozialen und regionalen“ Verantwortung in Ihrem Unternehmen nach?"
},
{
"id": "nachhaltigkeit_verantwortung_3",
"text": "Welche Aspekte sprechen aus Ihrer Sicht für die Bindung an Ihren Standort? Welche Rollen sehen Sie für sich dabei, die Region zu entwickeln?"
}
]
},
"attraktivitaet": {
"fieldName": "Textfeld 4",
"label": "Attraktivität",
"antwortFormat": "mehrfachfrage_ein_antwortfeld",
"fragen": [
{
"id": "attraktivitaet_1",
"text": "Wie sorgen Sie dafür, dass Ihre Mitarbeiterinnen und Mitarbeiter gerne einen Beitrag für den Erfolg Ihres Unternehmens leisten? Wie sorgen Sie für „Freude an Resultaten“ und Durchhaltevermögen in „schwierigen Zeiten“?"
},
{
"id": "attraktivitaet_2",
"text": "Auf welche Weise wird für Ihre Mitarbeiterinnen und Mitarbeiter der Sinn und Nutzen ihrer täglichen Arbeit erlebbar? Woran erkennen Sie, dass das gelingt?"
},
{
"id": "attraktivitaet_3",
"text": "Welche Möglichkeiten bestehen in Ihrem Unternehmen für die persönliche und fachliche Entwicklung der Mitarbeiterinnen und Mitarbeiter? Inwiefern können diese Einfluss darauf nehmen?"
},
{
"id": "attraktivitaet_4",
"text": "Woran können potentielle Bewerber/-innen Ihr Unternehmen als exzellenten Arbeitgeber erkennen? Wie stellen Sie sicher, dass dies für die richtigen Bewerber ersichtlich ist?"
},
{
"id": "attraktivitaet_5",
"text": "Woran erkennen Sie, dass Ihre Unternehmenskultur „funktioniert“? Wie stellen Sie sicher, dass Verantwortung, Vertrauen, Leistung, Lernen und Innovation in die Unternehmenskultur einfließen?"
}
]
}
}
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}