Files
grotti/web/static/ui.js
T

380 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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;