Grotti 3.0.0: Aufträge, Wartung, SSE, Ausschuss-Dialog, UI-Umbau.

Migrationen 003–009, Kundaufträge mit Lieferschein, Abräumen mit Urteil,
ntfy/Backup/Zeitfenster, Live-Stream und Mobile-Mehr-Menü.
This commit is contained in:
Dominic Gundel committed 2026-10-18 00:54:51 +02:00
1 parent 0236278929
commit 8e88efc094
45 files changed
+3121 -122

No files matched your search

+9 -2
View File
@@ -12,7 +12,8 @@ function escapeHtml(value) {
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
async function api(path, options = {}) {
@@ -277,10 +278,16 @@ function toast(message, kind) {
host.setAttribute("aria-live", "polite");
document.body.appendChild(host);
}
const text = String(message ?? "");
host.querySelectorAll(".toast").forEach((node) => {
const body = node.querySelector("div");
if (body && body.textContent === text) node.remove();
});
while (host.children.length >= 3) host.firstElementChild?.remove();
const node = document.createElement("div");
node.className = "toast " + (kind || "good");
node.innerHTML = icon(kind === "bad" ? "alert" : "check", "lg") + "<div></div>";
node.querySelector("div").textContent = message;
node.querySelector("div").textContent = text;
host.appendChild(node);
const ttl = kind === "bad" ? 7000 : 4000;
setTimeout(() => {
+95 -1
View File
@@ -5,7 +5,7 @@
--bg: #0a0a0c;
--ink: #f0f0f2;
--muted: #9a9aa3;
--faint: #6b6b75;
--faint: #8d8d97;
--line: rgba(255, 255, 255, 0.08);
--line-strong: rgba(255, 255, 255, 0.14);
--card: #141418;
@@ -484,6 +484,7 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; }
.icon-btn.done-btn { color: var(--st-printing); border-color: rgba(61, 206, 124, 0.35); border-radius: 99px !important; }
.icon-btn.done-btn:hover { color: var(--st-printing); background: rgba(61, 206, 124, 0.12); }
.icon-btn.done-btn.confirm-armed { width: auto; padding: 0 0.5rem !important; font-size: 0.66rem; font-weight: 700; background: rgba(61, 206, 124, 0.18); }
.icon-btn.confirm-armed { width: auto; padding: 0 0.5rem !important; font-size: 0.7rem; font-weight: 700; }
.queue-tools { display: flex; gap: 0.1rem; flex-wrap: nowrap; flex: none; }
.send-progress { margin-top: 0.75rem; display: grid; gap: 0.55rem; }
.send-job { display: grid; gap: 0.3rem; }
@@ -1959,6 +1960,31 @@ body.desk-on main {
.tabs button:hover { color: var(--ink); filter: none; }
.tabs button.on { color: var(--ink); border-bottom-color: var(--accent); }
.conn-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 120;
padding: 0.45rem 1rem;
text-align: center;
font-size: var(--t-sm);
font-weight: 500;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
pointer-events: none;
}
.conn-banner[hidden] { display: none !important; }
.conn-banner.bad {
background: rgba(127, 29, 29, 0.92);
color: #fecaca;
border-bottom: 1px solid rgba(248, 113, 113, 0.45);
}
.conn-banner.ok {
background: rgba(20, 83, 45, 0.92);
color: #bbf7d0;
border-bottom: 1px solid rgba(61, 206, 124, 0.45);
}
.toast-host {
position: fixed;
right: 1rem;
@@ -2544,3 +2570,71 @@ ul.plain li:last-child { border-bottom: 0; }
@media (min-width: 800px) {
.grid-2 { grid-template-columns: 1fr 1fr; }
}
/* Abräumen-Dialog + Mobile „Mehr“ */
.abraeumen-dlg,
.mobile-more-sheet {
border: 1px solid var(--line-strong);
border-radius: var(--radius);
padding: 1rem 1.1rem;
background: var(--card);
color: var(--ink);
max-width: min(22rem, calc(100vw - 2rem));
box-shadow: var(--shadow);
}
.abraeumen-dlg::backdrop,
.mobile-more-sheet::backdrop {
background: rgba(0, 0, 0, 0.55);
}
.abraeumen-form h2 { margin: 0 0 0.35rem; font-size: var(--t-lg); }
.abraeumen-form .actions { margin-top: 0.85rem; display: flex; gap: 0.5rem; flex-wrap: wrap; }
.mobile-more-sheet nav {
display: grid;
gap: 0.35rem;
margin-bottom: 0.75rem;
}
.mobile-more-sheet a {
display: block;
padding: 0.65rem 0.75rem;
border-radius: var(--radius-sm);
color: var(--ink);
text-decoration: none;
background: var(--card-2);
border: 1px solid var(--line);
}
.mobile-nav .mobile-more-btn {
appearance: none;
background: transparent;
border: 0;
color: inherit;
font: inherit;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
padding: 0.35rem 0.5rem;
cursor: pointer;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@media (pointer: coarse) {
.icon-btn, .tiny-btn, .fchip, button, .mobile-nav a, .mobile-nav button {
min-height: 2.75rem;
min-width: 2.75rem;
}
}
@media print {
.sidebar, .mobile-nav, .conn-banner, .toast-host, .nav-toggle { display: none !important; }
.app-shell { display: block; }
.app-main { margin: 0; }
body { background: #fff; color: #111; }
.card, article { box-shadow: none; border-color: #ccc; }
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "Grotti",
"short_name": "Grotti",
"description": "3D-Druck-Farm steuern",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0c",
"theme_color": "#0a0a0c",
"lang": "de",
"icons": [
{
"src": "/static/brand/favicon.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/brand/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
}
]
}
+27
View File
@@ -0,0 +1,27 @@
/* Minimaler Service-Worker: App-Shell-Cache für Offline-Hinweis. */
const CACHE = "grotti-shell-v1";
const SHELL = ["/", "/static/grotti.css?v=54", "/static/api.js?v=28", "/static/ui.js?v=2"];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL).catch(() => undefined)));
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.pathname.startsWith("/api/")) return;
event.respondWith(
fetch(req).catch(() => caches.match(req).then((hit) => hit || caches.match("/")))
);
});
+208
View File
@@ -0,0 +1,208 @@
/** Live-UI: Verbindungsbanner, Poller, gemeinsame Zustands-Labels. */
const RUN_STATES = {
completed: "fertig",
cancelled: "abgebrochen",
error: "Fehler",
in_progress: "druckt",
printing: "druckt",
paused: "pausiert",
interrupted: "unterbrochen",
standby: "bereit",
heating: "heizt",
complete: "fertig",
offline: "offline",
waiting: "wartet",
running: "läuft",
};
let connBannerOk = true;
let connBannerTimer = 0;
function setConnectionBanner(ok, text) {
let el = document.getElementById("conn-banner");
if (!el) {
el = document.createElement("div");
el.id = "conn-banner";
el.className = "conn-banner";
el.setAttribute("role", "status");
el.hidden = true;
document.body.prepend(el);
}
clearTimeout(connBannerTimer);
if (ok) {
const wasBad = !connBannerOk;
connBannerOk = true;
if (wasBad && text) {
el.textContent = text;
el.className = "conn-banner ok";
el.hidden = false;
connBannerTimer = setTimeout(() => {
el.hidden = true;
el.textContent = "";
}, 2800);
return;
}
el.hidden = true;
el.textContent = "";
el.className = "conn-banner";
return;
}
connBannerOk = false;
el.textContent = text || "Keine Verbindung zum Server.";
el.className = "conn-banner bad";
el.hidden = false;
}
/**
* @param {() => void | Promise<void>} fn
* @param {number} intervalMs
* @returns {{ start: () => void, stop: () => void }}
*/
function createPoller(fn, intervalMs) {
const base = Math.max(1000, Number(intervalMs) || 5000);
const maxMs = 60000;
let timer = 0;
let busy = false;
let delay = base;
let fails = 0;
let running = false;
function schedule() {
if (!running) return;
clearTimeout(timer);
timer = setTimeout(tick, delay);
}
async function tick() {
if (!running || document.hidden || busy) {
schedule();
return;
}
busy = true;
try {
await fn();
delay = base;
fails = 0;
if (!connBannerOk) setConnectionBanner(true, "Wieder verbunden.");
else setConnectionBanner(true, "");
} catch (error) {
fails += 1;
delay = Math.min(maxMs, Math.max(base, delay * 2));
if (fails >= 2) {
setConnectionBanner(false, (error && error.message) || "Daten konnten nicht geladen werden.");
}
} finally {
busy = false;
schedule();
}
}
function onVis() {
if (!document.hidden && running) tick();
}
return {
start() {
if (running) return;
running = true;
document.addEventListener("visibilitychange", onVis);
tick();
},
stop() {
running = false;
document.removeEventListener("visibilitychange", onVis);
clearTimeout(timer);
timer = 0;
},
};
}
const VERDICT_REASON_LABELS = {
haftung: "Haftung",
warping: "Warping",
spaghetti: "Spaghetti",
layer_shift: "Layer-Shift",
verstopfung: "Verstopfung",
farbe: "Farbe",
sonstiges: "Sonstiges",
};
/**
* Dialog vor „Platte geräumt“: Urteil, optional Grund und Stückzahlen.
* @returns {Promise<object|null>} Payload für /abraeumen oder null bei Abbruch
*/
function promptAbraeumen(hostLabel) {
return new Promise((resolve) => {
const existing = document.getElementById("abraeumen-dlg");
if (existing) existing.remove();
const dlg = document.createElement("dialog");
dlg.id = "abraeumen-dlg";
dlg.className = "abraeumen-dlg";
const reasons = Object.entries(VERDICT_REASON_LABELS)
.map(([k, label]) => `<option value="${k}">${label}</option>`)
.join("");
dlg.innerHTML = `
<form method="dialog" class="abraeumen-form">
<h2>Platte geräumt</h2>
<p class="tiny">${escapeHtml(hostLabel || "Drucker")}</p>
<div class="field"><label for="ab-verdict">Urteil</label>
<select id="ab-verdict" name="verdict">
<option value="gut">gut</option>
<option value="ausschuss">Ausschuss</option>
</select>
</div>
<div class="field" id="ab-reason-wrap" hidden>
<label for="ab-reason">Grund</label>
<select id="ab-reason">${reasons}</select>
</div>
<div class="grid-2" id="ab-pieces" hidden>
<div class="field"><label for="ab-ok">Stück gut</label><input id="ab-ok" type="number" min="0" step="1" value="0"></div>
<div class="field"><label for="ab-bad">Stück schlecht</label><input id="ab-bad" type="number" min="0" step="1" value="1"></div>
</div>
<div class="actions">
<button type="submit" value="ok" class="btn-primary">Quittieren</button>
<button type="button" class="ghost" id="ab-cancel">Abbrechen</button>
</div>
</form>`;
document.body.appendChild(dlg);
const verdict = dlg.querySelector("#ab-verdict");
const reasonWrap = dlg.querySelector("#ab-reason-wrap");
const pieces = dlg.querySelector("#ab-pieces");
function sync() {
const scrap = verdict.value === "ausschuss";
reasonWrap.hidden = !scrap;
pieces.hidden = !scrap;
}
verdict.addEventListener("change", sync);
sync();
dlg.querySelector("#ab-cancel").addEventListener("click", () => {
dlg.close();
dlg.remove();
resolve(null);
});
dlg.addEventListener("close", () => {
if (dlg.returnValue !== "ok") {
dlg.remove();
resolve(null);
return;
}
const payload = {
confirm: true,
verdict: verdict.value,
verdict_reason: verdict.value === "ausschuss" ? dlg.querySelector("#ab-reason").value : "",
pieces_ok: Number(dlg.querySelector("#ab-ok").value) || 0,
pieces_bad: Number(dlg.querySelector("#ab-bad").value) || 0,
};
dlg.remove();
resolve(payload);
});
dlg.showModal();
});
}
window.RUN_STATES = RUN_STATES;
window.setConnectionBanner = setConnectionBanner;
window.createPoller = createPoller;
window.promptAbraeumen = promptAbraeumen;
window.VERDICT_REASON_LABELS = VERDICT_REASON_LABELS;