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:
1 parent
0236278929
commit
8e88efc094
45 files changed
+3121
-122
No files matched your search
+9
-2
@@ -12,7 +12,8 @@ function escapeHtml(value) {
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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
@@ -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; }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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("/")))
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user