- 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>
203 lines
7.7 KiB
JavaScript
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|