307 lines
12 KiB
React
307 lines
12 KiB
React
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';
|
||
import ChatBox from '../components/ChatBox.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' ? '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">
|
||
{state.chat && <ChatBox chat={state.chat} matchId={id} token={token} playerToken={playerToken} />}
|
||
<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' && state.status === 'lobby') {
|
||
return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Das Veto und der Spielstand erscheinen hier, sobald das Match startet.</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>
|
||
)}
|
||
{state.vetoStart === 'knife' && <KnifeRound state={state} />}
|
||
{isOrg && state.vetoStart === 'knife' ? (
|
||
<div className="knife-decide">
|
||
<p>Wer hat die Messerrunde gewonnen? Dieses Team beginnt das Veto.</p>
|
||
<div className="row">
|
||
{['A', 'B'].map((t) => (
|
||
<button key={t} className={`btn btn-lg knife-team-${t.toLowerCase()}`} disabled={!state.canStartVeto || !!busy}
|
||
onClick={() => act('veto/start', { firstTeam: t }, `Veto gestartet – ${state.teams[t].name} beginnt`)}>
|
||
{state.teams[t].name} beginnt
|
||
</button>
|
||
))}
|
||
</div>
|
||
{!state.canStartVeto && <p className="small muted">Die Knöpfe werden aktiv, sobald beide Teams bereit sind.</p>}
|
||
</div>
|
||
) : 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>}
|
||
</>
|
||
) : state.vetoStart === 'knife' ? (
|
||
<p className="small muted">Nach der Messerrunde legt der Organisator fest, welches Team das Veto beginnt.</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. */
|
||
/**
|
||
* Seitenwahl per Messerrunde: freie Messer-Server vorschlagen, passende Teamgröße zuerst.
|
||
* Frei = niemand drauf (oder Spielerzahl unbekannt).
|
||
*/
|
||
function KnifeRound({ state }) {
|
||
const [servers, setServers] = useState(null);
|
||
useEffect(() => {
|
||
let alive = true;
|
||
const load = () => api('/knife-servers').then((res) => alive && setServers(res.servers)).catch(() => alive && setServers([]));
|
||
load();
|
||
const t = setInterval(load, 10_000);
|
||
return () => {
|
||
alive = false;
|
||
clearInterval(t);
|
||
};
|
||
}, []);
|
||
const free = (servers ?? []).filter((k) => !k.playerCount);
|
||
const matching = free.filter((k) => k.mode === state.mode);
|
||
const shown = matching.length ? matching : free;
|
||
return (
|
||
<div className="knife-round">
|
||
<h3>Seitenwahl per Messerrunde</h3>
|
||
<p className="small">
|
||
Beide Teams gehen zusammen auf einen freien Messer-Server und spielen eine Runde nur mit Messer. Danach legt der Organisator fest,
|
||
welches Team das Veto beginnt.
|
||
</p>
|
||
{servers === null ? (
|
||
<p className="small muted">Suche freie Messer-Server …</p>
|
||
) : shown.length === 0 ? (
|
||
<p className="small tone-pending">Gerade ist kein Messer-Server frei. Fragt die Turnierleitung, oder wartet, bis einer frei wird.</p>
|
||
) : (
|
||
<>
|
||
{!matching.length && <p className="small muted">Kein freier Server mit eurer Teamgröße, hier die anderen freien:</p>}
|
||
<ul className="knife-list">
|
||
{shown.map((k) => (
|
||
<li key={k.id}>
|
||
<strong>{k.name}</strong>
|
||
{k.modeShort && <span className="knife-mode">{k.modeShort}</span>}
|
||
<span className="muted small">{k.mapName}</span>
|
||
<code>{k.connect}</code>
|
||
<a className="btn btn-sm btn-primary" href={k.steamUrl}>Verbinden</a>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 dieses Match ohne Zugang. Veto, Serienstand und Ergebnisse sind öffentlich.
|
||
Teamdetails und Verbindungsdaten gibt es nur für Teilnehmer.
|
||
</div>
|
||
);
|
||
}
|
||
|