Frontend, README und Konfigurationsvorlage

React-Oberfläche mit Übersicht, Match-Erstellung, Beitritt über Team- und
Zuschauercodes, Teamkarten, visuellem Map-Veto, Serie und Serverpanel.
README beschreibt Einrichtung, Umgebungsvariablen, Rollen, Veto-Abläufe,
Serveradapter und CSTV-Zuschauerzugang.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SPP UserandClaude Opus 5.5 committed 2026-10-25 17:02:18 +01:00
1 parent 104aa1a67b
commit 568ccf9b09
24 files changed
+1938 -1

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
import { useCallback, useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { api } from '../lib/api.js';
import { FORMAT_LABEL, ROLE_LABEL, serverLabel } from '../lib/labels.js';
import { clearAccess, getToken, setAccess } from '../lib/session.js';
import { useMatch } from '../lib/useMatch.js';
import TeamCard from '../components/TeamCard.jsx';
import VetoBoard from '../components/VetoBoard.jsx';
import ServerPanel from '../components/ServerPanel.jsx';
import OrganizerPanel from '../components/OrganizerPanel.jsx';
import SeriesList from '../components/SeriesList.jsx';
import EventLog from '../components/EventLog.jsx';
export default function MatchPage() {
const { id } = useParams();
const [token, setToken] = useState(() => {
// Organisator-Link für ein weiteres Gerät: /m/<id>#org=<token>
const hash = new URLSearchParams(window.location.hash.slice(1));
const org = hash.get('org');
if (org) {
setAccess(id, { token: org, kind: 'organizer' });
window.history.replaceState(null, '', window.location.pathname);
}
return getToken(id);
});
const { state, error, connected } = useMatch(id, token);
const [busy, setBusy] = useState(null);
const [toast, setToast] = useState(null);
// Gespeicherter Zugang ist ungültig geworden (z. B. aus dem Team entfernt)
const lostAccess = state && token && state.viewer.role === 'public';
useEffect(() => {
if (lostAccess) {
clearAccess(id);
setToken(null);
setToast({ tone: 'error', text: 'Dein gespeicherter Zugang ist nicht mehr gültig. Tritt mit deinem Teamcode erneut bei.' });
}
}, [lostAccess, id]);
useEffect(() => {
if (!toast) return undefined;
const t = setTimeout(() => setToast(null), 6000);
return () => clearTimeout(t);
}, [toast]);
/** Führt eine Aktion aus; verhindert Doppelklicks, solange sie läuft. */
const act = useCallback(async (path, body, success) => {
if (busy) return;
setBusy(path);
try {
await api(`/matches/${id}/${path}`, { method: 'POST', body: body ?? {}, token });
if (success) setToast({ tone: 'ok', text: success });
} catch (err) {
setToast({ tone: 'error', text: err.message });
} finally {
setBusy(null);
}
}, [busy, id, token]);
if (error && !state) {
return (
<div className="empty">
<h2>{error.code === 'not_found' ? 'Match nicht gefunden' : 'Fehler'}</h2>
<p className="muted">{error.error}</p>
<Link to="/" className="btn">Zur Übersicht</Link>
</div>
);
}
if (!state) return <p className="muted">Verbinde mit Lobby …</p>;
const { viewer } = state;
const isOrg = viewer.role === 'organizer';
const server = serverLabel(state.server);
const ctx = { state, act, busy, isOrg, viewer };
return (
<div className="match">
{!connected && <div className="reconnect">Verbindung unterbrochen – stelle wieder her …</div>}
{toast && <div className={`toast toast-${toast.tone}`} role="status">{toast.text}</div>}
<header className="match-head">
<div className="versus">
<h1>
<span className="team-a">{state.teams.A.name}</span>
<span className="vs">vs.</span>
<span className="team-b">{state.teams.B.name}</span>
</h1>
</div>
<dl className="match-facts">
<div><dt>Modus</dt><dd>{state.modeLabel}</dd></div>
<div><dt>Serie</dt><dd>{FORMAT_LABEL[state.format]}</dd></div>
<div><dt>Status</dt><dd>{state.statusLabel}</dd></div>
<div><dt>Server</dt><dd className={`tone-${server.tone}`}>{server.text}</dd></div>
<div><dt>Du bist</dt><dd>{ROLE_LABEL[viewer.role]}{viewer.team && `, ${state.teams[viewer.team].name}`}</dd></div>
</dl>
</header>
{viewer.role === 'public' && <PublicNotice />}
<div className="match-grid">
<TeamCard team={state.teams.A} side="A" {...ctx} />
<section className="stage">
<Stage {...ctx} />
</section>
<TeamCard team={state.teams.B} side="B" {...ctx} />
</div>
{viewer.role !== 'public' && (
<div className="match-lower">
<ServerPanel {...ctx} />
{isOrg && <OrganizerPanel {...ctx} matchId={id} token={token} />}
<EventLog events={state.events} />
</div>
)}
</div>
);
}
function Stage(ctx) {
const { state, isOrg, act, busy } = ctx;
if (state.viewer.role === 'public') {
return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Details sehen nur Teilnehmer.</p></div>;
}
if (state.status === 'lobby') {
const { A, B } = state.teams;
const missing = [A, B].filter((t) => !t.readiness.ready);
return (
<div className="stage-note">
<h2>Warten auf beide Teams</h2>
<p className="muted">
{missing.length === 0
? 'Beide Teams sind bereit.'
: missing.map((t) => `${t.name}: ${t.readiness.count}/${t.teamSize} Spieler, ${t.readiness.allReady ? 'alle bereit' : 'noch nicht alle bereit'}`).join(' – ')}
</p>
{isOrg ? (
<button className="btn btn-primary btn-lg" disabled={!state.canStartVeto || !!busy}
onClick={() => act('veto/start', null, 'Veto gestartet')}>
Veto starten
</button>
) : (
<p className="small muted">Der Organisator startet das Veto, sobald beide Teams bereit sind.</p>
)}
<MapPoolPreview maps={state.maps} />
</div>
);
}
if (state.status === 'veto') return <VetoBoard {...ctx} />;
return (
<>
<SeriesList {...ctx} />
<VetoBoard {...ctx} compact />
</>
);
}
function MapPoolPreview({ maps }) {
return (
<div className="pool-preview" aria-label="Map-Pool">
{maps.map((m) => (
<span key={m.id} className="pool-chip" style={{ '--map': m.color ?? '#556' }}>{m.name}</span>
))}
</div>
);
}
function PublicNotice() {
return (
<div className="alert">
Du siehst diese Lobby ohne Zugang. Spieler treten über den Einladungslink ihres Teams bei,
Zuschauer über den Zuschauerlink. Beides bekommst du vom Organisator.
</div>
);
}