aether-framework/resources/[aether]/aether-admin/html/js/app.js
Jerrit Fritzsche e4b82dd685 Aether-Framework: Fachmodule, Admin-Panel und Docker-Datenbank
- aether-medical: Stationen/Belegung, Intensivstation, Blutbank, Fuhrpark,
  Nachrichten/Aushänge, Berichte und Textbausteine (server + NUI)
- aether-admin: vollständiges Server-Admin-Panel als echtes FiveM-Resource,
  an aether-core gekoppelt (Rechte über aether_users.admin_group), mit
  WebRTC-Live-Kameras (Bildschirmfreigabe) und separatem Media-Server (SFU)
- Prototypen admin/ vervollständigt
- SQL-Schema aether_admin.sql
- Docker-Compose mit MariaDB und idempotenter Auto-Migration (schema_migrations)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 18:55:32 +02:00

203 lines
7.7 KiB
JavaScript

/* =====================================================================
aether-admin — Oberflächenkern
Die Shell (Seitenleiste, Kopfleiste, Dialoge, Meldungen) und die
einzige Brücke zum Server: `App.aufruf(modul, aktion, daten)`. Jede
Sektion rendert aus echten Serverdaten — die Oberfläche hält keinen
eigenen Spielzustand und entscheidet nichts. Rechte werden serverseitig
geprüft; die UI blendet nur aus, was ohnehin abgelehnt würde.
===================================================================== */
"use strict";
const IST_FIVEM = (typeof GetParentResourceName === "function");
const RESSOURCE = IST_FIVEM ? GetParentResourceName() : "aether-admin";
const App = (() => {
const zustand = {
admin: { name: "—", gruppe: "user" },
sektionen: [],
rechte: {},
media: { aktiv: false },
serverName: "Aether RP",
sektion: "dashboard",
};
const sektionen = {}; // [id] = renderfunktion
const offeneRufe = {}; // [ruf] = { ok, fail }
let rufZaehler = 0;
let mapTimer = null;
const $ = (id) => document.getElementById(id);
/* ---- Helfer -------------------------------------------------------- */
function sicher(t) {
if (t === null || t === undefined) return "";
return String(t).replace(/[&<>"']/g,
(z) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[z]));
}
const euro = (n) => Math.round(n || 0).toLocaleString("de-DE") + " €";
const kompakt = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + " Mio"
: n >= 1e3 ? (n / 1e3).toFixed(1) + "k" : "" + Math.round(n || 0);
const initialen = (n) => String(n || "?").replace(/\s*\(.*\)/, "").split(" ")
.map((w) => w[0]).slice(0, 2).join("");
function avColor(n) {
let h = 0; for (const c of String(n || "")) h = (h * 31 + c.charCodeAt(0)) % 360;
return `hsl(${h} 45% 45%)`;
}
function zeit(w) {
if (!w) return "—";
const d = new Date(String(w).replace(" ", "T"));
if (isNaN(d)) return sicher(w);
return d.toLocaleString("de-DE",
{ day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
}
function darf(schluessel) { return zustand.rechte[schluessel] === true; }
/* ---- Server-Brücke ------------------------------------------------- */
function anClient(pfad, daten) {
if (!IST_FIVEM) return Promise.resolve({});
return fetch(`https://${RESSOURCE}/${pfad}`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify(daten || {}),
}).then((a) => a.json()).catch(() => ({}));
}
/**
* Ruft eine Serveraktion auf und wartet auf die Antwort.
* @returns {Promise<object>}
*/
function aufruf(modul, aktion, daten) {
return new Promise((ok, fail) => {
if (!IST_FIVEM) { fail(new Error("Vorschaumodus — keine Serververbindung.")); return; }
const ruf = ++rufZaehler;
offeneRufe[ruf] = { ok, fail };
anClient("aufruf", { modul, aktion, daten: daten || {}, ruf });
setTimeout(() => {
if (offeneRufe[ruf]) {
delete offeneRufe[ruf];
fail(new Error("Zeitüberschreitung — der Server hat nicht geantwortet."));
}
}, 15000);
});
}
/* ---- Meldungen & Dialoge ------------------------------------------- */
let toastTimer = null;
function toast(text, typ) {
const e = $("toast");
e.textContent = text;
e.className = "toast show" + (typ ? " " + typ : "");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => e.classList.remove("show"), 2600);
}
function modal(html, klein) {
$("overlay").innerHTML = `<div class="modal${klein ? " sm" : ""}">${html}</div>`;
$("overlay").classList.add("show");
}
function closeModal() { $("overlay").classList.remove("show"); $("overlay").innerHTML = ""; }
/* ---- Kopf & Navigation --------------------------------------------- */
function renderTop() {
$("top").innerHTML =
`<div class="srv-chip"><span class="srv-dot"></span>${sicher(zustand.serverName)}</div>
<div class="live" id="top-live"></div>
<div class="me"><span class="av" style="background:${avColor(zustand.admin.name)}">${sicher(initialen(zustand.admin.name))}</span>
<div><div class="mn">${sicher(zustand.admin.name)}</div><div class="mr">${sicher(zustand.admin.gruppe)}</div></div></div>`;
}
function renderNav() {
$("nav").innerHTML = zustand.sektionen.map((it) => it.grp
? `<div class="grp">${sicher(it.grp)}</div>`
: `<button data-s="${sicher(it.id)}" class="${it.id === zustand.sektion ? "active" : ""}">
<span class="ni">${sicher(it.icon)}</span>${sicher(it.label)}
<span class="badge" id="badge-${sicher(it.id)}" hidden></span></button>`).join("");
$("nav").querySelectorAll("[data-s]").forEach((b) =>
b.addEventListener("click", () => oeffneSektion(b.dataset.s)));
}
function oeffneSektion(id) {
clearInterval(mapTimer); mapTimer = null;
zustand.sektion = id;
renderNav();
const rendern = sektionen[id] || sektionen.dashboard;
$("main").innerHTML = `<div class="empty"><div class="big">⏳</div>Lädt …</div>`;
Promise.resolve()
.then(() => rendern($("main")))
.catch((f) => {
$("main").innerHTML = `<div class="phead"><div><h2>Fehler</h2>
<p>${sicher(f.message)}</p></div></div>`;
});
}
/* ---- Öffnen / Nachrichten vom Client ------------------------------- */
function zeigePanel(daten) {
zustand.admin = daten.admin || zustand.admin;
zustand.sektionen = daten.sektionen || [];
zustand.rechte = daten.rechte || {};
zustand.media = daten.media || { aktiv: false };
zustand.serverName = daten.serverName || "Aether RP";
$("panel").hidden = false;
document.body.style.display = "";
renderTop();
renderNav();
// Erste sichtbare Sektion öffnen
const erste = zustand.sektionen.find((s) => s.id);
oeffneSektion(erste ? erste.id : "dashboard");
}
function schliessePanel() {
$("panel").hidden = true;
document.body.style.display = "none";
closeModal();
clearInterval(mapTimer); mapTimer = null;
}
window.addEventListener("message", (e) => {
const d = e.data || {};
switch (d.aktion) {
case "oeffnen": zeigePanel(d); break;
case "schliessen": schliessePanel(); break;
case "antwort": {
const offen = offeneRufe[d.ruf];
if (!offen) break;
delete offeneRufe[d.ruf];
if (d.fehler) offen.fail(new Error(d.fehler));
else offen.ok(d.ergebnis || {});
break;
}
}
});
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
if ($("overlay").classList.contains("show")) { closeModal(); return; }
schliessen();
});
function schliessen() { anClient("schliessen", {}); schliessePanel(); }
/* ---- Öffentliche Schnittstelle ------------------------------------- */
return {
registriere(id, fn) { sektionen[id] = fn; },
zustand, aufruf, anClient, toast, modal, closeModal, schliessen,
sicher, euro, kompakt, initialen, avColor, zeit, darf,
renderNav, oeffneSektion, $,
setMapTimer(t) { clearInterval(mapTimer); mapTimer = t; },
start() {
if (IST_FIVEM) document.body.style.display = "none";
else {
// Eigenständige Browser-Vorschau: nur der Rahmen, keine Daten
$("panel").hidden = false;
zustand.sektionen = [{ grp: "Vorschau" }, { id: "dashboard", label: "Dashboard", icon: "📊" }];
renderTop(); renderNav();
$("main").innerHTML = `<div class="empty"><div class="big">🖥️</div>
Vorschaumodus — im Spiel mit <b>F9</b> öffnen. Ohne Server keine Live-Daten.</div>`;
}
},
};
})();
App.start();