Files
Cs2-Mapveto/client/src/pages/MatchPage.jsx
T
mregosar 263c7e7b7c Live-Ergebnisse: Stream-Seite, Anzeige, automatischer Mapwechsel
- Stream-Übersicht unter /stream/<Zuschauercode> (1920x1080, Lobby, Veto, Spielstand)
- Live-Stand, Rundenverlauf und Serienstand auf Match-Seite und Übersicht
- Parser mit echten AMP-Logs geprüft: Game Over mit leerer Map-Gruppe,
  Match_Start vor der Aufwärmphase, veraltete Seitenzuordnung nach der Halbzeit
- Nach Map-Ende automatisch nächste Map bzw. Abschluss der entschiedenen Serie
- LOG_DEBUG zur Fehlersuche, README und .env.example ergänzt
2026-10-25 19:20:10 +01:00

239 lines
9.0 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, useParams } from 'react-router-dom';
import { api } from '../lib/api.js';
import { FORMAT_LABEL, ROLE_LABEL, serverLabel } from '../lib/labels.js';
import { clearAccess, getPlayerToken, getToken, setAccess, setPlayerToken } 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';
import MapImage from '../components/MapImage.jsx';
import SelfJoin from '../components/SelfJoin.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);
});
// Spieler-Token des Organisators, wenn er im selben Browser selbst mitspielt
const [playerToken, setPlayerTokenState] = useState(() => getPlayerToken(id));
const { state, error, connected } = useMatch(id, token, playerToken);
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]);
// Organisator war Spieler, ist es aber nicht mehr (Team verlassen oder entfernt)
// justJoined: bis der erste Zustand mit Spieler ankommt, ist ein Zustand ohne Spieler noch veraltet
const [justJoined, setJustJoined] = useState(false);
useEffect(() => {
if (state?.viewer.playerId) setJustJoined(false);
}, [state]);
const lostPlayer = state && playerToken && !justJoined && state.viewer.isOrganizer && !state.viewer.playerId;
useEffect(() => {
if (lostPlayer) {
setPlayerToken(id, null);
setPlayerTokenState(null);
}
}, [lostPlayer, id]);
const joinAsOrganizer = useCallback((newPlayerToken) => {
setJustJoined(true);
setPlayerToken(id, newPlayerToken);
setPlayerTokenState(newPlayerToken);
}, [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, playerToken });
if (success) setToast({ tone: 'ok', text: success });
} catch (err) {
setToast({ tone: 'error', text: err.message });
} finally {
setBusy(null);
}
}, [busy, id, token, playerToken]);
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.isOrganizer;
const server = serverLabel(state.server);
const ctx = { state, act, busy, isOrg, viewer, matchId: id, onJoined: joinAsOrganizer };
return (
<div className="match">
{connected === false && <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>
{state.series.length > 1 ? (
<span className="series-total" title="Serienstand (gewonnene Maps)">
{state.seriesScore.A}<span className="colon">:</span>{state.seriesScore.B}
</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>{describeViewer(viewer, state.teams)}</dd></div>
</dl>
</header>
{viewer.role === 'public' && <PublicNotice />}
<div className={`match-grid ${state.status !== 'lobby' && viewer.role !== 'public' ? 'stage-wide' : ''}`}>
<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} token={token} />}
<EventLog events={state.events} />
</div>
)}
</div>
);
}
/** z. B. "Organisator, Captain bei Nordlicht" oder "Spieler bei Hafenkante" */
function describeViewer(viewer, teams) {
const team = viewer.team ? teams[viewer.team].name : null;
const asPlayer = team ? `${viewer.isCaptain ? 'Captain' : 'Spieler'} bei ${team}` : null;
if (viewer.isOrganizer) return asPlayer ? `Organisator, ${asPlayer}` : 'Organisator';
return asPlayer ?? ROLE_LABEL[viewer.role];
}
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>{missing.length === 0 ? 'Beide Teams sind bereit' : 'Warten auf beide Teams'}</h2>
{missing.length > 0 && (
<ul className="missing-list">
{missing.map((t) => <li key={t.key}><strong className={`team-${t.key.toLowerCase()}`}>{t.name}:</strong> {readinessGap(t, state.requireFullTeams)}</li>)}
</ul>
)}
{isOrg ? (
<>
<button className="btn btn-primary btn-lg" disabled={!state.canStartVeto || !!busy}
onClick={() => act('veto/start', null, 'Veto gestartet')}>
Veto starten
</button>
{!state.canStartVeto && <p className="small muted">Der Knopf wird aktiv, sobald beide Teams bereit sind.</p>}
</>
) : (
<p className="small muted">
{missing.length === 0
? 'Der Organisator kann das Veto jetzt starten.'
: 'Sobald beide Teams bereit sind, startet der Organisator das Veto.'}
</p>
)}
{isOrg && !state.viewer.playerId && <SelfJoin state={state} matchId={ctx.matchId} onJoined={ctx.onJoined} />}
<MapPoolPreview maps={state.maps} />
</div>
);
}
if (state.status === 'veto') return <VetoBoard {...ctx} />;
return (
<>
<SeriesList {...ctx} />
<VetoBoard {...ctx} compact />
</>
);
}
/** Sagt konkret, was einem Team noch fehlt, damit es als bereit gilt. */
function readinessGap(team, requireFull) {
const r = team.readiness;
const gaps = [];
if (!r.enough) {
const need = requireFull ? team.teamSize - r.count : 1;
gaps.push(requireFull
? `noch ${need} von ${team.teamSize} Spielern fehlen (Team muss vollständig sein)`
: 'mindestens ein Spieler fehlt');
}
if (r.count > 0 && !r.allReady) gaps.push('noch nicht alle haben „bereit“ geklickt');
if (r.count > 0 && !r.hasCaptain) gaps.push('kein Captain');
return gaps.join(', ');
}
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' }}>
<MapImage map={m} />
<span className="pool-chip-name">{m.name}</span>
</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>
);
}