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/#org= 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 (

{error.code === 'not_found' ? 'Match nicht gefunden' : 'Fehler'}

{error.error}

Zur Übersicht
); } if (!state) return

Verbinde mit Lobby …

; const { viewer } = state; const isOrg = viewer.isOrganizer; const server = serverLabel(state.server); const ctx = { state, act, busy, isOrg, viewer, matchId: id, onJoined: joinAsOrganizer }; return (
{connected === false &&
Verbindung unterbrochen – stelle wieder her …
} {toast &&
{toast.text}
}

{state.teams.A.name} {state.series.length > 1 ? ( {state.seriesScore.A}:{state.seriesScore.B} ) : ( vs. )} {state.teams.B.name}

Modus
{state.modeLabel}
Serie
{FORMAT_LABEL[state.format]}
Status
{state.statusLabel}
Server
{server.text}
Du bist
{describeViewer(viewer, state.teams)}
{viewer.role === 'public' && }
{viewer.role !== 'public' && (
{isOrg && }
)}
); } /** 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

{state.statusLabel}

Details sehen nur Teilnehmer.

; } if (state.status === 'lobby') { const { A, B } = state.teams; const missing = [A, B].filter((t) => !t.readiness.ready); return (

{missing.length === 0 ? 'Beide Teams sind bereit' : 'Warten auf beide Teams'}

{missing.length > 0 && (
    {missing.map((t) =>
  • {t.name}: {readinessGap(t, state.requireFullTeams)}
  • )}
)} {isOrg ? ( <> {!state.canStartVeto &&

Der Knopf wird aktiv, sobald beide Teams bereit sind.

} ) : (

{missing.length === 0 ? 'Der Organisator kann das Veto jetzt starten.' : 'Sobald beide Teams bereit sind, startet der Organisator das Veto.'}

)} {isOrg && !state.viewer.playerId && }
); } if (state.status === 'veto') return ; return ( <> ); } /** 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 (
{maps.map((m) => ( {m.name} ))}
); } function PublicNotice() { return (
Du siehst diese Lobby ohne Zugang. Spieler treten über den Einladungslink ihres Teams bei, Zuschauer über den Zuschauerlink. Beides bekommst du vom Organisator.
); }