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
+61
View File
@@ -0,0 +1,61 @@
// Bild-Overlay mit Blättern (Pfeiltasten, Buttons, Wischen) statt neuem Tab.
import { useCallback, useEffect, useState } from 'react';
export default function Lightbox({ images, start = 0, onClose }) {
const [index, setIndex] = useState(start);
const [touchX, setTouchX] = useState(null);
const prev = useCallback(() => setIndex((i) => (i - 1 + images.length) % images.length), [images.length]);
const next = useCallback(() => setIndex((i) => (i + 1) % images.length), [images.length]);
useEffect(() => {
function onKey(e) {
if (e.key === 'Escape') onClose();
else if (e.key === 'ArrowLeft') prev();
else if (e.key === 'ArrowRight') next();
}
document.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
};
}, [onClose, prev, next]);
const img = images[index];
if (!img) return null;
return (
<div
className="lightbox"
onClick={onClose}
onTouchStart={(e) => setTouchX(e.touches[0].clientX)}
onTouchEnd={(e) => {
if (touchX === null) return;
const dx = e.changedTouches[0].clientX - touchX;
if (dx > 50) prev();
else if (dx < -50) next();
setTouchX(null);
}}
role="dialog" aria-modal="true"
>
<button className="lb-close" onClick={onClose} title="Schließen (Esc)"></button>
{images.length > 1 && (
<button className="lb-nav lb-prev" onClick={(e) => { e.stopPropagation(); prev(); }} title="Zurück (←)"></button>
)}
<img
className="lb-img"
src={img.src ?? img}
alt={img.alt ?? ''}
onClick={(e) => e.stopPropagation()}
/>
{images.length > 1 && (
<button className="lb-nav lb-next" onClick={(e) => { e.stopPropagation(); next(); }} title="Weiter (→)"></button>
)}
<div className="lb-meta" onClick={(e) => e.stopPropagation()}>
{img.caption && <span>{img.caption}</span>}
{images.length > 1 && <span className="lb-count">{index + 1} / {images.length}</span>}
</div>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
// Schimmernde Lade-Platzhalter statt „Lade …"-Text
/** n Karten-Platzhalter (Devlogs, Roadmap, Changelog, Server …) */
export function SkeletonCards({ n = 3 }) {
return (
<div aria-hidden="true">
{Array.from({ length: n }, (_, i) => (
<div className="card skel-card" key={i} style={{ animationDelay: `${i * 0.08}s` }}>
<div className="skel skel-line" style={{ width: '38%' }} />
<div className="skel skel-line" style={{ width: '92%' }} />
<div className="skel skel-line" style={{ width: '75%' }} />
</div>
))}
</div>
);
}
/** Bild-Raster-Platzhalter (Galerie) */
export function SkeletonGrid({ n = 6 }) {
return (
<div className="gallery-grid" aria-hidden="true">
{Array.from({ length: n }, (_, i) => (
<div className="skel skel-shot" key={i} style={{ animationDelay: `${i * 0.06}s` }} />
))}
</div>
);
}
/** Zeilen-Platzhalter (Level-Liste u. Ä.) */
export function SkeletonRows({ n = 8 }) {
return (
<div aria-hidden="true">
{Array.from({ length: n }, (_, i) => (
<div className="skel skel-row" key={i} style={{ animationDelay: `${i * 0.05}s` }} />
))}
</div>
);
}