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.
This commit is contained in:
1 parent
f08297dd0d
commit
f7520a6ca7
24 files changed
+1938
-1
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user