/* ==========================================================================
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;
});
// Favicon + Sidebar-Logo folgen demselben Theme wie der Rest der App
// (auch bei manuellem Umschalten, nicht nur bei System-Präferenz) --
// "dunkel"/"hell" bezeichnen hier die für den jeweiligen Modus gedachte
// Logo-Variante (helle Farben für Dark Mode, dunkle für Light Mode).
const suffix = theme === "light" ? "light" : "dark";
const favicon = document.getElementById("app-favicon");
if (favicon) favicon.href = "/static/images/icon-" + suffix + ".svg";
const sidebarLogo = document.getElementById("sidebar-logo");
if (sidebarLogo) sidebarLogo.src = "/static/images/logo-" + suffix + ".svg";
const loginBgLogo = document.getElementById("login-bg-logo");
if (loginBgLogo) loginBgLogo.src = "/static/images/logo-" + suffix + "-subline.svg";
}
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 = `
Bist du sicher?
`;
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");
};
/* ---------------- Ja/Nein-Dialog für ungespeicherte Änderungen ---------------- */
/* Eigener Dialog statt confirmAction (dort ist "Abbrechen" = beim Bleiben,
hier gibt es bewusst nur die zwei angefragten Optionen: speichern oder
verwerfen — beide verlassen die Seite, nur "Abbrechen"/Escape/Backdrop
bricht das Verlassen selbst ab und lässt die Seite unverändert offen. */
function ensureSaveDiscardModal() {
if (document.getElementById("save-discard-modal")) return;
const html = `
Ungespeicherte Änderungen
`;
document.body.insertAdjacentHTML("beforeend", html);
document.querySelectorAll("#save-discard-modal [data-close-modal]").forEach((btn) => {
btn.addEventListener("click", () => closeModal(btn));
});
document.getElementById("save-discard-modal").addEventListener("click", (e) => {
if (e.target.id === "save-discard-modal") closeModal(e.target);
});
}
window.confirmSaveDiscard = function (message, onSave, onDiscard) {
ensureSaveDiscardModal();
document.getElementById("save-discard-message").innerText = message;
const yesBtn = document.getElementById("save-discard-yes");
const noBtn = document.getElementById("save-discard-no");
const freshYes = yesBtn.cloneNode(true);
yesBtn.parentNode.replaceChild(freshYes, yesBtn);
const freshNo = noBtn.cloneNode(true);
noBtn.parentNode.replaceChild(freshNo, noBtn);
freshYes.addEventListener("click", () => { closeModal(freshYes); onSave(); });
freshNo.addEventListener("click", () => { closeModal(freshNo); onDiscard(); });
openModal("save-discard-modal");
};
/* ---------------- RAW-Log-Popup (Live-Log & Verlauf) ---------------- */
/* Gleiches Modal-Grundgerüst wie der Ja/Nein-Dialog für ungespeicherte
Änderungen (modal-overlay/modal-header/modal-body/modal-footer), nur
breiter/höher und mit lazy nachgeladenem Inhalt -- der komplette
Log-Inhalt wird bewusst erst beim tatsächlichen Öffnen per fetch
nachgeladen, nicht schon beim Seitenaufbau, damit genau das Problem
(unnötig große Ladezeiten) nicht durch das Popup selbst zurückkommt. */
function ensureRawLogModal() {
if (document.getElementById("raw-log-modal")) return;
const html = `
Komplettes Log (RAW)
`;
document.body.insertAdjacentHTML("beforeend", html);
document.querySelectorAll("#raw-log-modal [data-close-modal]").forEach((btn) => {
btn.addEventListener("click", () => closeModal(btn));
});
document.getElementById("raw-log-modal").addEventListener("click", (e) => {
if (e.target.id === "raw-log-modal") closeModal(e.target);
});
}
function renderRawLogLines(container, text) {
// Eine Zeile = ein
, Nummer kommt per CSS-Counter (::before) --
// so bleibt der eigentliche Zeileninhalt reiner textContent (kein XSS-
// Risiko) und die Nummerierung muss nirgends von Hand mitgezählt werden.
const lines = text.split("\n");
if (lines.length && lines[lines.length - 1] === "") lines.pop(); // trailing \n erzeugt sonst eine Phantomzeile
container.textContent = "";
const frag = document.createDocumentFragment();
lines.forEach((line) => {
const row = document.createElement("div");
row.className = "raw-log-line";
row.textContent = line;
frag.appendChild(row);
});
container.appendChild(frag);
}
window.openRawLogModal = function (url, title, withLineNumbers) {
if (!url) return;
ensureRawLogModal();
document.getElementById("raw-log-modal-title").innerText = title || "Komplettes Log (RAW)";
const content = document.getElementById("raw-log-modal-content");
const meta = document.getElementById("raw-log-modal-meta");
content.classList.toggle("with-line-numbers", !!withLineNumbers);
content.textContent = "Lade …";
meta.textContent = "";
openModal("raw-log-modal");
fetch(url)
.then((r) => {
const logName = r.headers.get("X-Log-Name");
if (logName) meta.textContent = logName;
return r.text();
})
.then((text) => {
if (withLineNumbers) renderRawLogLines(content, text);
else content.textContent = text;
})
.catch(() => { content.textContent = "Fehler beim Laden des Logs."; });
};
/* ---------------- Hinweis-Icons (Hover-Tooltip + Klick-Modal) ---------------- */
/* Zentraler Mechanismus für ALLE "i"-Icons (siehe _hint_icon.html) app-weit
-- ein einziges delegiertes Set von Listenern statt pro Icon eigener
Handler, damit auch nachträglich per JS eingefügte Icons (z.B. in
dynamisch nachgeladenen Tabellenzeilen) ohne weiteres Zutun funktionieren.
Hover zeigt den Text als Tooltip neben dem Mauszeiger -- aber NUR auf
Geräten, die tatsächlich sinnvoll hovern können (matchMedia-Check bei
JEDEM mouseenter neu ausgewertet, nicht einmalig beim Laden gecacht,
damit z.B. ein Convertible/Tablet beim Umschalten Maus/Touch korrekt
reagiert). Auf reinen Touch-Geräten bleibt so nur Tap -> Modal übrig,
da dort ohnehin kein echtes mouseenter/mousemove-Hover stattfindet. */
function ensureHintTooltip() {
if (document.getElementById("hint-tooltip")) return;
const el = document.createElement("div");
el.id = "hint-tooltip";
document.body.appendChild(el);
}
function ensureHintModal() {
if (document.getElementById("hint-modal")) return;
const html = `
Hinweis
`;
document.body.insertAdjacentHTML("beforeend", html);
document.querySelectorAll("#hint-modal [data-close-modal]").forEach((btn) => {
btn.addEventListener("click", () => closeModal(btn));
});
document.getElementById("hint-modal").addEventListener("click", (e) => {
if (e.target.id === "hint-modal") closeModal(e.target);
});
}
function canHover() {
return window.matchMedia && window.matchMedia("(hover: hover) and (pointer: fine)").matches;
}
function initHintIcons() {
if (!document.querySelector(".hint-icon")) return;
ensureHintTooltip();
const tooltip = document.getElementById("hint-tooltip");
function positionTooltip(e) {
const offset = 14;
const rect = tooltip.getBoundingClientRect();
let x = e.clientX + offset;
let y = e.clientY + offset;
if (x + rect.width > window.innerWidth - 8) x = e.clientX - rect.width - offset;
if (y + rect.height > window.innerHeight - 8) y = e.clientY - rect.height - offset;
tooltip.style.left = Math.max(8, x) + "px";
tooltip.style.top = Math.max(8, y) + "px";
}
// mouseenter/mouseleave bubbeln nicht -- Delegation via Capture-Phase
// auf document funktioniert dafür trotzdem zuverlässig.
document.addEventListener("mouseenter", function (e) {
const icon = e.target.closest && e.target.closest(".hint-icon");
if (!icon || !canHover()) return;
tooltip.textContent = icon.dataset.hint || "";
tooltip.style.display = "block";
positionTooltip(e);
}, true);
document.addEventListener("mouseleave", function (e) {
const icon = e.target.closest && e.target.closest(".hint-icon");
if (!icon) return;
tooltip.style.display = "none";
}, true);
document.addEventListener("mousemove", function (e) {
if (tooltip.style.display === "block") positionTooltip(e);
});
document.addEventListener("click", function (e) {
const icon = e.target.closest && e.target.closest(".hint-icon");
if (!icon) return;
e.preventDefault();
tooltip.style.display = "none";
ensureHintModal();
document.getElementById("hint-modal-title").textContent = icon.dataset.hintTitle || "Hinweis";
document.getElementById("hint-modal-body").textContent = icon.dataset.hint || "";
openModal("hint-modal");
});
}
/* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */
/* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten
Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/
Toggle-Buttons) seit dem Laden der Seite verändert wurde, und fragt vor
dem Verlassen (Klick auf einen Link, z.B. in der Navigation) per Ja/Nein,
ob zuerst gespeichert werden soll — statt Änderungen stillschweigend zu
verwerfen. "Ja" sendet das Formular ganz normal ab (echter POST, kein
AJAX-Nachbau nötig) und merkt sich das eigentliche Ziel in
sessionStorage, um nach dem Speichern automatisch dorthin
weiterzuleiten, statt dass der Link ein zweites Mal angeklickt werden
muss. Für Browser-eigene Navigation (Tab schließen, Reload, Adresszeile)
gibt es zusätzlich beforeunload — dort erlaubt der Browser aus
Sicherheitsgründen aber nur eine generische Warnung, kein eigenes
Ja/Nein/Speichern-Dialogfeld. */
const PENDING_NAV_KEY = "poe-pending-nav-after-save";
function serializeForm(form) {
return new URLSearchParams(new FormData(form)).toString();
}
function initUnsavedChangesGuard() {
const pendingNav = sessionStorage.getItem(PENDING_NAV_KEY);
if (pendingNav) {
sessionStorage.removeItem(PENDING_NAV_KEY);
window.location.href = pendingNav;
return;
}
const trackable = [];
document.querySelectorAll("form[method='post' i]").forEach((form) => {
if (form.hasAttribute("data-no-unsaved-guard")) return;
const fields = form.querySelectorAll("input:not([type=hidden]):not([type=submit]):not([type=button]), select, textarea");
if (!fields.length) return; // reine Aktions-Formulare (Löschen/Toggle) ohne Eingabefeld
trackable.push({ form, initial: serializeForm(form) });
});
if (!trackable.length) return;
// Verhindert die native Browser-Warnung (beforeunload), wenn die Seite
// ohnehin schon bewusst verlassen wird — entweder weil eines der
// beobachteten Formulare ganz normal abgeschickt wurde (eigener
// "Speichern"/"Anlegen"-Button auf der Seite) oder weil unser eigener
// Ja/Nein-Dialog die Navigation ausgelöst hat. Ohne das würde beim
// normalen Speichern zusätzlich zum eigentlichen Erfolg immer noch die
// generische Browser-Meldung aufpoppen — die soll ausschließlich dann
// erscheinen, wenn tatsächlich UNGEFRAGT navigiert wird (Tab schließen,
// Reload, Adresszeile), nicht bei einem bewussten Speichern.
let navigatingAway = false;
trackable.forEach(({ form }) => {
form.addEventListener("submit", () => { navigatingAway = true; });
});
function dirtyForm() {
return trackable.find(({ form, initial }) => serializeForm(form) !== initial) || null;
}
document.addEventListener("click", (e) => {
const link = e.target.closest("a[href]");
if (!link || link.target === "_blank" || link.href.startsWith("javascript:") || link.hasAttribute("data-no-unsaved-guard")) return;
const dirty = dirtyForm();
if (!dirty) return;
e.preventDefault();
const href = link.href;
window.confirmSaveDiscard(
"Es gibt ungespeicherte Änderungen auf dieser Seite. Vor dem Verlassen speichern?",
() => {
navigatingAway = true;
sessionStorage.setItem(PENDING_NAV_KEY, href);
dirty.form.requestSubmit ? dirty.form.requestSubmit() : dirty.form.submit();
},
() => {
navigatingAway = true;
window.location.href = href;
}
);
});
window.addEventListener("beforeunload", (e) => {
if (!navigatingAway && dirtyForm()) {
e.preventDefault();
e.returnValue = "";
}
});
}
/* 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);
});
});
}
/* ---------------- 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();
initUnsavedChangesGuard();
initSortableTables();
initHintIcons();
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
});
window.PoeUI = { openModal, closeModal };
})();