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>
249 lines
9.7 KiB
React
249 lines
9.7 KiB
React
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>
|
||
);
|
||
}
|