Files
Cs2-Mapveto/client/src/pages/MatchPage.jsx
T
mregosar 8be8b3d879 Organisator kann im selben Browser selbst mitspielen
Ein Aufrufer darf Organisator- und Spieler-Zugang kombinieren
(Authorization + X-Player-Token). Berechtigungen prüfen jetzt einzelne
Merkmale statt einer Rangstufe. In der Lobby gibt es „Selbst mitspielen?“,
Teamlinks funktionieren auch im Organisator-Browser. Online-Status wird
direkt nach dem Beitritt aktualisiert.
2026-10-25 17:53:39 +01:00

233 lines
8.8 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>
<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>
);
}