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:
@@ -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 &&
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user