UX-Paket: Navbar aufgeräumt, /server-Seite, Lightbox, Skeletons, Audit-Log, Reroll

- Navbar entschlackt: nur noch Devlog · Roadmap · Server · Community ·
  Setup; Galerie/Level/Events/Changelog im Community-Dropdown,
  Profil/Commits/Logout im Avatar-Menü; Mobile-Burger mit Drawer
- Öffentliche /server-Seite: Live-Status-Karten mit Spieler-Balken,
  Map, Connect-Button und 24h-Spielerzahl-Chart (SVG); Monitor sampelt
  jede Abfrage in player_history (7 Tage Retention), GET /api/servers
  liefert nur öffentliche Felder (keine Query-URLs/Hosts)
- Lightbox: Galerie- und Devlog-Bilder öffnen im Overlay mit
  Pfeiltasten, Buttons, Wisch-Gesten und Zähler statt neuem Tab
- Skeleton-Loader: schimmernde Platzhalter auf Devlogs, Roadmap,
  Galerie, Level, Changelog und Server statt "Lade …"
- Team-Audit-Log: audit_log-Tabelle (max 500 Einträge), Logging bei
  Settings/Composer/Vorlagen/Rollen-Menüs/Gameservern/Team/API-Keys/
  Branding, GET /api/auditlog (Owner) + Liste im Team-Tab
- Giveaway: 🔁 Reroll-Button (Admin-only) + 👥 Teilnehmerliste am
  Gewinner-Post

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 22:15:58 +02:00
co-authored by Claude Fable 5
parent 8d470ba26b
commit be11398f7c
17 changed files with 671 additions and 28 deletions
+114
View File
@@ -0,0 +1,114 @@
// Öffentliche Server-Status-Seite: Live-Status + 24h-Spielerzahl-Verlauf
// (Daten sammelt der Server-Monitor alle 2 Minuten).
import { useEffect, useState } from 'react';
import { apiGet } from '../api.js';
import { SkeletonCards } from '../components/Skeleton.jsx';
const timeFmt = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit' });
/** 24h-Verlauf als SVG-Fläche; Offline-Samples als rote Punkte auf der Nulllinie */
function HistoryChart({ history, max }) {
if (history.length < 2) return null;
const W = 600, H = 90, PAD = 4;
const peak = Math.max(max ?? 0, 1, ...history.map((h) => h.players ?? 0));
const x = (i) => PAD + (i / (history.length - 1)) * (W - PAD * 2);
const y = (p) => H - PAD - ((p ?? 0) / peak) * (H - PAD * 2);
const points = history.map((h, i) => `${x(i).toFixed(1)},${y(h.online ? h.players : 0).toFixed(1)}`);
const area = `${PAD},${H - PAD} ${points.join(' ')} ${W - PAD},${H - PAD}`;
const offline = history.map((h, i) => (h.online ? null : i)).filter((i) => i !== null);
return (
<svg className="srv-chart" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="srvFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--neon)" stopOpacity=".35" />
<stop offset="100%" stopColor="var(--neon)" stopOpacity="0" />
</linearGradient>
</defs>
<polygon points={area} fill="url(#srvFill)" />
<polyline points={points.join(' ')} fill="none" stroke="var(--neon)" strokeWidth="1.5" />
{offline.map((i) => (
<circle key={i} cx={x(i)} cy={H - PAD} r="2" fill="#f23f43" />
))}
</svg>
);
}
function ServerCard({ s }) {
const pct = s.online && s.max ? Math.round((s.players / s.max) * 100) : 0;
return (
<article className={`card srv-card ${s.online === false ? 'srv-down' : ''}`}>
<div className="srv-head">
<span className="srv-name">{s.icon} {s.name}</span>
<span className={`srv-status ${s.online ? 'on' : s.online === false ? 'off' : ''}`}>
{s.online === null ? '⏳ Warte auf Check' : s.online ? '🟢 Online' : '🔴 Offline'}
{s.ping != null && s.online && <span className="srv-ping"> · {s.ping}ms</span>}
</span>
</div>
{s.online && s.players != null && s.max > 0 && (
<>
<div className="xp-bar srv-bar">
<div className="xp-fill" style={{ width: `${Math.min(100, pct)}%` }} />
</div>
<p className="srv-players">
{s.players} / {s.max} Spieler ({pct}%)
{s.map && <span className="srv-map"> · 🗺 {s.map}</span>}
</p>
</>
)}
{s.history.length > 1 && <HistoryChart history={s.history} max={s.max} />}
{s.history.length > 1 && <p className="srv-chart-label">Spielerzahl · letzte 24h</p>}
{(s.connect_url || s.address) && (
<div className="srv-connect">
{s.connect_url && (
<a className="btn btn-save" href={s.connect_url}>Connect</a>
)}
{s.address && <code className="alpha-key srv-addr">{s.address}</code>}
</div>
)}
{s.checkedAt && (
<p className="srv-checked">Zuletzt geprüft: {timeFmt.format(new Date(s.checkedAt))} Uhr</p>
)}
</article>
);
}
export default function Server() {
const [servers, setServers] = useState(null);
const [error, setError] = useState(false);
useEffect(() => {
const load = () => apiGet('/api/servers').then((d) => setServers(d.servers)).catch(() => setError(true));
load();
const timer = setInterval(load, 60_000);
window.scrollTo(0, 0);
return () => clearInterval(timer);
}, []);
return (
<>
<section className="page-header">
<div className="page-bg-text">SERVER</div>
<div className="page-header-grid" aria-hidden="true" />
<div className="page-header-content">
<div className="page-tag">// Live-Status</div>
<h1 className="page-title">Server</h1>
<p className="page-subtitle">
Alle Game-Server auf einen Blick aktualisiert alle 2 Minuten.
</p>
</div>
</section>
<section className="content">
{error && <p className="notice">Server-Status konnte nicht geladen werden.</p>}
{!error && !servers && <SkeletonCards n={2} />}
{servers?.length === 0 && <p className="notice">Noch keine Server eingetragen.</p>}
{servers?.map((s) => <ServerCard s={s} key={s.id} />)}
</section>
</>
);
}