Grotti 3.1.0: Bedarf-UI, Zeitfenster, HT/NT, Theme, Chrome-Fixes.

Bedarf in Sidebar/Material, Drucker-Zeitfenster und Tarif in Einstellungen, helles Theme; Legacy-Token und /api/drucker entfernt; SSE/SW-Fixes gegen hängende Navigation in Chrome.
This commit is contained in:
Dominic Gundel committed 2026-10-18 02:20:12 +02:00
1 parent 8e88efc094
commit 6e59df22e5
23 files changed
+741 -171

No files matched your search

+111 -16
View File
@@ -9,11 +9,27 @@
<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=54">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=56">
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
<meta name="theme-color" content="#0a0a0c">
<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"));
@@ -35,6 +51,14 @@
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>
@@ -132,6 +156,11 @@
<ul class="queue-list" id="alert-list"></ul>
<div class="queue-foot"><button type="button" class="ghost tiny-btn" id="events-clear">gelesen</button>&nbsp;<a href="/aktivitaet">alle&nbsp;→</a></div>
</details>
<details class="queue-box" data-box="bedarf" id="bedarf-box" open hidden>
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Bedarf</span><span class="count bad" id="bedarf-count">0</span></summary>
<ul class="queue-list" id="bedarf-list"></ul>
<div class="queue-foot"><a href="/material#bedarf">Material&nbsp;→</a></div>
</details>
</div>
<nav class="nav-secondary" aria-label="Weitere Seiten">
<a href="/einstellungen" class="{{ 'on' if page == 'einstellungen' else '' }}" title="Einstellungen"><svg class="ico"><use href="#i-settings"/></svg><span>Einstellungen</span></a>
@@ -171,10 +200,10 @@
<a href="/einstellungen">Einstellungen</a>
<a href="/doku">Doku</a>
</nav>
<form method="dialog"><button class="ghost">Schließen</button></form>
<form method="dialog"><button class="ghost" value="cancel">Schließen</button></form>
</dialog>
<script src="{{ url_for('static', filename='api.js') }}?v=28"></script>
<script src="{{ url_for('static', filename='ui.js') }}?v=2"></script>
<script src="{{ url_for('static', filename='api.js') }}?v=29"></script>
<script src="{{ url_for('static', filename='ui.js') }}?v=3"></script>
<script>
document.getElementById("logout-btn")?.addEventListener("click", async () => {
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
@@ -477,6 +506,25 @@ async function refreshEvents() {
</li>`).join("");
}
async function refreshBedarf() {
let data;
try { data = await api("/bedarf"); } catch (error) { return; }
const shortfall = data.shortfall || [];
const box = document.getElementById("bedarf-box");
if (!box) return;
box.hidden = !shortfall.length;
document.getElementById("bedarf-count").textContent = shortfall.length;
document.getElementById("bedarf-list").innerHTML = shortfall.slice(0, 5).map((item) => {
const label = [item.material_type, item.color].filter(Boolean).join(" · ") || "Filament";
return `<li class="queue-item">
<div class="queue-row"><span class="grow"><span class="queue-name">${escapeHtml(label)}</span></span>
<span class="queue-sub">${grams(item.shortfall_g)} fehlen</span></div>
<span class="queue-msg">${grams(item.weight_g)} Queue · ${grams(item.remaining_g)} Rest</span>
</li>`;
}).join("");
if (typeof window.onBedarfChanged === "function") window.onBedarfChanged(data);
}
async function refreshTasks() {
let items;
try { items = await api("/aufgaben"); } catch (error) { return; }
@@ -508,11 +556,29 @@ async function refreshTasks() {
};
});
}
document.getElementById("mobile-more")?.addEventListener("click", () => {
document.getElementById("mobile-more-sheet")?.showModal();
});
(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();
});
// Modal schließt und blockiert sonst die ganze App (Chrome).
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").catch(() => {});
navigator.serviceWorker.register("/static/sw.js", { updateViaCache: "none" }).catch(() => {});
}
document.getElementById("events-clear").addEventListener("click", async (event) => {
@@ -524,15 +590,21 @@ document.getElementById("events-clear").addEventListener("click", async (event)
window.refreshPrintQueue = refreshPrintQueue;
window.refreshSendQueue = refreshSendQueue;
window.refreshBedarf = refreshBedarf;
refreshSendQueue();
refreshPrintQueue();
refreshEvents();
refreshTasks();
refreshBedarf();
window.refreshTasks = refreshTasks;
(function liveUpdates() {
let streamLive = false;
const sseKinds = {
queue: () => refreshPrintQueue(),
queue: () => {
refreshPrintQueue();
refreshBedarf();
},
senden: () => refreshSendQueue(),
ereignis: () => refreshEvents(),
aufgaben: () => refreshTasks(),
@@ -543,23 +615,37 @@ window.refreshTasks = refreshTasks;
},
};
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", () => {
es.close();
streamLive = false;
try { es.close(); } catch (e) {}
setConnectionBanner(false, "Live-Verbindung unterbrochen — Fallback-Polling läuft.");
setTimeout(connectStream, retryMs);
clearTimeout(retryTimer);
retryTimer = setTimeout(connectStream, retryMs);
retryMs = Math.min(retryMax, retryMs * 2);
});
Object.keys(sseKinds).forEach((kind) => {
@@ -568,12 +654,21 @@ window.refreshTasks = refreshTasks;
es.addEventListener("ping", () => {});
}
connectStream();
window.addEventListener("pagehide", () => {
streamLive = false;
clearTimeout(retryTimer);
if (es) {
try { es.close(); } catch (e) {}
}
});
}
createPoller(refreshPrintQueue, 8000).start();
createPoller(refreshSendQueue, 4000).start();
createPoller(refreshEvents, 12000).start();
createPoller(refreshTasks, 12000).start();
// Poller nur als Fallback, wenn SSE tot ist — sonst staut Chrome die Verbindungen.
createPoller(whenStreamDown(refreshPrintQueue), 8000).start();
createPoller(whenStreamDown(refreshSendQueue), 4000).start();
createPoller(whenStreamDown(refreshEvents), 12000).start();
createPoller(whenStreamDown(refreshTasks), 12000).start();
createPoller(whenStreamDown(refreshBedarf), 30000).start();
})();
</script>
{% block script %}{% endblock %}