Jetzt/Topbar und Material-Routing, Pause bei Filament-Ende, Druckbericht mit Stromanteilen, Design- und Kamera-Stream-Polish; Bedarf-Alert-Spam und Platte-belegt-Bugs behoben.
336 lines
9.7 KiB
JavaScript
336 lines
9.7 KiB
JavaScript
/** 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>
|
|
<label class="check"><input type="checkbox" id="ab-reprint"> Gleich nochmal drucken</label>
|
|
<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,
|
|
reprint: !!dlg.querySelector("#ab-reprint").checked,
|
|
};
|
|
dlg.remove();
|
|
resolve(payload);
|
|
});
|
|
dlg.showModal();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Toast mit Rückgängig für umkehrbare Aktionen.
|
|
* @param {string} message
|
|
* @param {() => void | Promise<void>} onUndo
|
|
* @param {number} [ttlMs]
|
|
*/
|
|
function undoToast(message, onUndo, ttlMs) {
|
|
let host = document.querySelector(".toast-host");
|
|
if (!host) {
|
|
host = document.createElement("div");
|
|
host.className = "toast-host";
|
|
host.setAttribute("role", "status");
|
|
host.setAttribute("aria-live", "polite");
|
|
document.body.appendChild(host);
|
|
}
|
|
const node = document.createElement("div");
|
|
node.className = "toast good undo-toast";
|
|
node.innerHTML = '<div></div><button type="button">Rückgängig</button>';
|
|
node.querySelector("div").textContent = String(message ?? "");
|
|
let undone = false;
|
|
const btn = node.querySelector("button");
|
|
btn.addEventListener("click", async () => {
|
|
if (undone) return;
|
|
undone = true;
|
|
node.remove();
|
|
try { await onUndo(); } catch (error) { toast(error.message || String(error), "bad"); }
|
|
});
|
|
host.appendChild(node);
|
|
setTimeout(() => {
|
|
if (undone) return;
|
|
node.classList.add("out");
|
|
setTimeout(() => node.remove(), 260);
|
|
}, ttlMs || 6000);
|
|
}
|
|
|
|
/** Globale Suche Ctrl+K / Cmd+K. */
|
|
function openCommandPalette() {
|
|
const existing = document.getElementById("cmdk-dlg");
|
|
if (existing) {
|
|
existing.showModal();
|
|
existing.querySelector("input")?.focus();
|
|
return;
|
|
}
|
|
const dlg = document.createElement("dialog");
|
|
dlg.id = "cmdk-dlg";
|
|
dlg.className = "cmdk-dlg";
|
|
dlg.innerHTML = `<div class="cmdk-wrap">
|
|
<input type="search" placeholder="Drucker, Datei, Spule, Charge, Auftrag…" autocomplete="off">
|
|
<ul class="cmdk-list"></ul>
|
|
</div>`;
|
|
document.body.appendChild(dlg);
|
|
const input = dlg.querySelector("input");
|
|
const list = dlg.querySelector(".cmdk-list");
|
|
let items = [];
|
|
let active = 0;
|
|
let timer = 0;
|
|
|
|
function paint() {
|
|
list.innerHTML = items.map((item, i) =>
|
|
`<li class="${i === active ? "on" : ""}" data-i="${i}">
|
|
<span>${escapeHtml(item.title)}</span>
|
|
<span class="tiny">${escapeHtml(item.subtitle || "")}</span>
|
|
</li>`
|
|
).join("") || '<li class="tiny">Nichts gefunden.</li>';
|
|
}
|
|
|
|
async function search(q) {
|
|
q = (q || "").trim();
|
|
if (!q) { items = []; paint(); return; }
|
|
try {
|
|
const data = await api("/suche?q=" + encodeURIComponent(q));
|
|
items = data.items || [];
|
|
} catch (error) {
|
|
items = [];
|
|
}
|
|
active = 0;
|
|
paint();
|
|
}
|
|
|
|
function go(item) {
|
|
if (!item || !item.href) return;
|
|
dlg.close();
|
|
location.href = item.href;
|
|
}
|
|
|
|
input.addEventListener("input", () => {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => search(input.value), 180);
|
|
});
|
|
input.addEventListener("keydown", (event) => {
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
active = Math.min(items.length - 1, active + 1);
|
|
paint();
|
|
} else if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
active = Math.max(0, active - 1);
|
|
paint();
|
|
} else if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
go(items[active]);
|
|
} else if (event.key === "Escape") {
|
|
dlg.close();
|
|
}
|
|
});
|
|
list.addEventListener("click", (event) => {
|
|
const li = event.target.closest("[data-i]");
|
|
if (!li) return;
|
|
go(items[Number(li.dataset.i)]);
|
|
});
|
|
dlg.addEventListener("close", () => { /* bleibt im DOM */ });
|
|
dlg.showModal();
|
|
input.focus();
|
|
}
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
if ((event.ctrlKey || event.metaKey) && (event.key === "k" || event.key === "K")) {
|
|
event.preventDefault();
|
|
openCommandPalette();
|
|
}
|
|
});
|
|
|
|
window.undoToast = undoToast;
|
|
window.openCommandPalette = openCommandPalette;
|
|
|
|
window.RUN_STATES = RUN_STATES;
|
|
window.setConnectionBanner = setConnectionBanner;
|
|
window.createPoller = createPoller;
|
|
window.promptAbraeumen = promptAbraeumen;
|
|
window.VERDICT_REASON_LABELS = VERDICT_REASON_LABELS;
|