/* ========================================================================== PoE Manager — shared UI behaviour (sidebar, theme, modals, toasts) ========================================================================== */ (function () { "use strict"; /* ---------------- Theme ---------------- */ const THEME_KEY = "poe-theme"; function applyTheme(theme) { document.documentElement.setAttribute("data-theme", theme); document.querySelectorAll("[data-theme-icon]").forEach((el) => { el.innerHTML = theme === "light" ? ICONS.moon : ICONS.sun; }); } function initTheme() { const saved = localStorage.getItem(THEME_KEY) || (window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"); applyTheme(saved); } function toggleTheme() { const current = document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark"; const next = current === "light" ? "dark" : "light"; localStorage.setItem(THEME_KEY, next); applyTheme(next); } const ICONS = { sun: '', moon: '', }; /* ---------------- Sidebar (mobile) ---------------- */ function initSidebar() { const sidebar = document.querySelector(".sidebar"); const backdrop = document.querySelector(".sidebar-backdrop"); const main = document.querySelector(".main"); const toggles = document.querySelectorAll("[data-sidebar-toggle]"); if (!sidebar) return; const DESKTOP_BREAKPOINT = 900; const COLLAPSE_KEY = "poe-sidebar-collapsed"; // Mobile: temporäres Überlagern per Hamburger + Backdrop. function open() { sidebar.classList.add("open"); backdrop && backdrop.classList.add("open"); } function close() { sidebar.classList.remove("open"); backdrop && backdrop.classList.remove("open"); } // Desktop: dauerhaftes Ein-/Ausklappen, über Neuladen hinweg gemerkt. function setCollapsed(collapsed) { sidebar.classList.toggle("collapsed", collapsed); main && main.classList.toggle("sidebar-collapsed", collapsed); localStorage.setItem(COLLAPSE_KEY, collapsed ? "1" : "0"); } if (localStorage.getItem(COLLAPSE_KEY) === "1") setCollapsed(true); toggles.forEach((btn) => btn.addEventListener("click", () => { if (window.innerWidth <= DESKTOP_BREAKPOINT) { sidebar.classList.contains("open") ? close() : open(); } else { setCollapsed(!sidebar.classList.contains("collapsed")); } })); backdrop && backdrop.addEventListener("click", close); document.querySelectorAll(".nav-item").forEach((a) => a.addEventListener("click", close)); } /* ---------------- Modals ---------------- */ function openModal(id) { const el = document.getElementById(id); if (!el) return; el.classList.add("open"); document.body.style.overflow = "hidden"; } function closeModal(el) { const overlay = el.closest ? el.closest(".modal-overlay") : el; if (!overlay) return; overlay.classList.remove("open"); document.body.style.overflow = ""; } function initModals() { document.querySelectorAll("[data-open-modal]").forEach((trigger) => { trigger.addEventListener("click", () => openModal(trigger.getAttribute("data-open-modal"))); }); document.querySelectorAll("[data-close-modal]").forEach((btn) => { btn.addEventListener("click", () => closeModal(btn)); }); document.querySelectorAll(".modal-overlay").forEach((overlay) => { overlay.addEventListener("click", (e) => { if (e.target === overlay) closeModal(overlay); }); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") { document.querySelectorAll(".modal-overlay.open").forEach((o) => closeModal(o)); } }); } /* ---------------- Confirm dialog (replaces window.confirm) ---------------- */ function ensureConfirmModal() { if (document.getElementById("confirm-modal")) return; const html = ` `; document.body.insertAdjacentHTML("beforeend", html); document.querySelectorAll("#confirm-modal [data-close-modal]").forEach((btn) => { btn.addEventListener("click", () => closeModal(btn)); }); document.getElementById("confirm-modal").addEventListener("click", (e) => { if (e.target.id === "confirm-modal") closeModal(e.target); }); } window.confirmAction = function (message, onConfirm, title) { ensureConfirmModal(); document.getElementById("confirm-title").innerText = title || "Bist du sicher?"; document.getElementById("confirm-message").innerText = message; const okBtn = document.getElementById("confirm-ok"); const freshBtn = okBtn.cloneNode(true); okBtn.parentNode.replaceChild(freshBtn, okBtn); freshBtn.addEventListener("click", () => { closeModal(freshBtn); onConfirm(); }); openModal("confirm-modal"); }; /* Intercept forms/buttons marked with data-confirm="message" */ function initConfirmables() { document.querySelectorAll("form[data-confirm]").forEach((form) => { form.addEventListener("submit", function (e) { if (form.dataset.confirmed === "1") return; e.preventDefault(); // Welcher Button hat den Submit ausgelöst? Muss beim erneuten // requestSubmit() explizit mitgegeben werden — sonst geht bei // Formularen, die ihre ID über name/value des Submit-Buttons // transportieren (statt über ein ), dieses // Feld beim Neu-Absenden stillschweigend verloren (requestSubmit() // ohne Argument zählt als "kein Button aktiviert"). const submitter = e.submitter; window.confirmAction(form.getAttribute("data-confirm"), () => { form.dataset.confirmed = "1"; form.requestSubmit ? form.requestSubmit(submitter) : form.submit(); }, form.getAttribute("data-confirm-title")); }); }); } /* ---------------- Toasts ---------------- */ function ensureToastStack() { let stack = document.querySelector(".toast-stack"); if (!stack) { stack = document.createElement("div"); stack.className = "toast-stack"; document.body.appendChild(stack); } return stack; } window.showToast = function (message, type) { const stack = ensureToastStack(); const toast = document.createElement("div"); toast.className = "toast " + (type || "info"); toast.innerHTML = `${message}×`; stack.appendChild(toast); const remove = () => { toast.classList.add("hide"); setTimeout(() => toast.remove(), 180); }; toast.querySelector(".toast-close").addEventListener("click", remove); setTimeout(remove, 5000); }; function initFlashedMessages() { const data = document.getElementById("flashed-data"); if (!data) return; try { const messages = JSON.parse(data.textContent); messages.forEach(([category, msg]) => { const type = category === "danger" ? "danger" : category === "success" ? "success" : "info"; window.showToast(msg, type); }); } catch (e) { /* noop */ } } /* Nav-Gruppen mit Untermenü (z.B. "Logs") auf-/zuklappen — der manuell aufgeklappte Zustand bleibt über Seitenwechsel hinweg erhalten (localStorage), zusätzlich zur automatischen Aufklappung der Gruppe der aktuell aktiven Seite (server-seitig via "expanded"-Klasse). */ const NAV_EXPANDED_KEY = "poe-nav-expanded"; function getExpandedNavGroups() { try { return new Set(JSON.parse(localStorage.getItem(NAV_EXPANDED_KEY) || "[]")); } catch (e) { return new Set(); } } function saveExpandedNavGroups(set) { localStorage.setItem(NAV_EXPANDED_KEY, JSON.stringify(Array.from(set))); } function initNavGroups() { const expanded = getExpandedNavGroups(); document.querySelectorAll("[data-nav-group]").forEach((group) => { const key = group.dataset.navGroupKey; if (key && expanded.has(key)) group.classList.add("expanded"); }); document.querySelectorAll("[data-nav-group-toggle]").forEach((btn) => { btn.addEventListener("click", () => { const group = btn.closest("[data-nav-group]"); const isExpanded = group.classList.toggle("expanded"); const key = group.dataset.navGroupKey; if (!key) return; const set = getExpandedNavGroups(); if (isExpanded) set.add(key); else set.delete(key); saveExpandedNavGroups(set); }); }); } /* ---------------- Globaler "Nächste Prüfung"-Timer (Topbar, jede Seite) ---------------- */ function initCheckTimer() { const pill = document.getElementById("global-timer-pill"); const timerEl = document.getElementById("global-timer"); if (!pill || !timerEl) return; // lastRunMs/intervalMs werden bei JEDEM Tick frisch aus dem DOM gelesen // (nicht einmalig gecacht) — so kann z.B. das Dashboard nach einem // Live-Update (siehe dashboard-tiles.js) oder ein manueller "Jetzt // prüfen"-Klick das data-last-run-ms-Attribut aktualisieren und der // global laufende Countdown übernimmt das automatisch. function update() { const lastRunMs = parseInt(pill.dataset.lastRunMs, 10); const intervalMs = parseInt(pill.dataset.intervalMs, 10); if (!intervalMs) { timerEl.textContent = "--"; return; } let remainingMs = intervalMs; if (!isNaN(lastRunMs) && lastRunMs > 0) { const elapsed = Date.now() - lastRunMs; remainingMs = intervalMs - (((elapsed % intervalMs) + intervalMs) % intervalMs); } timerEl.textContent = Math.max(0, Math.ceil(remainingMs / 1000)); } update(); setInterval(update, 1000); } /* "Jetzt prüfen"-Button neben dem globalen Timer (nur Admins): startet rpi-check.service neu (frisches Logfile + sofortiger Durchlauf) und meldet das per Custom Event, damit Dashboard/Live-Log sich selbst aktualisieren können, ohne dass app.js die Seiteninhalte kennen muss. */ function initRunCheckNow() { const btn = document.getElementById("run-check-now"); if (!btn) return; btn.addEventListener("click", () => { btn.disabled = true; btn.classList.add("spinning"); fetch("/check/run-now", { method: "POST" }) .then((r) => r.json()) .then((data) => { if (!data.success) { window.showToast(data.msg || "Fehler beim Starten der Prüfung.", "danger"); btn.disabled = false; btn.classList.remove("spinning"); return; } window.showToast(data.msg || "Prüfung gestartet.", "success"); document.dispatchEvent(new CustomEvent("poe:check-triggered")); // Topbar-Countdown auf JEDER Seite aktualisieren, nicht nur auf // dem Dashboard — vorher lief er auf allen anderen Seiten mit dem // alten Zeitpunkt weiter, obwohl im Hintergrund längst neu geprüft // wurde (die Prüfung lief trotzdem korrekt, nur sichtbar tat sich // nichts). Der Server liefert den frischen Zeitpunkt direkt in // dieser Antwort mit (siehe run_check_now) statt eines zweiten // Requests dafür. const pill = document.getElementById("global-timer-pill"); if (pill && data.last_run_epoch_ms) { pill.dataset.lastRunMs = data.last_run_epoch_ms; } btn.disabled = false; btn.classList.remove("spinning"); }) .catch(() => { window.showToast("Fehler beim Starten der Prüfung.", "danger"); btn.disabled = false; btn.classList.remove("spinning"); }); }); } /* ---------------- Sortierbare Tabellen ---------------- */ /* * Klick auf sortiert die Tabelle. Zwei Modi: * - Normale Tabellen: sortiert -Zeilen innerhalb des einzigen * anhand von data-sort- auf der jeweiligen . * - Akkordeon-Tabellen (mehrere , z.B. Gruppen mit Detail-Zeile): * sortiert ganze -Blöcke anhand von data-sort- auf dem * jeweiligen , damit Haupt- und Detail-Zeile zusammenbleiben. * (z.B. die virtuelle "Admin"-Zeile) bleibt * dabei immer an ihrer Position. */ function compareSortValues(a, b, asc) { a = (a === null || a === undefined) ? "" : String(a); b = (b === null || b === undefined) ? "" : String(b); const na = parseFloat(a), nb = parseFloat(b); const bothNumeric = a !== "" && b !== "" && !isNaN(na) && !isNaN(nb); const cmp = bothNumeric ? (na - nb) : a.toLowerCase().localeCompare(b.toLowerCase(), "de"); return asc ? cmp : -cmp; } function sortTable(table, th) { const key = th.dataset.sortKey; const asc = th.dataset.sortDir !== "asc"; table.querySelectorAll("thead th[data-sort-key]").forEach((h) => { delete h.dataset.sortDir; h.classList.remove("sort-asc", "sort-desc"); }); th.dataset.sortDir = asc ? "asc" : "desc"; th.classList.add(asc ? "sort-asc" : "sort-desc"); if (table.tBodies.length > 1) { const movable = Array.prototype.filter.call(table.tBodies, (tb) => !tb.hasAttribute("data-sort-pinned")); movable.sort((a, b) => compareSortValues( a.getAttribute("data-sort-" + key), b.getAttribute("data-sort-" + key), asc )); movable.forEach((tb) => table.appendChild(tb)); } else { const tbody = table.tBodies[0]; const rows = Array.prototype.filter.call(tbody.rows, (r) => !r.classList.contains("empty-row")); rows.sort((a, b) => compareSortValues( a.getAttribute("data-sort-" + key), b.getAttribute("data-sort-" + key), asc )); rows.forEach((r) => tbody.appendChild(r)); } } function initSortableTables() { document.querySelectorAll("table[data-sortable] thead th[data-sort-key]").forEach((th) => { th.classList.add("sortable-col"); th.addEventListener("click", () => sortTable(th.closest("table"), th)); }); } /* ---------------- Init ---------------- */ document.addEventListener("DOMContentLoaded", function () { initTheme(); initSidebar(); initModals(); initConfirmables(); initFlashedMessages(); initNavGroups(); initCheckTimer(); initRunCheckNow(); initSortableTables(); document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme)); }); window.PoeUI = { openModal, closeModal }; })();