Files
Cs2-Mapveto/client/src/pages/AdminPage.jsx
T
SPP UserandClaude Haiku 5.5 330048acfc Admin-Modus: Verwaltung aller Matches unter /admin
Anmeldung mit ADMIN_PASSWORD (12 h, Sitzung nur im Speicher, Sperre nach
5 Fehlversuchen pro Minute). Alle Matches mit Codes, Servern, Spielern,
Veto und Verlauf. Aktionen: Veto starten bzw. Schritte für beide Teams,
Warmup beenden, nächste Map, Server stoppen/neu starten, abschließen,
abbrechen, Spieler entfernen, Codes erneuern, Match löschen.
Ohne ADMIN_PASSWORD ist der Admin-Modus komplett aus.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 23:16:44 +01:00

249 lines
9.7 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../lib/api.js';
import { FORMAT_LABEL, serverLabel } from '../lib/labels.js';
import CopyField from '../components/CopyField.jsx';
const KEY = 'cs2mv:admin';
const REFRESH_MS = 3000;
const getToken = () => {
try {
return sessionStorage.getItem(KEY);
} catch {
return null;
}
};
const storeToken = (t) => {
try {
if (t) sessionStorage.setItem(KEY, t);
else sessionStorage.removeItem(KEY);
} catch {
/* ignorieren */
}
};
export default function AdminPage() {
const [token, setToken] = useState(getToken);
const [matches, setMatches] = useState(null);
const [error, setError] = useState(null);
const [notice, setNotice] = useState(null);
const [busy, setBusy] = useState(null);
const logout = useCallback(() => {
api('/admin/logout', { method: 'POST', token }).catch(() => {});
storeToken(null);
setToken(null);
setMatches(null);
}, [token]);
const load = useCallback(async () => {
try {
const res = await api('/admin/matches', { token });
setMatches(res.matches);
setError(null);
} catch (err) {
if (err.status === 401) {
storeToken(null);
setToken(null);
} else {
setError(err.message);
}
}
}, [token]);
useEffect(() => {
if (!token) return undefined;
load();
const t = setInterval(load, REFRESH_MS);
return () => clearInterval(t);
}, [token, load]);
useEffect(() => {
if (!notice) return undefined;
const t = setTimeout(() => setNotice(null), 5000);
return () => clearTimeout(t);
}, [notice]);
if (!token) return <AdminLogin onLogin={(t) => { storeToken(t); setToken(t); }} />;
/** Aktion ausführen; destruktive Aktionen müssen bestätigt werden. */
const run = async ({ method = 'POST', path, body, confirm: question, success }) => {
if (question && !window.confirm(question)) return;
setBusy(path);
try {
await api(`/admin${path}`, { method, body, token });
setNotice({ tone: 'ok', text: success ?? 'Erledigt' });
load();
} catch (err) {
setNotice({ tone: 'error', text: err.message });
} finally {
setBusy(null);
}
};
return (
<div className="admin">
<header className="admin-head">
<div>
<h1>Admin</h1>
<p className="muted small">Alle Matches, Server und Teams. Aktualisiert sich alle 3 Sekunden.</p>
</div>
<button className="btn btn-sm" onClick={logout}>Abmelden</button>
</header>
{notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>}
{error && <div className="alert alert-error">{error}</div>}
{matches === null && !error && <p className="muted">Lade Matches …</p>}
{matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>}
<div className="admin-list">
{matches?.map((m) => (
<MatchAdminCard key={m.id} m={m} run={run} busy={busy} />
))}
</div>
</div>
);
}
function AdminLogin({ onLogin }) {
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const submit = async (e) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
const res = await api('/admin/login', { method: 'POST', body: { password } });
onLogin(res.token);
} catch (err) {
setError(err.message);
setBusy(false);
}
};
return (
<form className="create admin-login" onSubmit={submit}>
<h1>Admin-Anmeldung</h1>
<p className="lead">Das Passwort ist in der Konfiguration unter <code>ADMIN_PASSWORD</code> hinterlegt.</p>
<label className="field">
<span>Passwort</span>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus autoComplete="current-password" required />
</label>
{error && <div className="alert alert-error">{error}</div>}
<button className="btn btn-primary btn-lg" disabled={busy}>{busy ? 'Melde an …' : 'Anmelden'}</button>
<p className="small muted"><Link to="/">Zur Übersicht</Link></p>
</form>
);
}
function MatchAdminCard({ m, run, busy }) {
const [open, setOpen] = useState(false);
const server = serverLabel(m.server);
const closed = ['finished', 'cancelled'].includes(m.status);
const id = m.id;
const btn = (label, cfg, cls = '') => (
<button className={`btn btn-sm ${cls}`} disabled={!!busy} onClick={() => run({ ...cfg, path: `/matches/${id}${cfg.path}` })}>
{label}
</button>
);
const hasNext = m.series.some((s) => s.number === m.currentMapNumber + 1);
return (
<section className={`panel admin-card status-${m.status}`}>
<div className="admin-card-head">
<div>
<strong className="team-a">{m.teams.A.name}</strong>
<span className="vs"> vs. </span>
<strong className="team-b">{m.teams.B.name}</strong>
<div className="small muted">
{m.modeLabel}, {FORMAT_LABEL[m.format]} · {m.statusLabel} · <span className={`tone-${server.tone}`}>{server.text}</span>
{m.format !== 'bo1' && <> · Serie {m.seriesScore.A}:{m.seriesScore.B}</>}
</div>
</div>
<button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button>
</div>
<div className="admin-actions">
<Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link>
{m.status === 'lobby' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')}
{(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })}
{(m.server?.status === 'configured') && hasNext && btn('Nächste Map', { path: '/series/next' })}
{m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed &&
btn('Server neu starten / wiederholen', { path: '/server/retry' })}
{m.server && !['stopped'].includes(m.server.status) && btn('Server stoppen', { path: '/server/stop', confirm: 'Server stoppen?' })}
{!closed && btn('Match abschließen', { path: '/finish', confirm: 'Match als abgeschlossen markieren?' })}
{!closed && btn('Match abbrechen', { path: '/cancel', confirm: 'Match abbrechen?' }, 'btn-ghost danger')}
{btn('Löschen', { method: 'DELETE', path: '', confirm: `Match ${m.teams.A.name} vs. ${m.teams.B.name} endgültig löschen?`, success: 'Match gelöscht' }, 'btn-ghost danger')}
</div>
{open && (
<div className="admin-details">
<div className="admin-grid">
<div>
<h3>Einladungen</h3>
<div className="small muted">Team A</div>
<CopyField value={m.organizer.links.A} />
<div className="small muted">Team B</div>
<CopyField value={m.organizer.links.B} />
<div className="small muted">Zuschauer</div>
<CopyField value={m.organizer.links.spectator} />
<div className="small muted">Stream-Ansicht</div>
<CopyField value={m.organizer.links.stream} />
<div className="row">
{['A', 'B', 'spectator'].map((t) => (
<button key={t} className="link-btn" disabled={!!busy}
onClick={() => run({ path: `/matches/${id}/codes/regenerate`, body: { target: t }, confirm: 'Neuer Code? Der alte Link wird ungültig.', success: 'Neuer Code erzeugt' })}>
Code {t === 'spectator' ? 'Zuschauer' : t} erneuern
</button>
))}
</div>
</div>
<div>
<h3>Server</h3>
{m.server?.connect && <CopyField label="Spieler-Verbindung" value={m.server.connect.command} />}
{m.server?.gotv && <CopyField label="CSTV" value={m.server.gotv.command} />}
<p className="small muted">{m.server?.message ?? 'kein Server'}</p>
<p className="small muted">Versuche: {m.server?.attempts ?? 0} · Spieler auf dem Server: {m.server?.playerCount ?? '–'}</p>
</div>
</div>
<h3>Spieler</h3>
<div className="admin-players">
{['A', 'B'].map((t) => (
<div key={t}>
<div className="small muted">{m.teams[t].name} ({m.teams[t].readiness.count}/{m.teams[t].teamSize})</div>
<ul>
{m.teams[t].players.map((p) => (
<li key={p.id}>
{p.nickname} {p.isCaptain && <em>Captain</em>} {p.ready && <span className="tone-ok">bereit</span>}
{!closed && (
<button className="link-btn danger" disabled={!!busy}
onClick={() => run({ path: `/matches/${id}/kick`, body: { playerId: p.id }, confirm: `${p.nickname} entfernen?`, success: `${p.nickname} entfernt` })}>
Entfernen
</button>
)}
</li>
))}
{m.teams[t].players.length === 0 && <li className="muted">niemand</li>}
</ul>
</div>
))}
</div>
{m.veto && !m.veto.done && m.veto.current && (
<p className="small">Veto: {m.teams[m.veto.current.team].name} ist am Zug ({m.veto.current.type}).</p>
)}
<h3>Verlauf</h3>
<ol className="admin-log">
{m.events.slice(0, 15).map((e, i) => (
<li key={i}><time>{new Date(e.at).toLocaleTimeString('de-DE')}</time> {e.message}</li>
))}
</ol>
</div>
)}
</section>
);
}