Meldungen zu Veto, Match-Start, Map- und Serienende, Matchende und Serverfehlern gehen an DISCORD_WEBHOOK_URL. Im Admin-Bereich wählt man, welche Ereignisse gemeldet werden, und kann eine Testmeldung senden. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
372 lines
14 KiB
React
372 lines
14 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>
|
||
|
||
{token && <NotificationSettings token={token} setNotice={setNotice} />}
|
||
{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} token={token} reload={load} setNotice={setNotice} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Auswahl, welche Ereignisse Discord erhalten. Die Werte werden erst beim Speichern übernommen. */
|
||
function NotificationSettings({ token, setNotice }) {
|
||
const [settings, setSettings] = useState(null);
|
||
const [events, setEvents] = useState({});
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
useEffect(() => {
|
||
api('/admin/notifications', { token })
|
||
.then((res) => { setSettings(res); setEvents(res.events); })
|
||
.catch((err) => setNotice({ tone: 'error', text: err.message }));
|
||
}, [token, setNotice]);
|
||
|
||
const save = async () => {
|
||
setBusy(true);
|
||
try {
|
||
const res = await api('/admin/notifications', { method: 'PUT', body: { events }, token });
|
||
setSettings(res);
|
||
setEvents(res.events);
|
||
setNotice({ tone: 'ok', text: 'Meldungen gespeichert' });
|
||
} catch (err) {
|
||
setNotice({ tone: 'error', text: err.message });
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const test = async () => {
|
||
setBusy(true);
|
||
try {
|
||
await api('/admin/notifications/test', { method: 'POST', token });
|
||
setNotice({ tone: 'ok', text: 'Testmeldung gesendet' });
|
||
} catch (err) {
|
||
setNotice({ tone: 'error', text: err.message });
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
if (!settings) return null;
|
||
const dirty = settings.catalog.some(({ key }) => !!events[key] !== settings.events[key]);
|
||
|
||
return (
|
||
<section className="admin-settings">
|
||
<h2>Discord-Meldungen</h2>
|
||
{!settings.enabled && (
|
||
<p className="alert alert-error">Kein Webhook eingerichtet. Setze DISCORD_WEBHOOK_URL und starte die Anwendung neu.</p>
|
||
)}
|
||
<div className="admin-settings-grid">
|
||
{settings.catalog.map(({ key, label }) => (
|
||
<label key={key} className="admin-check">
|
||
<input
|
||
type="checkbox"
|
||
checked={!!events[key]}
|
||
onChange={(e) => setEvents({ ...events, [key]: e.target.checked })}
|
||
/>
|
||
{label}
|
||
</label>
|
||
))}
|
||
</div>
|
||
<div className="admin-actions">
|
||
<button className="btn btn-sm btn-primary" onClick={save} disabled={busy || !dirty}>Speichern</button>
|
||
<button className="btn btn-sm" onClick={test} disabled={busy || !settings.enabled}>Testmeldung senden</button>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
/** Rollback: nur auf der Admin-Seite. Lädt die Sicherungen der aktuellen Map und spielt eine davon zurück. */
|
||
function RollbackPanel({ matchId, token, reload, setNotice }) {
|
||
const [points, setPoints] = useState(null);
|
||
const [file, setFile] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const load = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await api(`/admin/matches/${matchId}/rollback-points`, { token });
|
||
setPoints(res.points);
|
||
setFile(res.points[0]?.file ?? '');
|
||
} catch (err) {
|
||
setNotice({ tone: 'error', text: err.message });
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const rollback = async () => {
|
||
const p = points.find((x) => x.file === file);
|
||
if (!p || !window.confirm(`Match auf den Stand nach Runde ${p.round} zurückspielen? Spätere Runden dieser Map gehen verloren.`)) return;
|
||
try {
|
||
await api(`/admin/matches/${matchId}/rollback`, { method: 'POST', body: { file }, token });
|
||
setNotice({ tone: 'ok', text: `Zurückgespielt auf Runde ${p.round}` });
|
||
setPoints(null);
|
||
reload();
|
||
} catch (err) {
|
||
setNotice({ tone: 'error', text: err.message });
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="rollback">
|
||
<h3>Runde zurückspielen <span className="admin-badge">nur Admin</span></h3>
|
||
{!points && <button className="btn btn-sm" onClick={load} disabled={loading}>{loading ? 'Lade …' : 'Sicherungen laden'}</button>}
|
||
{points && points.length === 0 && <p className="small muted">Keine Sicherungen für diese Map gefunden.</p>}
|
||
{points && points.length > 0 && (
|
||
<div className="row">
|
||
<select value={file} onChange={(e) => setFile(e.target.value)}>
|
||
{points.map((p) => <option key={p.file} value={p.file}>Nach Runde {p.round}</option>)}
|
||
</select>
|
||
<button className="btn btn-sm btn-danger" onClick={rollback}>Zurückspielen</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
||
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') && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' })}
|
||
{(m.server?.status === 'configured') && btn('Fortsetzen', { path: '/server/resume' })}
|
||
{(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.status === 'live' && m.server?.status === 'configured' && (
|
||
<RollbackPanel matchId={id} token={token} reload={reload} setNotice={setNotice} />
|
||
)}
|
||
|
||
{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>
|
||
);
|
||
}
|