2026-05-26 17:40:37 +02:00

1171 lines
41 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Printer Backend — Admin</title>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
<style>
:root {
--bg: #0f1419;
--surface: #1a2332;
--border: #2d3a4d;
--text: #e7ecf3;
--muted: #8b9cb3;
--accent: #3b82f6;
--ok: #22c55e;
--err: #ef4444;
--sidebar-w: 260px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
line-height: 1.5;
}
.app {
display: flex;
min-height: 100vh;
}
.sidebar {
width: var(--sidebar-w);
flex-shrink: 0;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
overflow: hidden;
}
.sidebar-brand {
padding: 1.25rem 1rem 1rem;
border-bottom: 1px solid var(--border);
}
.sidebar-brand h1 { font-size: 1.1rem; font-weight: 600; }
.sidebar-brand p { color: var(--muted); font-size: 0.8rem; margin-top: 0.2rem; }
.sidebar-nav {
flex: 1;
overflow-y: auto;
padding: 0.5rem 0;
}
.nav-section { margin-bottom: 0.25rem; }
.nav-section-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.55rem 1rem;
background: none;
border: none;
color: var(--text);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
text-align: left;
}
.nav-section-header:hover { background: rgba(255,255,255,0.04); }
.nav-section-header.active { color: var(--accent); }
.nav-chevron {
width: 0.65rem;
height: 0.65rem;
border-right: 2px solid var(--muted);
border-bottom: 2px solid var(--muted);
transform: rotate(-45deg);
transition: transform 0.15s;
flex-shrink: 0;
margin-left: auto;
}
.nav-chevron.open { transform: rotate(45deg); margin-top: -0.2rem; }
.nav-children {
padding: 0.15rem 0 0.35rem;
}
.nav-item {
display: block;
width: 100%;
padding: 0.4rem 1rem 0.4rem 1.75rem;
background: none;
border: none;
color: var(--muted);
font-size: 0.82rem;
text-align: left;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nav-item:hover { color: var(--text); background: rgba(255,255,255,0.04); }
.nav-item.selected {
color: var(--text);
background: rgba(59, 130, 246, 0.15);
border-right: 2px solid var(--accent);
}
.nav-item.add {
color: var(--accent);
font-weight: 500;
}
.nav-item.add:hover { background: rgba(59, 130, 246, 0.1); }
.nav-empty {
padding: 0.25rem 1rem 0.25rem 1.75rem;
font-size: 0.78rem;
color: var(--muted);
font-style: italic;
}
.sidebar-footer {
padding: 1rem;
border-top: 1px solid var(--border);
}
.sidebar-footer label { font-size: 0.75rem; color: var(--muted); display: block; margin-bottom: 0.35rem; }
.sidebar-footer input {
width: 100%;
padding: 0.4rem 0.6rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.78rem;
}
.api-status { font-size: 0.75rem; margin-top: 0.35rem; }
.api-status.ok { color: var(--ok); }
.api-status.err { color: var(--err); }
.main {
flex: 1;
min-width: 0;
padding: 2rem 2rem 3rem;
max-width: 900px;
}
.main-header {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.main-header h2 { font-size: 1.35rem; font-weight: 600; }
.main-header p { color: var(--muted); margin-top: 0.25rem; font-size: 0.9rem; }
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.5rem;
}
.muted { color: var(--muted); font-size: 0.85rem; margin-top: 0.5rem; }
button {
padding: 0.5rem 1rem;
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
}
button:hover { filter: brightness(1.1); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.secondary {
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
}
button.secondary:hover { background: rgba(59, 130, 246, 0.12); filter: none; }
.btn-row { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1.25rem; align-items: center; }
button.danger {
background: transparent;
border: 1px solid var(--err);
color: var(--err);
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
button.danger:hover { background: rgba(239, 68, 68, 0.12); filter: none; }
input, select {
width: 100%;
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.875rem;
}
label { font-size: 0.8rem; color: var(--muted); display: block; margin-top: 0.75rem; }
label:first-child { margin-top: 0; }
.form-grid {
display: grid;
gap: 0.5rem 1rem;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.msg-err { color: var(--err); font-size: 0.85rem; margin-top: 0.75rem; }
.msg-ok { color: var(--ok); font-size: 0.85rem; margin-top: 0.75rem; }
.item-preview-panel {
margin-bottom: 1.25rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
display: flex;
align-items: center;
justify-content: center;
min-height: 180px;
}
.item-preview-panel img {
max-width: 100%;
max-height: 160px;
background: #fff;
padding: 0.5rem;
border-radius: 4px;
}
.layout-preview-wrap {
margin-top: 1.25rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
overflow: auto;
}
.layout-preview-wrap svg {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
}
.layout-stats {
display: flex;
flex-wrap: wrap;
gap: 1rem 2rem;
margin-top: 0.75rem;
font-size: 0.85rem;
}
.layout-stats strong { color: var(--text); }
.layout-stats span { color: var(--muted); }
.empty-state {
color: var(--muted);
font-size: 0.95rem;
padding: 2rem 0;
text-align: center;
}
@media (max-width: 720px) {
.app { flex-direction: column; }
.sidebar {
width: 100%;
height: auto;
position: relative;
max-height: 45vh;
}
.main { padding: 1.25rem; }
}
</style>
</head>
<body>
<div class="app" x-data="dashboard()" x-init="init()">
<aside class="sidebar">
<div class="sidebar-brand">
<h1>Printer Backend</h1>
<p>Admin</p>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<button type="button" class="nav-section-header"
:class="{ active: section === 'plates' }"
@click="openSection('plates')">
Platten
<span class="nav-chevron" :class="{ open: expanded.plates }"></span>
</button>
<div class="nav-children" x-show="expanded.plates">
<template x-if="plates.length === 0 && !platesLoading">
<p class="nav-empty">Keine Platten</p>
</template>
<template x-for="p in plates" :key="p.id">
<button type="button" class="nav-item"
:class="{ selected: section === 'plates' && plateEditingId === p.id }"
@click="selectPlate(p)"
x-text="sidebarPlateLabel(p)"></button>
</template>
<button type="button" class="nav-item add"
:class="{ selected: section === 'plates' && !plateEditingId }"
@click="newPlate()">+ Neu</button>
</div>
</div>
<div class="nav-section">
<button type="button" class="nav-section-header"
:class="{ active: section === 'items' }"
@click="openSection('items')">
Items
<span class="nav-chevron" :class="{ open: expanded.items }"></span>
</button>
<div class="nav-children" x-show="expanded.items">
<template x-if="items.length === 0 && !itemsLoading">
<p class="nav-empty">Keine Items</p>
</template>
<template x-for="it in items" :key="it.id">
<button type="button" class="nav-item"
:class="{ selected: section === 'items' && itemEditingId === it.id }"
@click="selectItem(it)"
x-text="labelItem(it)"></button>
</template>
<button type="button" class="nav-item add"
:class="{ selected: section === 'items' && !itemEditingId }"
@click="newItem()">+ Neu</button>
</div>
</div>
<div class="nav-section">
<button type="button" class="nav-section-header"
:class="{ active: section === 'configurations' }"
@click="openSection('configurations')">
Konfigurationen
<span class="nav-chevron" :class="{ open: expanded.configurations }"></span>
</button>
<div class="nav-children" x-show="expanded.configurations">
<template x-if="configurations.length === 0">
<p class="nav-empty">Keine Konfigurationen</p>
</template>
<template x-for="c in configurations" :key="c.id">
<button type="button" class="nav-item"
:class="{ selected: section === 'configurations' && configEditingId === c.id }"
@click="selectConfiguration(c)"
x-text="sidebarConfigLabel(c)"></button>
</template>
<button type="button" class="nav-item add"
:class="{ selected: section === 'configurations' && !configEditingId }"
@click="newConfiguration()">+ Neu</button>
</div>
</div>
</nav>
<div class="sidebar-footer">
<label for="api-base">API</label>
<input id="api-base" type="url" x-model="apiBase" @change="onApiBaseChange()" placeholder="http://127.0.0.1:8080">
<p class="api-status ok" x-show="apiOk === true">Erreichbar</p>
<p class="api-status err" x-show="apiOk === false">Nicht erreichbar</p>
</div>
</aside>
<main class="main">
<!-- Platten -->
<div x-show="section === 'plates'">
<div class="main-header">
<h2 x-text="plateEditingId ? 'Platte bearbeiten' : 'Neue Platte'"></h2>
<p class="muted" x-show="!plateEditingId">Druckbett mit Abmessungen und Rändern anlegen.</p>
</div>
<div class="panel">
<form @submit.prevent="savePlate()">
<div class="form-grid">
<div>
<label>Name (optional)</label>
<input type="text" x-model="plateForm.name" placeholder="z.B. Druckbett A3">
</div>
<div>
<label>Breite (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="plateForm.width_mm" required>
</div>
<div>
<label>Höhe (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="plateForm.height_mm" required>
</div>
<div>
<label>Margin oben</label>
<input type="number" step="0.1" min="0" x-model.number="plateForm.margin_top_mm">
</div>
<div>
<label>Margin rechts</label>
<input type="number" step="0.1" min="0" x-model.number="plateForm.margin_right_mm">
</div>
<div>
<label>Margin unten</label>
<input type="number" step="0.1" min="0" x-model.number="plateForm.margin_bottom_mm">
</div>
<div>
<label>Margin links</label>
<input type="number" step="0.1" min="0" x-model.number="plateForm.margin_left_mm">
</div>
</div>
<div class="btn-row">
<button type="submit" :disabled="plateSaving">
<span x-text="plateSaving ? 'Speichere…' : (plateEditingId ? 'Speichern' : 'Anlegen')"></span>
</button>
<button type="button" class="danger" x-show="plateEditingId" @click="deletePlate(plateEditingId)">Löschen</button>
</div>
<p class="msg-err" x-show="plateError" x-text="plateError"></p>
<p class="msg-ok" x-show="plateSuccess" x-text="plateSuccess"></p>
</form>
</div>
</div>
<!-- Items -->
<div x-show="section === 'items'">
<div class="main-header">
<h2 x-text="itemEditingId ? 'Item bearbeiten' : 'Neues Item'"></h2>
<p class="muted" x-show="!itemEditingId">Erzeugt SVG-Maske in <code>data/svg_template/</code>.</p>
<p class="muted" x-show="itemEditingId">SVG und Metadaten aktualisieren; Dateiname bleibt unverändert.</p>
</div>
<div class="panel">
<div class="item-preview-panel" x-show="itemEditingId">
<img :src="itemSvgUrl(itemEditingId)" alt="SVG-Vorschau" loading="lazy">
</div>
<form @submit.prevent="saveItem()">
<div class="form-grid">
<div>
<label>Name</label>
<input type="text" x-model="itemForm.name" placeholder="z.B. sticker_80" :required="!itemEditingId">
</div>
<div>
<label>Breite (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="itemForm.width_mm" required>
</div>
<div>
<label>Höhe (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="itemForm.height_mm" required>
</div>
<div>
<label>Eckenradius (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="itemForm.corner_radius_mm">
</div>
<div>
<label>Bleed (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="itemForm.bleed_mm">
</div>
<div>
<label>Margin (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="itemForm.margin_mm">
</div>
<div>
<label>Padding (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="itemForm.padding_mm">
</div>
</div>
<div class="btn-row">
<button type="submit" :disabled="itemSaving">
<span x-text="itemSaving ? 'Speichere…' : (itemEditingId ? 'Speichern' : 'Anlegen')"></span>
</button>
<button type="button" class="danger" x-show="itemEditingId" @click="deleteItem(itemEditingId)">Löschen</button>
</div>
<p class="msg-err" x-show="itemError" x-text="itemError"></p>
<p class="msg-ok" x-show="itemSuccess" x-text="itemSuccess"></p>
</form>
</div>
</div>
<!-- Konfigurationen -->
<div x-show="section === 'configurations'">
<div class="main-header">
<h2 x-text="configEditingId ? 'Konfiguration bearbeiten' : 'Neue Konfiguration'"></h2>
<p class="muted">Kombiniert Platte und Item; berechnet maximale Stückzahl.</p>
</div>
<div class="panel">
<form @submit.prevent="saveConfiguration()">
<div class="form-grid">
<div>
<label>Name (optional)</label>
<input type="text" x-model="configForm.name" placeholder="z.B. A3 × Sticker 80">
</div>
<div>
<label>Platte</label>
<select x-model="configForm.plate_id" @change="refreshLayoutPreview()" required>
<option value="">— wählen —</option>
<template x-for="p in plates" :key="p.id">
<option :value="p.id" x-text="labelPlate(p)"></option>
</template>
</select>
</div>
<div>
<label>Item</label>
<select x-model="configForm.item_id" @change="refreshLayoutPreview()" required>
<option value="">— wählen —</option>
<template x-for="it in items" :key="it.id">
<option :value="it.id" x-text="labelItem(it)"></option>
</template>
</select>
</div>
<div>
<label>Abstand zwischen Items (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="configForm.spacing_mm" @input="refreshLayoutPreview()">
</div>
</div>
<div class="btn-row">
<button type="submit" :disabled="configSaving || !configForm.plate_id || !configForm.item_id">
<span x-text="configSaving ? 'Speichere…' : (configEditingId ? 'Speichern' : 'Anlegen')"></span>
</button>
<button type="button" class="secondary" @click="downloadLayoutPDF()"
:disabled="pdfGenerating || !layoutPreview || !configForm.plate_id || !configForm.item_id">
<span x-text="pdfGenerating ? 'PDF…' : 'PDF-Vorschau'"></span>
</button>
<button type="button" class="danger" x-show="configEditingId" @click="deleteConfiguration(configEditingId)">Löschen</button>
</div>
<p class="msg-err" x-show="configError" x-text="configError"></p>
<p class="msg-ok" x-show="configSuccess" x-text="configSuccess"></p>
</form>
<template x-if="layoutPreview">
<div class="layout-preview-wrap">
<div x-html="layoutPreviewSvg()"></div>
<div class="layout-stats">
<span><strong x-text="layoutPreview.count"></strong> Items passen</span>
<span><strong x-text="layoutPreview.columns + ' × ' + layoutPreview.rows"></strong> Raster</span>
<span>Item <strong x-text="layoutPreview.footprint_width_mm.toFixed(1) + ' × ' + layoutPreview.footprint_height_mm.toFixed(1) + ' mm'"></strong></span>
<span>Zellenabstand <strong x-text="layoutPreview.cell_width_mm.toFixed(1) + ' mm'"></strong></span>
<span>Druckfläche <strong x-text="layoutPreview.printable_width_mm.toFixed(1) + ' × ' + layoutPreview.printable_height_mm.toFixed(1) + ' mm'"></strong></span>
</div>
</div>
</template>
<p class="muted" x-show="!layoutPreview && configForm.plate_id && configForm.item_id && !layoutLoading">
Keine Vorschau (Platte zu klein oder API-Fehler).
</p>
<p class="muted" x-show="layoutLoading">Berechne Layout…</p>
</div>
</div>
</main>
</div>
<script>
function dashboard() {
return {
apiBase: '',
apiOk: null,
section: 'plates',
expanded: { plates: true, items: false, configurations: false },
plates: [],
platesLoading: false,
plateSaving: false,
plateError: '',
plateSuccess: '',
plateEditingId: null,
plateForm: {
name: '',
width_mm: 300,
height_mm: 400,
margin_top_mm: 10,
margin_right_mm: 10,
margin_bottom_mm: 10,
margin_left_mm: 10,
},
items: [],
itemsLoading: false,
itemSaving: false,
itemError: '',
itemSuccess: '',
itemEditingId: null,
itemForm: {
name: '',
width_mm: 80,
height_mm: 80,
corner_radius_mm: 5,
bleed_mm: 2,
margin_mm: 5,
padding_mm: 3,
},
configurations: [],
configEditingId: null,
configSaving: false,
configError: '',
configSuccess: '',
configForm: {
name: '',
plate_id: '',
item_id: '',
spacing_mm: 2,
},
layoutPreview: null,
layoutLoading: false,
pdfGenerating: false,
_layoutTimer: null,
openSection(name) {
if (this.section === name) {
this.expanded[name] = !this.expanded[name];
} else {
this.section = name;
this.expanded[name] = true;
}
},
selectPlate(p) {
this.section = 'plates';
this.expanded.plates = true;
this.editPlate(p);
},
newPlate() {
this.section = 'plates';
this.expanded.plates = true;
this.cancelPlateEdit();
},
selectItem(it) {
this.section = 'items';
this.expanded.items = true;
this.editItem(it);
},
newItem() {
this.section = 'items';
this.expanded.items = true;
this.cancelItemEdit();
},
selectConfiguration(c) {
this.section = 'configurations';
this.expanded.configurations = true;
this.editConfiguration(c);
},
newConfiguration() {
this.section = 'configurations';
this.expanded.configurations = true;
this.cancelConfigEdit();
},
sidebarPlateLabel(p) {
const n = p.name || 'Platte';
return n + ' · ' + this.fmtSize(p.width_mm, p.height_mm);
},
sidebarConfigLabel(c) {
return c.name || ('Konfiguration ' + c.id.slice(0, 8));
},
apiUrl(path) {
return `${this.apiBase.replace(/\/$/, '')}${path}`;
},
itemSvgUrl(id) {
return `${this.apiUrl('/items/' + id + '/svg')}?t=${Date.now()}`;
},
async init() {
try {
const res = await fetch('/config.json');
if (res.ok) {
const cfg = await res.json();
this.apiBase = cfg.api_base || this.apiBase;
}
} catch (_) {}
if (!this.apiBase) this.apiBase = 'http://127.0.0.1:8080';
await this.onApiBaseChange();
},
async onApiBaseChange() {
await this.checkAPI();
if (this.apiOk) {
await Promise.all([this.loadPlates(), this.loadItems(), this.loadConfigurations()]);
}
},
async checkAPI() {
try {
const res = await fetch(this.apiUrl('/health'));
this.apiOk = res.ok;
} catch {
this.apiOk = false;
}
},
async apiJSON(method, path, body) {
const opts = { method, headers: {} };
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch(this.apiUrl(path), opts);
const text = await res.text();
if (!res.ok) {
let msg = res.statusText;
try {
const err = JSON.parse(text);
if (err.error) msg = err.error;
} catch {
if (text) msg = text;
}
throw new Error(msg);
}
return text ? JSON.parse(text) : null;
},
async loadPlates() {
this.platesLoading = true;
try {
this.plates = await this.apiJSON('GET', '/plates') || [];
} catch (e) {
this.plateError = String(e.message || e);
} finally {
this.platesLoading = false;
}
},
defaultPlateForm() {
return {
name: '',
width_mm: 300,
height_mm: 400,
margin_top_mm: 10,
margin_right_mm: 10,
margin_bottom_mm: 10,
margin_left_mm: 10,
};
},
editPlate(p) {
this.plateEditingId = p.id;
this.plateForm = {
name: p.name || '',
width_mm: p.width_mm,
height_mm: p.height_mm,
margin_top_mm: p.margin_top_mm,
margin_right_mm: p.margin_right_mm,
margin_bottom_mm: p.margin_bottom_mm,
margin_left_mm: p.margin_left_mm,
};
this.plateError = '';
this.plateSuccess = '';
},
cancelPlateEdit() {
this.plateEditingId = null;
this.plateForm = this.defaultPlateForm();
this.plateError = '';
this.plateSuccess = '';
},
async savePlate() {
this.plateSaving = true;
this.plateError = '';
this.plateSuccess = '';
const body = {
name: this.plateForm.name,
width_mm: Number(this.plateForm.width_mm),
height_mm: Number(this.plateForm.height_mm),
margin_top_mm: Number(this.plateForm.margin_top_mm) || 0,
margin_right_mm: Number(this.plateForm.margin_right_mm) || 0,
margin_bottom_mm: Number(this.plateForm.margin_bottom_mm) || 0,
margin_left_mm: Number(this.plateForm.margin_left_mm) || 0,
};
try {
if (this.plateEditingId) {
await this.apiJSON('PUT', '/plates/' + this.plateEditingId, body);
this.plateSuccess = 'Platte aktualisiert.';
} else {
await this.apiJSON('POST', '/plates', body);
this.plateSuccess = 'Platte gespeichert.';
}
const keepId = this.plateEditingId;
await this.loadPlates();
if (keepId) {
const p = this.plates.find(x => x.id === keepId);
if (p) this.editPlate(p);
else this.cancelPlateEdit();
} else {
this.cancelPlateEdit();
}
this.refreshLayoutPreview();
} catch (e) {
this.plateError = String(e.message || e);
} finally {
this.plateSaving = false;
}
},
configsUsingPlate(plateId) {
return (this.configurations || []).filter(c => c.plate_id === plateId);
},
configsUsingItem(itemId) {
return (this.configurations || []).filter(c => c.item_id === itemId);
},
confirmDeletePlate(id) {
const used = this.configsUsingPlate(id);
if (used.length === 0) {
return confirm('Platte wirklich löschen?');
}
const names = used.map(c => '• ' + this.configSummary(c)).join('\n');
return confirm(
'Diese Platte wird in ' + used.length + ' Konfiguration(en) verwendet:\n\n'
+ names + '\n\n'
+ 'Platte trotzdem wirklich löschen? Die Konfigurationen bleiben gespeichert, verweisen dann aber auf eine gelöschte Platte.'
);
},
confirmDeleteItem(id) {
const used = this.configsUsingItem(id);
if (used.length === 0) {
return confirm('Item inkl. SVG wirklich löschen?');
}
const names = used.map(c => '• ' + this.configSummary(c)).join('\n');
return confirm(
'Dieses Item wird in ' + used.length + ' Konfiguration(en) verwendet:\n\n'
+ names + '\n\n'
+ 'Item inkl. SVG trotzdem wirklich löschen? Die Konfigurationen bleiben gespeichert, verweisen dann aber auf ein gelöschtes Item.'
);
},
async deletePlate(id) {
if (!this.confirmDeletePlate(id)) return;
this.plateError = '';
try {
const res = await fetch(this.apiUrl('/plates/' + id), { method: 'DELETE' });
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
}
await Promise.all([this.loadPlates(), this.loadConfigurations()]);
if (this.plateEditingId === id) this.cancelPlateEdit();
if (this.configForm.plate_id === id) {
this.configForm.plate_id = '';
this.layoutPreview = null;
}
} catch (e) {
this.plateError = String(e.message || e);
}
},
async loadItems() {
this.itemsLoading = true;
try {
this.items = await this.apiJSON('GET', '/items') || [];
} catch (e) {
this.itemError = String(e.message || e);
} finally {
this.itemsLoading = false;
}
},
defaultItemForm() {
return {
name: '',
width_mm: 80,
height_mm: 80,
corner_radius_mm: 5,
bleed_mm: 2,
margin_mm: 5,
padding_mm: 3,
};
},
itemSizeLabel(it) {
const w = it.spec.width_mm || it.spec.size_mm;
const h = it.spec.height_mm || it.spec.size_mm;
let s = w + ' × ' + h + ' mm';
if (it.spec.corner_radius_mm) s += ' · r ' + it.spec.corner_radius_mm;
s += ' · bleed ' + it.spec.bleed_mm + ' · margin ' + it.spec.margin_mm;
return s;
},
editItem(it) {
this.itemEditingId = it.id;
const w = it.spec.width_mm || it.spec.size_mm;
const h = it.spec.height_mm || it.spec.size_mm;
this.itemForm = {
name: it.name || '',
width_mm: w,
height_mm: h,
corner_radius_mm: it.spec.corner_radius_mm || 0,
bleed_mm: it.spec.bleed_mm,
margin_mm: it.spec.margin_mm,
padding_mm: it.spec.padding_mm,
};
this.itemError = '';
this.itemSuccess = '';
},
cancelItemEdit() {
this.itemEditingId = null;
this.itemForm = this.defaultItemForm();
this.itemError = '';
this.itemSuccess = '';
},
async saveItem() {
this.itemSaving = true;
this.itemError = '';
this.itemSuccess = '';
const body = {
name: this.itemForm.name,
width_mm: Number(this.itemForm.width_mm),
height_mm: Number(this.itemForm.height_mm),
corner_radius_mm: Number(this.itemForm.corner_radius_mm) || 0,
bleed_mm: Number(this.itemForm.bleed_mm) || 0,
margin_mm: Number(this.itemForm.margin_mm) || 0,
padding_mm: Number(this.itemForm.padding_mm) || 0,
};
try {
if (this.itemEditingId) {
await this.apiJSON('PUT', '/items/' + this.itemEditingId, body);
this.itemSuccess = 'Item aktualisiert.';
} else {
if (!body.name) {
throw new Error('Name ist erforderlich');
}
await this.apiJSON('POST', '/items', body);
this.itemSuccess = 'Item erzeugt.';
}
const keepId = this.itemEditingId;
await this.loadItems();
if (keepId) {
const it = this.items.find(x => x.id === keepId);
if (it) this.editItem(it);
else this.cancelItemEdit();
} else {
this.cancelItemEdit();
}
this.refreshLayoutPreview();
} catch (e) {
this.itemError = String(e.message || e);
} finally {
this.itemSaving = false;
}
},
async deleteItem(id) {
if (!this.confirmDeleteItem(id)) return;
this.itemError = '';
try {
const res = await fetch(this.apiUrl('/items/' + id), { method: 'DELETE' });
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
}
await Promise.all([this.loadItems(), this.loadConfigurations()]);
if (this.itemEditingId === id) this.cancelItemEdit();
if (this.configForm.item_id === id) {
this.configForm.item_id = '';
this.layoutPreview = null;
}
} catch (e) {
this.itemError = String(e.message || e);
}
},
async loadConfigurations() {
try {
this.configurations = await this.apiJSON('GET', '/configurations') || [];
} catch (e) {
this.configError = String(e.message || e);
}
},
defaultConfigForm() {
return {
name: '',
plate_id: '',
item_id: '',
spacing_mm: 2,
};
},
editConfiguration(c) {
this.configEditingId = c.id;
this.configForm = {
name: c.name || '',
plate_id: c.plate_id,
item_id: c.item_id,
spacing_mm: c.spacing_mm,
};
this.configError = '';
this.configSuccess = '';
this.refreshLayoutPreview();
},
cancelConfigEdit() {
this.configEditingId = null;
this.configForm = this.defaultConfigForm();
this.layoutPreview = null;
this.configError = '';
this.configSuccess = '';
},
async saveConfiguration() {
this.configSaving = true;
this.configError = '';
this.configSuccess = '';
const body = {
name: this.configForm.name,
plate_id: this.configForm.plate_id,
item_id: this.configForm.item_id,
spacing_mm: Number(this.configForm.spacing_mm) || 0,
};
try {
if (this.configEditingId) {
await this.apiJSON('PUT', '/configurations/' + this.configEditingId, body);
this.configSuccess = 'Konfiguration aktualisiert.';
} else {
await this.apiJSON('POST', '/configurations', body);
this.configSuccess = 'Konfiguration gespeichert.';
}
const keepId = this.configEditingId;
await this.loadConfigurations();
if (keepId) {
const c = this.configurations.find(x => x.id === keepId);
if (c) this.editConfiguration(c);
else this.cancelConfigEdit();
} else {
this.cancelConfigEdit();
}
} catch (e) {
this.configError = String(e.message || e);
} finally {
this.configSaving = false;
}
},
async downloadBlob(path, filename) {
this.pdfGenerating = true;
this.configError = '';
try {
const res = await fetch(this.apiUrl(path));
if (!res.ok) {
let msg = res.statusText;
try {
const err = await res.json();
if (err.error) msg = err.error;
} catch (_) {}
throw new Error(msg);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
this.configError = String(e.message || e);
} finally {
this.pdfGenerating = false;
}
},
downloadLayoutPDF() {
const q = new URLSearchParams({
plate_id: this.configForm.plate_id,
item_id: this.configForm.item_id,
spacing_mm: String(Number(this.configForm.spacing_mm) || 0),
});
const name = this.configEditingId
? 'configuration-' + this.configEditingId.slice(0, 8) + '.pdf'
: 'layout-preview.pdf';
const path = this.configEditingId
? '/configurations/' + this.configEditingId + '/pdf'
: '/layout/pdf?' + q;
this.downloadBlob(path, name);
},
async deleteConfiguration(id) {
if (!confirm('Konfiguration wirklich löschen?')) return;
this.configError = '';
try {
const res = await fetch(this.apiUrl('/configurations/' + id), { method: 'DELETE' });
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
}
await this.loadConfigurations();
if (this.configEditingId === id) this.cancelConfigEdit();
} catch (e) {
this.configError = String(e.message || e);
}
},
refreshLayoutPreview() {
clearTimeout(this._layoutTimer);
this._layoutTimer = setTimeout(() => this.fetchLayoutPreview(), 200);
},
async fetchLayoutPreview() {
const plateId = this.configForm.plate_id;
const itemId = this.configForm.item_id;
if (!plateId || !itemId || !this.apiOk) {
this.layoutPreview = null;
return;
}
this.layoutLoading = true;
try {
const q = new URLSearchParams({
plate_id: plateId,
item_id: itemId,
spacing_mm: String(Number(this.configForm.spacing_mm) || 0),
});
this.layoutPreview = await this.apiJSON('GET', '/layout/preview?' + q);
} catch (e) {
this.layoutPreview = null;
} finally {
this.layoutLoading = false;
}
},
labelPlate(p) {
const n = p.name ? p.name + ' — ' : '';
return n + this.fmtSize(p.width_mm, p.height_mm);
},
labelItem(it) {
return it.name || it.svg_template;
},
configSummary(c) {
const plate = this.plates.find(p => p.id === c.plate_id);
const item = this.items.find(i => i.id === c.item_id);
const plateLabel = plate ? this.labelPlate(plate) : c.plate_id;
const itemLabel = item ? this.labelItem(item) : c.item_id;
return plateLabel + ' · ' + itemLabel + ' · Abstand ' + c.spacing_mm + ' mm';
},
layoutPreviewSvg(preview) {
const p = preview || this.layoutPreview;
if (!p || !p.plate_width_mm) return '';
const pw = p.plate_width_mm;
const ph = p.plate_height_mm;
const maxPx = 520;
const scale = Math.min(maxPx / pw, maxPx / ph, 2);
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;');
let items = '';
const trim = p.trim_offset_mm || 0;
const canvasW = p.canvas_width_mm || p.footprint_width_mm;
const canvasH = p.canvas_height_mm || p.footprint_height_mm;
const fpW = p.footprint_width_mm;
const fpH = p.footprint_height_mm;
for (const pos of p.positions || []) {
const x = pos.x_mm * scale;
const y = pos.y_mm * scale;
items += `<rect x="${x}" y="${y}" width="${canvasW * scale}" height="${canvasH * scale}" fill="none" stroke="#8b9cb3" stroke-width="0.8" stroke-dasharray="3,2"/>`;
items += `<rect x="${(pos.x_mm + trim) * scale}" y="${(pos.y_mm + trim) * scale}" width="${fpW * scale}" height="${fpH * scale}" rx="3" fill="#22c55e" fill-opacity="0.35" stroke="#22c55e" stroke-width="1"/>`;
}
const px = p.printable_x_mm * scale;
const py = p.printable_y_mm * scale;
const prW = p.printable_width_mm * scale;
const prH = p.printable_height_mm * scale;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${pw * scale}" height="${ph * scale}" viewBox="0 0 ${pw * scale} ${ph * scale}" role="img" aria-label="Layout-Vorschau">
<rect width="100%" height="100%" fill="#2d3a4d"/>
<rect x="${px}" y="${py}" width="${prW}" height="${prH}" fill="#1a2332" stroke="#8b9cb3" stroke-width="1" stroke-dasharray="4,3"/>
${items}
<text x="8" y="16" fill="#e7ecf3" font-size="11" font-family="system-ui,sans-serif">${esc(p.count)}× · ${esc(p.columns)}×${esc(p.rows)}</text>
</svg>`;
},
fmtSize(w, h) {
return `${w} × ${h} mm`;
},
fmtMargins(p) {
return `${p.margin_top_mm} / ${p.margin_right_mm} / ${p.margin_bottom_mm} / ${p.margin_left_mm}`;
},
fmtPrintable(p) {
const w = p.width_mm - p.margin_left_mm - p.margin_right_mm;
const h = p.height_mm - p.margin_top_mm - p.margin_bottom_mm;
return `${w.toFixed(1)} × ${h.toFixed(1)} mm`;
},
};
}
</script>
</body>
</html>