822 lines
28 KiB
HTML
822 lines
28 KiB
HTML
<!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.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>
|
||
<button type="submit" :disabled="configSaving || !configForm.plate_id || !configForm.item_id">
|
||
<span x-text="configSaving ? 'Speichere…' : 'Konfiguration speichern'"></span>
|
||
</button>
|
||
<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>Fußabdruck <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>
|
||
<button type="button" class="danger" @click="deleteConfiguration(c.id)">Löschen</button>
|
||
</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,
|
||
_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 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, '&').replace(/</g, '<');
|
||
|
||
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>
|