2026-05-26 17:11:09 +02:00

882 lines
30 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;
}
* { 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;
}
.page {
max-width: 1000px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
header {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
header h1 { font-size: 1.5rem; font-weight: 600; }
header p { color: var(--muted); margin-top: 0.25rem; font-size: 0.9rem; }
.grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem;
}
.card.wide { grid-column: 1 / -1; }
.card h2 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin-bottom: 0.75rem;
}
.muted { color: var(--muted); font-size: 0.85rem; margin-top: 0.5rem; }
button {
margin-top: 1rem;
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: 1rem; }
button.danger {
background: transparent;
border: 1px solid var(--err);
color: var(--err);
margin-top: 0;
padding: 0.35rem 0.65rem;
font-size: 0.8rem;
}
button.danger:hover { background: rgba(239, 68, 68, 0.12); filter: none; }
input {
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));
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
margin-top: 0.75rem;
}
th, td {
text-align: left;
padding: 0.5rem 0.25rem;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
th { color: var(--muted); font-weight: 500; font-size: 0.75rem; }
.empty { color: var(--muted); font-size: 0.85rem; margin-top: 0.5rem; }
.msg-err { color: var(--err); font-size: 0.85rem; margin-top: 0.5rem; }
.msg-ok { color: var(--ok); font-size: 0.85rem; margin-top: 0.5rem; }
.item-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
margin-top: 1rem;
}
.item-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.item-preview {
background: #fff;
padding: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
min-height: 160px;
}
.item-preview img {
max-width: 100%;
max-height: 140px;
width: auto;
height: auto;
}
.item-body {
padding: 0.75rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.item-body strong { font-size: 0.9rem; }
.item-body span { font-size: 0.8rem; color: var(--muted); }
.item-actions {
padding: 0 0.75rem 0.75rem;
}
.row-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
.layout-preview-wrap {
margin-top: 1rem;
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); }
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;
}
.config-list {
display: grid;
gap: 1rem;
margin-top: 1rem;
}
.config-entry {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
}
.config-entry header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 0;
padding-bottom: 0.5rem;
border-bottom: none;
}
.config-entry header h3 {
font-size: 0.95rem;
font-weight: 600;
}
</style>
</head>
<body>
<div class="page" x-data="dashboard()" x-init="init()">
<header>
<h1>Printer Backend</h1>
<p>Platten und Items verwalten</p>
</header>
<div class="grid">
<section class="card">
<h2>API</h2>
<label for="api-base">Basis-URL</label>
<input id="api-base" type="url" x-model="apiBase" @change="onApiBaseChange()" placeholder="http://127.0.0.1:8080">
<p class="muted" x-show="apiOk === true">API erreichbar</p>
<p class="msg-err" x-show="apiOk === false">API nicht erreichbar</p>
</section>
<section class="card wide">
<h2>Platte anlegen</h2>
<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>
<button type="submit" :disabled="plateSaving">
<span x-text="plateSaving ? 'Speichere…' : 'Platte anlegen'"></span>
</button>
<p class="msg-err" x-show="plateError" x-text="plateError"></p>
<p class="msg-ok" x-show="plateSuccess" x-text="plateSuccess"></p>
</form>
</section>
<section class="card wide">
<h2>Platten</h2>
<template x-if="plates.length === 0 && !platesLoading">
<p class="empty">Keine Platten.</p>
</template>
<table x-show="plates.length > 0">
<thead>
<tr>
<th>Name</th>
<th>Größe</th>
<th>Margins</th>
<th>Druckfläche</th>
<th></th>
</tr>
</thead>
<tbody>
<template x-for="p in plates" :key="p.id">
<tr>
<td x-text="p.name || '—'"></td>
<td x-text="fmtSize(p.width_mm, p.height_mm)"></td>
<td x-text="fmtMargins(p)"></td>
<td x-text="fmtPrintable(p)"></td>
<td class="row-actions">
<button type="button" class="danger" @click="deletePlate(p.id)">Löschen</button>
</td>
</tr>
</template>
</tbody>
</table>
</section>
<section class="card wide">
<h2>Item anlegen</h2>
<p class="muted">Erzeugt SVG-Maske in <code>data/svg_template/</code>.</p>
<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>
</div>
<div>
<label>Größe (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="itemForm.size_mm" required>
</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>
<button type="submit" :disabled="itemSaving">
<span x-text="itemSaving ? 'Erzeuge…' : 'Item anlegen'"></span>
</button>
<p class="msg-err" x-show="itemError" x-text="itemError"></p>
<p class="msg-ok" x-show="itemSuccess" x-text="itemSuccess"></p>
</form>
</section>
<section class="card wide">
<h2>Konfiguration — Layout-Vorschau</h2>
<p class="muted">Kombiniert Platte und Item; berechnet maximale Stückzahl unter Einhaltung aller Margins und Abstände.</p>
<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…' : 'Konfiguration speichern'"></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>
</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) + ' 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="empty" 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>
</section>
<section class="card wide" x-show="configurations.length > 0">
<h2>Gespeicherte Konfigurationen</h2>
<div class="config-list">
<template x-for="c in configurations" :key="c.id">
<article class="config-entry">
<header>
<div>
<h3 x-text="c.name || ('Konfiguration ' + c.id.slice(0, 8))"></h3>
<p class="muted" x-text="configSummary(c)"></p>
</div>
<div class="row-actions">
<button type="button" class="secondary" @click="downloadConfigurationPDF(c.id)"
:disabled="pdfGenerating || !!c.preview_error" x-show="c.preview && !c.preview_error">
PDF
</button>
<button type="button" class="danger" @click="deleteConfiguration(c.id)">Löschen</button>
</div>
</header>
<p class="msg-err" x-show="c.preview_error" x-text="c.preview_error"></p>
<div class="layout-preview-wrap" x-show="c.preview && !c.preview_error">
<div x-html="layoutPreviewSvg(c.preview)"></div>
<div class="layout-stats">
<span><strong x-text="c.preview.count"></strong> Items</span>
<span><strong x-text="c.preview.columns + ' × ' + c.preview.rows"></strong></span>
</div>
</div>
</article>
</template>
</div>
</section>
<section class="card wide">
<h2>Items</h2>
<template x-if="items.length === 0 && !itemsLoading">
<p class="empty">Keine Items.</p>
</template>
<div class="item-grid" x-show="items.length > 0">
<template x-for="it in items" :key="it.id">
<article class="item-card">
<div class="item-preview">
<img :src="itemSvgUrl(it.id)" :alt="labelItem(it)" loading="lazy">
</div>
<div class="item-body">
<strong x-text="labelItem(it)"></strong>
<span x-text="it.spec.size_mm + ' mm · bleed ' + it.spec.bleed_mm + ' · margin ' + it.spec.margin_mm"></span>
<span><code x-text="it.svg_template"></code></span>
</div>
<div class="item-actions">
<button type="button" class="danger" @click="deleteItem(it.id)">Löschen</button>
</div>
</article>
</template>
</div>
</section>
</div>
</div>
<script>
function dashboard() {
return {
apiBase: '',
apiOk: null,
plates: [],
platesLoading: false,
plateSaving: false,
plateError: '',
plateSuccess: '',
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: '',
itemForm: {
name: '',
size_mm: 80,
bleed_mm: 2,
margin_mm: 5,
padding_mm: 3,
},
configurations: [],
configSaving: false,
configError: '',
configSuccess: '',
configForm: {
name: '',
plate_id: '',
item_id: '',
spacing_mm: 2,
},
layoutPreview: null,
layoutLoading: false,
pdfGenerating: false,
_layoutTimer: null,
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;
}
},
async savePlate() {
this.plateSaving = true;
this.plateError = '';
this.plateSuccess = '';
try {
await this.apiJSON('POST', '/plates', {
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,
});
this.plateSuccess = 'Platte gespeichert.';
await this.loadPlates();
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.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;
}
},
async saveItem() {
this.itemSaving = true;
this.itemError = '';
this.itemSuccess = '';
try {
await this.apiJSON('POST', '/items', {
name: this.itemForm.name,
size_mm: Number(this.itemForm.size_mm),
bleed_mm: Number(this.itemForm.bleed_mm) || 0,
margin_mm: Number(this.itemForm.margin_mm) || 0,
padding_mm: Number(this.itemForm.padding_mm) || 0,
});
this.itemSuccess = 'Item erzeugt.';
this.itemForm.name = '';
await this.loadItems();
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.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);
}
},
async saveConfiguration() {
this.configSaving = true;
this.configError = '';
this.configSuccess = '';
try {
await this.apiJSON('POST', '/configurations', {
name: this.configForm.name,
plate_id: this.configForm.plate_id,
item_id: this.configForm.item_id,
spacing_mm: Number(this.configForm.spacing_mm) || 0,
});
this.configSuccess = 'Konfiguration gespeichert.';
await this.loadConfigurations();
} 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),
});
this.downloadBlob('/layout/pdf?' + q, 'layout-preview.pdf');
},
downloadConfigurationPDF(id) {
this.downloadBlob('/configurations/' + id + '/pdf', 'configuration-' + id.slice(0, 8) + '.pdf');
},
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();
} 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 fpW = p.footprint_width_mm;
const fpH = p.footprint_height_mm;
for (const pos of p.positions || []) {
items += `<rect x="${pos.x_mm * scale}" y="${pos.y_mm * 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>