380 lines
11 KiB
JavaScript
380 lines
11 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;
|
||
|
||
/** Listen-Pagination (clientseitig auf gefiltertem Array). */
|
||
const DEFAULT_PAGE_SIZE = 40;
|
||
|
||
function pageSlice(items, page, pageSize) {
|
||
const list = Array.isArray(items) ? items : [];
|
||
const size = Math.max(1, Number(pageSize) || DEFAULT_PAGE_SIZE);
|
||
const total = list.length;
|
||
const pages = Math.max(1, Math.ceil(total / size) || 1);
|
||
let p = Math.max(1, parseInt(page, 10) || 1);
|
||
if (p > pages) p = pages;
|
||
const start = (p - 1) * size;
|
||
return {
|
||
items: list.slice(start, start + size),
|
||
page: p,
|
||
pages,
|
||
total,
|
||
pageSize: size,
|
||
from: total ? start + 1 : 0,
|
||
to: Math.min(start + size, total),
|
||
};
|
||
}
|
||
|
||
function renderPager(el, state, onPage) {
|
||
if (!el) return;
|
||
const s = state || pageSlice([], 1);
|
||
if (s.total <= s.pageSize) {
|
||
el.hidden = true;
|
||
el.innerHTML = "";
|
||
return;
|
||
}
|
||
el.hidden = false;
|
||
el.className = "pager";
|
||
el.innerHTML =
|
||
'<button type="button" class="ghost pager-prev" ' + (s.page <= 1 ? "disabled" : "") + ">Zurück</button>" +
|
||
'<span class="pager-info">' + s.from + "–" + s.to + " von " + s.total + "</span>" +
|
||
'<button type="button" class="ghost pager-next" ' + (s.page >= s.pages ? "disabled" : "") + ">Weiter</button>";
|
||
el.querySelector(".pager-prev").onclick = () => onPage(s.page - 1);
|
||
el.querySelector(".pager-next").onclick = () => onPage(s.page + 1);
|
||
}
|
||
|
||
window.DEFAULT_PAGE_SIZE = DEFAULT_PAGE_SIZE;
|
||
window.pageSlice = pageSlice;
|
||
window.renderPager = renderPager;
|