511 lines
16 KiB
HTML
511 lines
16 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; }
|
||
</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>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,
|
||
},
|
||
|
||
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()]);
|
||
}
|
||
},
|
||
|
||
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();
|
||
} catch (e) {
|
||
this.plateError = String(e.message || e);
|
||
} finally {
|
||
this.plateSaving = false;
|
||
}
|
||
},
|
||
|
||
async deletePlate(id) {
|
||
if (!confirm('Platte wirklich löschen?')) 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 this.loadPlates();
|
||
} 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();
|
||
} catch (e) {
|
||
this.itemError = String(e.message || e);
|
||
} finally {
|
||
this.itemSaving = false;
|
||
}
|
||
},
|
||
|
||
async deleteItem(id) {
|
||
if (!confirm('Item inkl. SVG wirklich löschen?')) 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 this.loadItems();
|
||
} catch (e) {
|
||
this.itemError = String(e.message || e);
|
||
}
|
||
},
|
||
|
||
labelItem(it) {
|
||
return it.name || it.svg_template;
|
||
},
|
||
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>
|