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.
This commit is contained in:
mregosar committed 2026-10-25 17:53:39 +01:00
1 parent 3a74e16bb8
commit 8be8b3d879
15 files changed
+248 -59

No files matched your search

+40 -8
View File
@@ -2,7 +2,7 @@ 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 { 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';
@@ -11,6 +11,7 @@ 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();
@@ -24,7 +25,9 @@ export default function MatchPage() {
}
return getToken(id);
});
const { state, error, connected } = useMatch(id, token);
// 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);
@@ -38,6 +41,26 @@ export default function MatchPage() {
}
}, [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);
@@ -49,14 +72,14 @@ export default function MatchPage() {
if (busy) return;
setBusy(path);
try {
await api(`/matches/${id}/${path}`, { method: 'POST', body: body ?? {}, token });
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]);
}, [busy, id, token, playerToken]);
if (error && !state) {
return (
@@ -70,9 +93,9 @@ export default function MatchPage() {
if (!state) return <p className="muted">Verbinde mit Lobby …</p>;
const { viewer } = state;
const isOrg = viewer.role === 'organizer';
const isOrg = viewer.isOrganizer;
const server = serverLabel(state.server);
const ctx = { state, act, busy, isOrg, viewer };
const ctx = { state, act, busy, isOrg, viewer, matchId: id, onJoined: joinAsOrganizer };
return (
<div className="match">
@@ -92,7 +115,7 @@ export default function MatchPage() {
<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>
<div><dt>Du bist</dt><dd>{describeViewer(viewer, state.teams)}</dd></div>
</dl>
</header>
@@ -109,7 +132,7 @@ export default function MatchPage() {
{viewer.role !== 'public' && (
<div className="match-lower">
<ServerPanel {...ctx} />
{isOrg && <OrganizerPanel {...ctx} matchId={id} token={token} />}
{isOrg && <OrganizerPanel {...ctx} token={token} />}
<EventLog events={state.events} />
</div>
)}
@@ -117,6 +140,14 @@ export default function MatchPage() {
);
}
/** 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') {
@@ -148,6 +179,7 @@ function Stage(ctx) {
: '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>
);