Files
grotti/web/templates/layout.html
T

672 lines
29 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf" content="{{ csrf }}">
{% if current_user %}<meta name="user-role" content="{{ current_user.role }}">{% endif %}
<title>{{ title }} · Grotti</title>
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=68">
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
<meta name="theme-color" content="#0a0a0c" id="theme-color-meta">
<script>
(function () {
function applyThemeEarly(mode) {
var light = mode === "light";
if (mode === "system") {
try { light = window.matchMedia("(prefers-color-scheme: light)").matches; } catch (e) { light = false; }
}
document.documentElement.dataset.theme = light ? "light" : "dark";
document.documentElement.style.colorScheme = light ? "light" : "dark";
var meta = document.getElementById("theme-color-meta");
if (meta) meta.content = light ? "#f4f4f6" : "#0a0a0c";
}
window.applyTheme = function (mode) {
try { if (mode) localStorage.setItem("grotti-theme", mode); } catch (e) {}
var m = mode;
try { m = m || localStorage.getItem("grotti-theme") || "dark"; } catch (e) { m = "dark"; }
applyThemeEarly(m);
};
try {
if (!localStorage.getItem("grotti-display") && localStorage.getItem("farm-display")) {
localStorage.setItem("grotti-display", localStorage.getItem("farm-display"));
}
} catch (e) {}
try {
var q = new URLSearchParams(location.search);
if (q.has("shelf")) {
var on = q.get("shelf") === "1" || q.get("shelf") === "true";
localStorage.setItem("grotti-display", on ? "shelf" : "desktop");
q.delete("shelf");
var next = location.pathname + (q.toString() ? "?" + q : "") + location.hash;
history.replaceState(null, "", next);
}
} catch (e) {}
try {
if (localStorage.getItem("grotti-display") === "shelf") document.documentElement.classList.add("shelf");
var nav = localStorage.getItem("grotti-nav-collapsed");
if (nav === "1" || (nav == null && localStorage.getItem("grotti-display") === "shelf")) {
document.documentElement.classList.add("nav-collapsed");
}
applyThemeEarly(localStorage.getItem("grotti-theme") || "dark");
} catch (e) {}
try {
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", function () {
var mode = "dark";
try { mode = localStorage.getItem("grotti-theme") || "dark"; } catch (err) {}
if (mode === "system") applyThemeEarly("system");
});
} catch (e) {}
})();
</script>
</head>
<body class="app-shell">
<div id="conn-banner" class="conn-banner" hidden role="status"></div>
{{ icons_sprite }}
<aside class="sidebar">
<div class="brand-row">
<a class="brand" href="/" title="Grotti">
<img class="brand-mark" src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="28" height="28" decoding="async">
<span class="brand-name">Grotti</span>
</a>
<div class="brand-tools">
<button type="button" class="icon-btn m-only" id="cmdk-open-m" title="Suche (Ctrl+K)" aria-label="Suche öffnen"><svg class="ico"><use href="#i-search"/></svg></button>
<button type="button" class="icon-btn nav-toggle" id="nav-toggle" title="Menü einklappen" aria-label="Menü einklappen" aria-pressed="false"><svg class="ico"><use href="#i-panel"/></svg></button>
</div>
</div>
<nav class="nav-main" aria-label="Hauptmenü">
<span class="nav-label">Live</span>
<a href="/" class="{{ 'on' if page == 'wand' else '' }}" title="Drucker"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a>
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }} nav-desk" title="Dateien"><svg class="ico"><use href="#i-file"/></svg><span>Dateien</span></a>
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
<a href="/auftraege" class="{{ 'on' if page == 'auftraege' else '' }} nav-desk" title="Aufträge"><svg class="ico"><use href="#i-folder"/></svg><span>Aufträge</span></a>
<span class="nav-label nav-desk">Rückschau</span>
<a href="/protokoll" class="{{ 'on' if page == 'protokoll' or page == 'bericht' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a>
<a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-chart"/></svg><span>Statistik</span></a>
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
<span class="nav-label nav-desk">Verwalten</span>
<a href="/projekte" class="{{ 'on' if page == 'projekte' else '' }} nav-desk" title="Projekte"><svg class="ico"><use href="#i-folder"/></svg><span>Projekte</span></a>
<a href="/material" class="{{ 'on' if page == 'material' else '' }} nav-desk" title="Material"><svg class="ico"><use href="#i-spool"/></svg><span>Material</span></a>
<a href="/wartung" class="{{ 'on' if page == 'wartung' else '' }} nav-desk" title="Wartung"><svg class="ico"><use href="#i-wrench"/></svg><span>Wartung</span></a>
</nav>
</aside>
<div class="app-main">
<header class="topbar">
<button type="button" class="cmdk-btn" id="cmdk-open" title="Suche (Ctrl+K)" aria-label="Suche öffnen"><svg class="ico"><use href="#i-search"/></svg><span>Suche</span><kbd>Ctrl K</kbd></button>
<div class="live-bar" id="live-bar">
<div class="live-chips" id="live-chips"></div>
{% if page != 'jetzt' %}
<div class="live-pop" id="live-pop" hidden>
<div class="live-panel" id="live-panel">
{% include "_live_panel.html" %}
</div>
</div>
{% endif %}
</div>
<div class="topbar-tools">
<a class="icon-btn {{ 'on' if page == 'einstellungen' else '' }}" href="/einstellungen" title="Einstellungen" aria-label="Einstellungen"><svg class="ico"><use href="#i-settings"/></svg></a>
<a class="icon-btn {{ 'on' if page == 'doku' else '' }}" href="/doku" title="Doku" aria-label="Doku"><svg class="ico"><use href="#i-book"/></svg></a>
{% if current_user %}
<details class="user-menu" id="user-menu">
<summary class="side-avatar" aria-label="Benutzer-Menü">{{ current_user.username[:1]|upper }}</summary>
<div class="menu-pop" role="menu">
<div class="menu-user">
<strong>{{ current_user.username }}</strong>
<span class="tiny">{{ current_user.role }}</span>
</div>
<button type="button" class="ghost" id="logout-btn" role="menuitem">Abmelden</button>
<a href="/doku" role="menuitem">v{{ version }}</a>
<span class="tiny menu-copy">© by <a href="mailto:[email protected]">[email protected]</a></span>
</div>
</details>
{% endif %}
</div>
</header>
<main>
{% block body %}{% endblock %}
</main>
</div>
<nav class="mobile-nav" aria-label="Schnellmenü">
<a href="/" class="{{ 'on' if page == 'wand' else '' }}"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a>
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }}"><svg class="ico"><use href="#i-file"/></svg><span>Dateien</span></a>
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
<a href="/jetzt" class="{{ 'on' if page == 'jetzt' else '' }}"><svg class="ico"><use href="#i-sparkles"/></svg><span>Jetzt</span><span class="nav-badge" id="mobile-jetzt-badge" hidden>0</span></a>
<button type="button" class="mobile-more-btn" id="mobile-more" aria-expanded="false" aria-controls="mobile-more-sheet"><svg class="ico"><use href="#i-menu"/></svg><span>Mehr</span></button>
</nav>
<dialog id="mobile-more-sheet" class="mobile-more-sheet">
<nav aria-label="Weitere Seiten">
<a href="/auftraege">Aufträge</a>
<a href="/projekte">Projekte</a>
<a href="/protokoll">Protokoll</a>
<a href="/statistik">Statistik</a>
<a href="/aktivitaet">Aktivität</a>
<a href="/material">Material</a>
<a href="/wartung">Wartung</a>
<a href="/einstellungen">Einstellungen</a>
<a href="/doku">Doku</a>
</nav>
{% if current_user %}
<div class="mobile-more-user">
<span class="side-avatar" aria-hidden="true">{{ current_user.username[:1]|upper }}</span>
<span class="grow">
<strong>{{ current_user.username }}</strong>
<span class="tiny">{{ current_user.role }} · v{{ version }}</span>
</span>
<button type="button" class="ghost tiny-btn" id="logout-btn-m">Abmelden</button>
</div>
{% endif %}
<form method="dialog"><button class="ghost" value="cancel">Schließen</button></form>
</dialog>
<script src="{{ url_for('static', filename='api.js') }}?v=32"></script>
<script src="{{ url_for('static', filename='ui.js') }}?v=5"></script>
<script>
async function doLogout() {
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
location.href = "/login";
}
document.getElementById("logout-btn")?.addEventListener("click", doLogout);
document.getElementById("logout-btn-m")?.addEventListener("click", doLogout);
(function userMenuClose() {
const menu = document.getElementById("user-menu");
if (!menu) return;
document.addEventListener("click", (event) => {
if (!menu.open) return;
if (menu.contains(event.target)) return;
menu.open = false;
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && menu.open) menu.open = false;
});
})();
(function applyShellClasses() {
const shelf = document.documentElement.classList.contains("shelf");
const collapsed = document.documentElement.classList.contains("nav-collapsed");
document.body.classList.toggle("shelf", shelf);
document.body.classList.toggle("nav-collapsed", collapsed);
const toggle = document.getElementById("nav-toggle");
if (toggle) {
toggle.setAttribute("aria-pressed", collapsed ? "true" : "false");
toggle.title = collapsed ? "Menü ausklappen" : "Menü einklappen";
}
})();
const LIVE_KEY = "grotti-live-closed";
const NAV_KEY = "grotti-nav-collapsed";
const DISPLAY_KEY = "grotti-display";
function setNavCollapsed(on) {
document.documentElement.classList.toggle("nav-collapsed", on);
document.body.classList.toggle("nav-collapsed", on);
try { localStorage.setItem(NAV_KEY, on ? "1" : "0"); } catch (error) {}
const toggle = document.getElementById("nav-toggle");
if (toggle) {
toggle.setAttribute("aria-pressed", on ? "true" : "false");
toggle.title = on ? "Menü ausklappen" : "Menü einklappen";
}
}
function setDisplayMode(mode) {
const shelf = mode === "shelf";
document.documentElement.classList.toggle("shelf", shelf);
document.body.classList.toggle("shelf", shelf);
try { localStorage.setItem(DISPLAY_KEY, shelf ? "shelf" : "desktop"); } catch (error) {}
if (shelf && localStorage.getItem(NAV_KEY) == null) setNavCollapsed(true);
}
document.getElementById("nav-toggle")?.addEventListener("click", () => {
setNavCollapsed(!document.body.classList.contains("nav-collapsed"));
});
function openCmdk() {
if (typeof openCommandPalette === "function") openCommandPalette();
}
document.getElementById("cmdk-open")?.addEventListener("click", openCmdk);
document.getElementById("cmdk-open-m")?.addEventListener("click", openCmdk);
(function restoreBoxes() {
let closed = [];
try { closed = JSON.parse(localStorage.getItem(LIVE_KEY) || "[]"); } catch (error) { closed = []; }
if (window.matchMedia("(max-width: 760px)").matches && !localStorage.getItem(LIVE_KEY)) closed = ["wait", "send"];
document.querySelectorAll("#live-panel details[data-box]").forEach((box) => {
if (closed.includes(box.dataset.box)) box.open = false;
box.addEventListener("toggle", () => {
const now = [...document.querySelectorAll("#live-panel details[data-box]")].filter((d) => !d.open).map((d) => d.dataset.box);
localStorage.setItem(LIVE_KEY, JSON.stringify(now));
});
});
})();
function queueTarget(job) {
return job.printer_name || job.host || (job.family_label ? "nächster " + job.family_label : "nächster freier");
}
async function cancelQueueItem(job, button) {
const running = job.status === "running";
const go = async () => {
if (button) button.disabled = true;
const prev = { status: job.status, priority: job.priority };
try {
await api("/druck-queue/" + job.id, { method: "PATCH", json: { status: "cancelled" } });
if (running) {
toast("Abgebrochen.");
} else if (typeof undoToast === "function") {
undoToast("Aus der Warteschlange genommen.", async () => {
await api("/druck-queue/" + job.id, {
method: "PATCH",
json: { status: "waiting", priority: prev.priority, undo: true },
});
toast("Wieder eingereiht.");
refreshJetzt();
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
});
} else {
toast("Aus der Warteschlange genommen.");
}
} catch (error) {
toast(error.message, "bad");
}
refreshJetzt();
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
if (button) button.disabled = false;
};
if (running && button) armButton(button, job.is_ablauf ? "Ablauf stoppen?" : "Druck stoppen?", go);
else go();
}
window.cancelQueueItem = cancelQueueItem;
function setListHtml(el, html) {
if (!el) return;
if (el._html === html) return;
el._html = html;
el.innerHTML = html;
}
function shortPrinter(name) {
let text = String(name || "").replace(/^Kobra\s+(?:3\s+)?/i, "").trim() || String(name || "");
return text.length > 12 ? text.slice(0, 10) + "…" : text;
}
function openLivePop(section) {
const pop = document.getElementById("live-pop");
if (!pop) {
const box = section ? document.querySelector(`[data-box="${section}"]`) : null;
if (box) {
box.scrollIntoView({ block: "start", behavior: "smooth" });
return;
}
location.href = section ? "/jetzt#" + encodeURIComponent(section) : "/jetzt";
return;
}
pop.hidden = false;
if (section) {
const box = pop.querySelector(`[data-box="${section}"]`);
if (box) box.scrollIntoView({ block: "nearest" });
}
}
function closeLivePop() {
const pop = document.getElementById("live-pop");
if (pop) pop.hidden = true;
}
function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId, host }) {
const pct = Math.max(0, Math.min(100, Math.round((Number(progress) || 0) * 100)));
const finish = remain > 0 ? finishClock(remain) : "";
const bits = [name];
if (pct > 0 || remain > 0) bits.push(pct + " %");
if (finish) bits.push(finish);
if (tipExtra) bits.push(tipExtra);
const tip = [
filename ? jobLabel(filename) : "",
remain > 0 ? "noch " + hours(remain) : "",
tipExtra ? "Schritt " + tipExtra : "",
].filter(Boolean).join(" · ");
const href = host ? "/#" + encodeURIComponent(host) : "/";
return `<li class="queue-item running run-line" style="--pct:${pct}">
<a class="queue-link run-line-link" href="${escapeHtml(href)}" title="${escapeHtml(tip || name || "Drucker")}">
<span class="run-line-fill" aria-hidden="true"></span>
<span class="run-line-text">${escapeHtml(bits.join(" · "))}</span>
</a>
${cancelId != null ? `<span class="queue-tools"><button type="button" class="icon-btn danger" data-cancel-q="${cancelId}" title="Abbrechen">${icon("x")}</button></span>` : ""}
</li>`;
}
function actionItemHtml(action) {
const kind = action.kind || "";
const cls = kind === "queue_error" || kind === "bedarf" || kind === "wartung" || kind === "auftrag" || kind === "filament_pause" ? "error" : "";
let tools = "";
if (kind === "clear" && action.host) {
tools = `<span class="queue-tools"><button type="button" class="icon-btn done-btn" data-cleared="${escapeHtml(action.host)}" title="Platte geräumt" aria-label="Platte geräumt">${icon("check")}</button></span>`;
} else if (kind === "queue_error" && action.id) {
if (action.is_ablauf) {
tools = `<span class="queue-tools"><button type="button" class="icon-btn" data-resume-q="${action.id}" title="Fortsetzen">${icon("play")}</button>
<button type="button" class="icon-btn danger" data-dismiss-q="${action.id}" title="Ausblenden">${icon("x")}</button></span>`;
} else {
tools = `<span class="queue-tools"><button type="button" class="icon-btn danger" data-dismiss-q="${action.id}" title="Ausblenden">${icon("x")}</button></span>`;
}
} else if (kind === "waiting" && action.id) {
tools = `<span class="queue-tools"><button type="button" class="icon-btn danger" data-cancel-q="${action.id}" title="Entfernen">${icon("x")}</button></span>`;
}
const href = action.href || "/warteschlange";
const name = action.printer || action.title || "";
const detail = action.detail || action.filename || "";
return `<li class="queue-item ${cls}">
<div class="queue-row">
<a class="queue-link grow" href="${escapeHtml(href)}"><span class="queue-name">${escapeHtml(action.title || name)}</span></a>
${tools}
</div>
<span class="queue-msg">${escapeHtml([name !== action.title ? name : "", detail].filter(Boolean).join(" · "))}</span>
</li>`;
}
function renderLiveChips({ actionItems, running, activeSends, alertCount, etaText }) {
const box = document.getElementById("live-chips");
if (!box) return;
const bits = [];
if (etaText) {
bits.push(`<span class="live-eta">${escapeHtml(etaText)}</span>`);
}
if (actionItems.length) {
bits.push(`<button type="button" class="live-chip todo" data-live-open="jetzt">${icon("sparkles")}<span>Jetzt</span><span class="count todo">${actionItems.length}</span></button>`);
}
running.forEach((p) => {
const pct = Math.max(0, Math.min(100, Math.round((Number(p.progress) || 0) * 100)));
const finish = p.remain > 0 ? finishClock(p.remain) : "";
const pausedFil = p.state === "paused" && (p.filament_empty || p.pause_reason);
const label = [
shortPrinter(p.name || p.host),
pausedFil ? "Material!" : (pct ? pct + "%" : ""),
finish,
].filter(Boolean).join(" · ");
const tip = [p.name || "", p.pause_reason || ""].filter(Boolean).join(" — ");
bits.push(`<button type="button" class="live-chip run${pausedFil ? " warn" : ""}" data-live-open="run" style="--pct:${pct}" title="${escapeHtml(tip || p.name || "")}">
<span class="live-chip-fill" aria-hidden="true"></span>
<span class="live-chip-text">${escapeHtml(label)}</span>
</button>`);
});
if (activeSends > 0) {
bits.push(`<button type="button" class="live-chip" data-live-open="send">${icon("send")}<span>Senden</span><span class="count">${activeSends}</span></button>`);
}
if (alertCount > 0) {
bits.push(`<button type="button" class="live-chip bad" data-live-open="alerts">${icon("bell")}<span>Meldungen</span><span class="count bad">${alertCount}</span></button>`);
}
setListHtml(box, bits.join(""));
box.querySelectorAll("[data-live-open]").forEach((btn) => {
btn.onclick = () => openLivePop(btn.dataset.liveOpen);
});
}
function bindLivePanelActions(panel) {
if (!panel) return;
panel.querySelectorAll("[data-cleared]").forEach((btn) => {
btn.onclick = async () => {
const host = btn.dataset.cleared;
const payload = await promptAbraeumen(btn.closest("li")?.querySelector(".queue-name")?.textContent || host);
if (!payload) return;
btn.disabled = true;
try {
await api("/drucker/" + encodeURIComponent(host) + "/abraeumen", { method: "POST", json: payload });
toast("Platte frei.");
} catch (error) { toast(error.message, "bad"); }
refreshJetzt();
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
btn.disabled = false;
};
});
panel.querySelectorAll("[data-cancel-q]").forEach((btn) => {
btn.onclick = () => cancelQueueItem({ id: Number(btn.dataset.cancelQ), status: "waiting" }, btn);
});
panel.querySelectorAll("[data-resume-q]").forEach((btn) => {
btn.onclick = () => armButton(btn, "Fortsetzen?", async () => {
try {
await api("/druck-queue/" + btn.dataset.resumeQ, { method: "PATCH", json: { status: "running", resume: true } });
toast("Ablauf geht weiter.");
} catch (error) { toast(error.message, "bad"); }
refreshJetzt();
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
});
});
panel.querySelectorAll("[data-dismiss-q]").forEach((btn) => {
btn.onclick = () => armButton(btn, "Ausblenden?", async () => {
try { await api("/druck-queue/" + btn.dataset.dismissQ, { method: "PATCH", json: { status: "dismissed" } }); } catch (error) { toast(error.message, "bad"); }
refreshJetzt();
});
});
}
async function refreshJetzt() {
let data;
try { data = await api("/jetzt"); } catch (error) { return; }
// Wartende ohne Materialgrund nicht in Jetzt — nur echte Handlungen + Material-Wartegründe
const actionItems = (data.actions || []).filter((a) => a.kind !== "waiting" || a.reason);
const running = data.running || [];
const eta = data.eta || {};
const multiEta = running.filter((p) => Number(p.remain) > 0).length >= 2;
const etaText = multiEta && eta.all_done_in_s > 0 ? "Alles fertig um " + finishClock(eta.all_done_in_s) : "";
const etaEl = document.getElementById("jetzt-eta");
if (etaEl) {
if (etaText) {
etaEl.hidden = false;
etaEl.textContent = etaText;
} else {
etaEl.hidden = true;
etaEl.textContent = "";
}
}
const jetztBox = document.getElementById("jetzt-box");
const jetztCount = document.getElementById("jetzt-count");
if (jetztCount) {
jetztCount.textContent = actionItems.length;
jetztCount.className = "count" + (actionItems.length ? " todo" : "");
}
if (jetztBox) {
jetztBox.classList.toggle("is-empty", !actionItems.length);
setListHtml(document.getElementById("jetzt-list"), actionItems.map(actionItemHtml).join(""));
}
const runBox = document.getElementById("run-box");
if (runBox) {
runBox.hidden = !running.length;
setListHtml(
document.getElementById("run-list"),
running.map((p) => runLineHtml({
name: p.name || p.host,
host: p.host,
progress: p.progress,
remain: p.remain,
filename: p.filename,
cancelId: p.queue_id,
})).join(""),
);
}
const sends = data.sends || [];
const activeSends = sends.filter((j) => j.status === "pending" || j.status === "running");
const sendBox = document.getElementById("send-box");
if (sendBox) {
sendBox.hidden = !activeSends.length && !sends.some((j) => j.status === "error");
const sendCount = document.getElementById("send-count");
if (sendCount) sendCount.textContent = activeSends.length;
setListHtml(
document.getElementById("send-list"),
sends.map((job) => {
const pct = Math.max(0, Math.min(100, Number(job.progress) || 0));
const cls = job.status === "error" ? "bad" : "";
return `<li class="queue-item ${job.status === "error" ? "error" : ""}">
<span class="queue-name">${escapeHtml(job.printer || "")}</span>
<span class="queue-msg">${escapeHtml(job.message || "")}</span>
<span class="send-progress-track slim"><span class="send-progress-bar ${cls}" style="width:${pct}%"></span></span>
</li>`;
}).join(""),
);
}
const alerts = data.alerts || [];
const alertCount = data.unread_alerts || alerts.length;
const alertBox = document.getElementById("alert-box");
if (alertBox) {
alertBox.hidden = !alerts.length;
const alertCountEl = document.getElementById("alert-count");
if (alertCountEl) alertCountEl.textContent = alertCount;
setListHtml(
document.getElementById("alert-list"),
alerts.map((item) => `<li class="queue-item">
<div class="queue-row"><span class="grow"><span class="queue-name">${escapeHtml(item.printer || item.kind)}</span></span><span class="queue-sub">${escapeHtml(clockOf(item.created_at))}</span></div>
<span class="queue-msg">${escapeHtml(item.message)}</span>
</li>`).join(""),
);
}
renderLiveChips({
actionItems,
running,
activeSends: activeSends.length,
alertCount,
etaText,
});
const mobileBadge = document.getElementById("mobile-jetzt-badge");
if (mobileBadge) {
const n = actionItems.length || running.length || activeSends.length || alertCount;
mobileBadge.hidden = !n;
mobileBadge.textContent = String(actionItems.length || n);
mobileBadge.classList.toggle("todo", !!actionItems.length);
}
if (typeof window.onBedarfChanged === "function") {
window.onBedarfChanged({ shortfall: Array(data.bedarf_shortfall || 0).fill({}) });
}
bindLivePanelActions(document.getElementById("live-panel"));
}
function refreshPrintQueue() { return refreshJetzt(); }
function refreshSendQueue() { return refreshJetzt(); }
function refreshEvents() { return refreshJetzt(); }
function refreshTasks() { return refreshJetzt(); }
function refreshBedarf() { return refreshJetzt(); }
(function mobileMoreSheet() {
const sheet = document.getElementById("mobile-more-sheet");
const btn = document.getElementById("mobile-more");
if (!sheet || !btn) return;
btn.addEventListener("click", () => {
if (typeof sheet.showModal === "function") sheet.showModal();
});
sheet.addEventListener("click", (event) => {
const link = event.target.closest("a[href]");
if (!link) return;
event.preventDefault();
const href = link.getAttribute("href");
sheet.close();
if (href) location.href = href;
});
sheet.addEventListener("close", () => {
btn.setAttribute("aria-expanded", "false");
});
btn.addEventListener("click", () => btn.setAttribute("aria-expanded", "true"));
})();
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/static/sw.js", { updateViaCache: "none" }).catch(() => {});
}
document.getElementById("events-clear")?.addEventListener("click", async (event) => {
event.preventDefault();
try { await api("/ereignisse/gelesen", { method: "POST", json: {} }); } catch (error) { /* still */ }
refreshJetzt();
if (typeof window.onEventsRead === "function") window.onEventsRead();
});
(function livePopClose() {
const bar = document.getElementById("live-bar");
const pop = document.getElementById("live-pop");
if (!bar || !pop) return;
document.addEventListener("click", (event) => {
if (pop.hidden) return;
if (bar.contains(event.target)) return;
closeLivePop();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeLivePop();
});
})();
window.refreshPrintQueue = refreshPrintQueue;
window.refreshSendQueue = refreshSendQueue;
window.refreshBedarf = refreshBedarf;
window.refreshTasks = refreshTasks;
window.refreshJetzt = refreshJetzt;
refreshJetzt();
(function liveUpdates() {
let streamLive = false;
const sseKinds = {
queue: () => refreshJetzt(),
senden: () => refreshJetzt(),
ereignis: () => refreshJetzt(),
aufgaben: () => refreshJetzt(),
drucker: () => {
refreshJetzt();
if (typeof window.onDruckerLive === "function") window.onDruckerLive();
},
};
function whenStreamDown(fn) {
return async () => {
if (streamLive) return;
await fn();
};
}
if (typeof EventSource !== "undefined") {
let es;
let retryMs = 2000;
const retryMax = 60000;
let hadStream = false;
let retryTimer = 0;
function connectStream() {
if (es) {
try { es.close(); } catch (e) {}
}
es = new EventSource("/api/v1/stream", { withCredentials: true });
es.addEventListener("open", () => {
streamLive = true;
retryMs = 2000;
if (hadStream) setConnectionBanner(true, "Live-Verbindung wieder da.");
hadStream = true;
});
es.addEventListener("error", () => {
streamLive = false;
try { es.close(); } catch (e) {}
setConnectionBanner(false, "Live-Verbindung unterbrochen — Fallback-Polling läuft.");
clearTimeout(retryTimer);
retryTimer = setTimeout(connectStream, retryMs);
retryMs = Math.min(retryMax, retryMs * 2);
});
Object.keys(sseKinds).forEach((kind) => {
es.addEventListener(kind, () => sseKinds[kind]());
});
es.addEventListener("ping", () => {});
}
connectStream();
window.addEventListener("pagehide", () => {
streamLive = false;
clearTimeout(retryTimer);
if (es) {
try { es.close(); } catch (e) {}
}
});
}
createPoller(whenStreamDown(refreshJetzt), 8000).start();
})();
</script>
{% block script %}{% endblock %}
</body>
</html>