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
+2 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { apiGet } from '../api.js';
import { SkeletonCards } from '../components/Skeleton.jsx';
import { Markdown } from '../markdown.jsx';
const dateFmt = new Intl.DateTimeFormat('de-DE', {
@@ -33,7 +34,7 @@ export default function Changelog() {
<section className="content">
{error && <p className="notice">Changelog konnte nicht geladen werden.</p>}
{!error && !data && <p className="notice">Lade </p>}
{!error && !data && <SkeletonCards n={3} />}
{data?.total === 0 && <p className="notice">Noch keine Releases veröffentlicht.</p>}
{data?.total > 0 &&
+20 -3
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useState } from 'react';
import { apiGet, apiDelete } from '../api.js';
import { Markdown } from '../markdown.jsx';
import Lightbox from '../components/Lightbox.jsx';
import { SkeletonCards } from '../components/Skeleton.jsx';
const dateFmt = new Intl.DateTimeFormat('de-DE', {
weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric',
@@ -46,6 +48,7 @@ export default function Devlogs({ me }) {
const [error, setError] = useState(false);
const [qInput, setQInput] = useState('');
const [q, setQ] = useState('');
const [lightbox, setLightbox] = useState(null); // { images, start }
// Sucheingabe entprellen (300 ms), bei neuer Suche zurück auf Seite 1
useEffect(() => {
@@ -110,7 +113,7 @@ export default function Devlogs({ me }) {
</div>
{error && <p className="notice">Devlogs konnten nicht geladen werden.</p>}
{!error && !data && <p className="notice">Lade </p>}
{!error && !data && <SkeletonCards n={3} />}
{data?.total === 0 && (
<p className="notice">{q ? `Keine Treffer für „${q}".` : 'Noch keine Devlogs archiviert.'}</p>
)}
@@ -147,8 +150,14 @@ export default function Devlogs({ me }) {
</div>
{d.images?.length > 0 && (
<div className={`card-images n${Math.min(d.images.length, 4)}`}>
{d.images.map((src) => (
<a key={src} href={src} target="_blank" rel="noreferrer">
{d.images.map((src, i) => (
<a
key={src} href={src}
onClick={(e) => {
e.preventDefault();
setLightbox({ images: d.images, start: i });
}}
>
<img src={src} alt="Devlog-Screenshot" loading="lazy" />
</a>
))}
@@ -166,6 +175,14 @@ export default function Devlogs({ me }) {
</div>
)}
{lightbox && (
<Lightbox
images={lightbox.images}
start={lightbox.start}
onClose={() => setLightbox(null)}
/>
)}
{pages > 1 && (
<div className="pager">
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
+20 -3
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react';
import { apiGet } from '../api.js';
import Lightbox from '../components/Lightbox.jsx';
import { SkeletonGrid } from '../components/Skeleton.jsx';
const dateFmt = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
@@ -7,6 +9,7 @@ export default function Galerie() {
const [data, setData] = useState(null);
const [page, setPage] = useState(1);
const [error, setError] = useState(false);
const [lightbox, setLightbox] = useState(null); // Start-Index oder null
useEffect(() => {
setError(false);
@@ -33,12 +36,15 @@ export default function Galerie() {
<section className="content">
{error && <p className="notice">Galerie konnte nicht geladen werden.</p>}
{!error && !data && <p className="notice">Lade </p>}
{!error && !data && <SkeletonGrid n={9} />}
{data?.total === 0 && <p className="notice">Noch keine Screenshots poste welche im Discord!</p>}
<div className="gallery-grid">
{shots.map((s) => (
<a className="shot" href={s.src} target="_blank" rel="noreferrer" key={s.key}>
{shots.map((s, i) => (
<a
className="shot" href={s.src} key={s.key}
onClick={(e) => { e.preventDefault(); setLightbox(i); }}
>
<img src={s.src} alt={`Screenshot von ${s.author}`} loading="lazy" />
<span className="shot-meta">
<span className="shot-author">{s.author}</span>
@@ -48,6 +54,17 @@ export default function Galerie() {
))}
</div>
{lightbox !== null && (
<Lightbox
images={shots.map((s) => ({
src: s.src,
caption: `${s.author} · ${dateFmt.format(new Date(s.date))}`,
}))}
start={lightbox}
onClose={() => setLightbox(null)}
/>
)}
{pages > 1 && (
<div className="pager">
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
+2 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { apiGet } from '../api.js';
import { SkeletonRows } from '../components/Skeleton.jsx';
/** Muss der Formel in src/bot/levels.js entsprechen */
function xpForLevel(level) {
@@ -64,7 +65,7 @@ export default function Level() {
<section className="content">
{error && <p className="notice">Bestenliste konnte nicht geladen werden.</p>}
{!error && !rows && <p className="notice">Lade …</p>}
{!error && !rows && <SkeletonRows n={10} />}
{rows?.length === 0 && <p className="notice">Noch keine XP vergeben — schreibt was im Discord! ✍️</p>}
{rows?.length > 0 && (
+2 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { apiGet, apiPost } from '../api.js';
import { Markdown } from '../markdown.jsx';
import { SkeletonCards } from '../components/Skeleton.jsx';
const dueFmt = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' });
@@ -136,7 +137,7 @@ export default function Roadmap() {
<section className="content">
{error && <p className="notice">Roadmap konnte nicht geladen werden.</p>}
{!error && !data && <p className="notice">Lade </p>}
{!error && !data && <SkeletonCards n={3} />}
{data?.milestones.length === 0 && (
<p className="notice">Noch keine Meilensteine angelegt.</p>
)}
+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>
</>
);
}
+21
View File
@@ -160,6 +160,7 @@ export default function Settings({ me }) {
// Bot-Beschreibung + Team
const [botDesc, setBotDesc] = useState('');
const [team, setTeam] = useState([]);
const [audit, setAudit] = useState([]);
const [teamDraft, setTeamDraft] = useState({ user_id: '', scopes: [] });
const canTab = (id) => {
@@ -201,6 +202,7 @@ export default function Settings({ me }) {
apiGet('/api/triggers').then((d) => setTriggers(d.triggers)).catch(() => {});
apiGet('/api/gameservers').then((d) => setServers(d.servers)).catch(() => {});
apiGet('/api/webadmins').then((d) => setTeam(d.admins)).catch(() => {});
apiGet('/api/auditlog').then((d) => setAudit(d.entries)).catch(() => {});
}, [me.admin, me.scopes?.length]);
useEffect(() => {
@@ -1465,6 +1467,25 @@ export default function Settings({ me }) {
))}
</div>
</div>
{audit.length > 0 && (
<>
<h2 className="settings-title" style={{ marginTop: '2rem' }}>// Audit-Log</h2>
<p className="field-hint" style={{ marginBottom: '.8rem' }}>
Die letzten Aktionen im Webinterface (max. 500 werden aufbewahrt).
</p>
<div className="key-list audit-list">
{audit.map((a) => (
<div className="key-row" key={a.id}>
<span className="audit-ts">{a.ts.slice(0, 16).replace('T', ' ')}</span>
<span className="key-name">{a.username}</span>
<span className="audit-action">{a.action}</span>
<span className="key-used audit-detail" title={a.detail}>{a.detail}</span>
</div>
))}
</div>
</>
)}
</div>
);