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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SPP UserandClaude Opus 5.5 committed 2026-10-25 17:53:39 +01:00
1 parent 470b12f232
commit 4152f9c0a6
15 files changed
+248 -59

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
import { useState } from 'react';
import { api } from '../lib/api.js';
/** Organisator tritt im selben Browser selbst einem Team bei. */
export default function SelfJoin({ state, matchId, onJoined }) {
const [nickname, setNickname] = useState('');
const [busyTeam, setBusyTeam] = useState(null);
const [error, setError] = useState(null);
const { codes } = state.organizer;
const join = async (team) => {
setBusyTeam(team);
setError(null);
try {
const res = await api(`/matches/${matchId}/join`, { method: 'POST', body: { code: codes[team], nickname } });
onJoined(res.playerToken);
} catch (err) {
setError(err.message);
} finally {
setBusyTeam(null);
}
};
const valid = nickname.trim().length >= 2;
return (
<div className="self-join">
<h3>Selbst mitspielen?</h3>
<p className="small muted">Tritt hier einem Team bei. Du bleibst dabei Organisator, ein zweiter Browser ist nicht nötig.</p>
<label className="field">
<span>Dein Spielername</span>
<input value={nickname} onChange={(e) => setNickname(e.target.value)} maxLength={24} placeholder="z. B. MrEgosar" />
</label>
<div className="self-join-buttons">
{['A', 'B'].map((key) => {
const team = state.teams[key];
const full = team.players.length >= team.teamSize;
return (
<button key={key} type="button" className={`btn self-join-${key}`} disabled={!valid || full || !!busyTeam}
onClick={() => join(key)}>
{busyTeam === key ? 'Trete bei …' : full ? `${team.name} ist voll` : `${team.name} beitreten`}
</button>
);
})}
</div>
{error && <p className="field-error">{error}</p>}
</div>
);
}
+1 -1
View File
@@ -59,7 +59,7 @@ export default function ServerPanel({ state, isOrg, act, busy }) {
</p>
</div>
)}
{state.viewer.role === 'spectator' && server?.status === 'configured' && !server.gotv && (
{state.viewer.isSpectator && !state.viewer.isOrganizer && server?.status === 'configured' && !server.gotv && (
<p className="small muted">CSTV ist auf diesem Server deaktiviert.</p>
)}
+3 -3
View File
@@ -3,7 +3,7 @@ export default function TeamCard({ team, side, state, viewer, isOrg, act, busy }
const inLobby = state.status === 'lobby';
const me = team.players.find((p) => p.isYou);
const emptySlots = Math.max(0, team.teamSize - team.players.length);
const canManageCaptain = isOrg || (viewer.role === 'captain' && isOwnTeam);
const canManageCaptain = isOrg || (viewer.isCaptain && isOwnTeam);
const { readiness } = team;
return (
@@ -25,14 +25,14 @@ export default function TeamCard({ team, side, state, viewer, isOrg, act, busy }
</span>
{p.isCaptain && <span className="captain" title="Captain – führt das Veto">Captain</span>}
{inLobby && <span className={`ready-dot ${p.ready ? 'on' : ''}`}>{p.ready ? 'bereit' : 'wartet'}</span>}
{(canManageCaptain && !p.isCaptain) || (isOrg && inLobby) ? (
{(canManageCaptain && !p.isCaptain) || (isOrg && inLobby && !p.isYou) ? (
<span className="row-actions">
{canManageCaptain && !p.isCaptain && (
<button className="link-btn" disabled={!!busy} onClick={() => act('captain', { playerId: p.id }, `${p.nickname} ist jetzt Captain`)}>
Zum Captain machen
</button>
)}
{isOrg && inLobby && (
{isOrg && inLobby && !p.isYou && (
<button className="link-btn danger" disabled={!!busy} onClick={() => act('kick', { playerId: p.id }, `${p.nickname} entfernt`)}>
Entfernen
</button>
+2 -2
View File
@@ -12,7 +12,7 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
useEffect(() => setSelected(null), [current?.index]);
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
const yourTurn = !!current && viewer.role === 'captain' && viewer.team === current.team;
const yourTurn = !!current && viewer.isCaptain && viewer.team === current.team;
const choosingMap = yourTurn && (current.type === 'ban' || current.type === 'pick');
const sideMap = current?.type === 'side' ? mapsById[current.map] : null;
@@ -34,7 +34,7 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
? 'Du bist am Zug.'
: viewer.team === current.team
? 'Dein Captain entscheidet.'
: viewer.role === 'spectator' || viewer.role === 'organizer'
: !viewer.team
? 'Live-Ansicht – nur Captains können wählen.'
: 'Das andere Team ist am Zug.'}
</span>
+3 -1
View File
@@ -6,7 +6,7 @@ export class ApiError extends Error {
}
}
export async function api(path, { method = 'GET', body, token } = {}) {
export async function api(path, { method = 'GET', body, token, playerToken } = {}) {
let res;
try {
res = await fetch(`/api${path}`, {
@@ -14,6 +14,8 @@ export async function api(path, { method = 'GET', body, token } = {}) {
headers: {
...(body ? { 'content-type': 'application/json' } : {}),
...(token ? { authorization: `Bearer ${token}` } : {}),
// Zweiter Zugang: Spieler-Token des Organisators, wenn er selbst mitspielt
...(playerToken ? { 'x-player-token': playerToken } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
+23 -6
View File
@@ -1,34 +1,51 @@
// Zugangsdaten pro Match im Browser. Nach Neuladen oder Verbindungsabbruch
// meldet sich der Client damit automatisch wieder an.
//
// Aufbau: { token, kind: 'organizer' | 'player' | 'spectator', playerToken? }
// playerToken gibt es nur beim Organisator, wenn er selbst einem Team beigetreten ist.
const KEY = (matchId) => `cs2mv:match:${matchId}`;
const memory = new Map();
export function getAccess(matchId) {
try {
return JSON.parse(localStorage.getItem(KEY(matchId))) ?? null;
return JSON.parse(localStorage.getItem(KEY(matchId))) ?? memory.get(matchId) ?? null;
} catch {
return null;
return memory.get(matchId) ?? null;
}
}
export function setAccess(matchId, access) {
memory.set(matchId, access);
try {
localStorage.setItem(KEY(matchId), JSON.stringify(access));
} catch {
/* privater Modus – Zugang gilt dann nur für diese Sitzung */
}
memory.set(matchId, access);
}
export function clearAccess(matchId) {
memory.delete(matchId);
try {
localStorage.removeItem(KEY(matchId));
} catch {
/* ignorieren */
}
memory.delete(matchId);
}
const memory = new Map();
export function getToken(matchId) {
return (getAccess(matchId) ?? memory.get(matchId))?.token ?? null;
return getAccess(matchId)?.token ?? null;
}
export function getPlayerToken(matchId) {
return getAccess(matchId)?.playerToken ?? null;
}
/** Spieler-Token neben dem Organisator-Zugang speichern bzw. entfernen (null). */
export function setPlayerToken(matchId, playerToken) {
const access = getAccess(matchId);
if (!access) return;
const next = { ...access };
if (playerToken) next.playerToken = playerToken;
else delete next.playerToken;
setAccess(matchId, next);
}
+6 -6
View File
@@ -9,17 +9,17 @@ export function getSocket() {
}
/** Abonniert den Live-Zustand eines Matches. Meldet sich nach jedem Reconnect neu an. */
export function useMatch(matchId, token) {
export function useMatch(matchId, token, playerToken = null) {
const [state, setState] = useState(null);
const [error, setError] = useState(null);
// null = Verbindung wird erstmals aufgebaut (kein Hinweis), false = Verbindung abgerissen
const [connected, setConnected] = useState(null);
const tokenRef = useRef(token);
tokenRef.current = token;
const tokenRef = useRef({ token, playerToken });
tokenRef.current = { token, playerToken };
useEffect(() => {
const socket = getSocket();
const subscribe = () => socket.emit('match:subscribe', { matchId, token: tokenRef.current });
const subscribe = () => socket.emit('match:subscribe', { matchId, ...tokenRef.current });
const onState = (s) => {
if (s.id !== matchId) return;
setState(s);
@@ -40,7 +40,7 @@ export function useMatch(matchId, token) {
// Erster Stand per REST, falls der Socket noch verbindet; neuere Socket-Stände haben Vorrang.
let active = true;
api(`/matches/${matchId}`, { token })
api(`/matches/${matchId}`, { token, playerToken })
.then((s) => active && setState((prev) => (prev && prev.updatedAt >= s.updatedAt ? prev : s)))
.catch((e) => active && setError({ error: e.message, code: e.code }));
@@ -51,7 +51,7 @@ export function useMatch(matchId, token) {
socket.off('connect', onConnect);
socket.off('disconnect', onDisconnect);
};
}, [matchId, token]);
}, [matchId, token, playerToken]);
return { state, error, connected };
}
+13 -4
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { api } from '../lib/api.js';
import { getAccess, setAccess } from '../lib/session.js';
import { getAccess, setAccess, setPlayerToken } from '../lib/session.js';
function useCode() {
const { code } = useParams();
@@ -13,6 +13,12 @@ function useCode() {
return { code, info, error };
}
function alreadyPlayer(access) {
if (!access) return false;
if (access.kind === 'organizer') return Boolean(access.playerToken);
return access.kind === 'player';
}
export function JoinPage() {
const { code, info, error } = useCode();
const navigate = useNavigate();
@@ -24,8 +30,8 @@ export function JoinPage() {
useEffect(() => {
if (!info) return;
if (info.access === 'spectator') navigate(`/watch/${code}`, { replace: true });
// Schon beigetreten? Dann direkt zur Lobby.
else if (getAccess(info.matchId)) navigate(`/m/${info.matchId}`, { replace: true });
// Schon als Spieler dabei? Dann direkt zur Lobby. Organisator ohne Team und Zuschauer dürfen beitreten.
else if (alreadyPlayer(getAccess(info.matchId))) navigate(`/m/${info.matchId}`, { replace: true });
}, [info, code, navigate]);
if (error) return <AccessError message={error} />;
@@ -37,7 +43,10 @@ export function JoinPage() {
setSubmitError(null);
try {
const res = await api(`/matches/${info.matchId}/join`, { method: 'POST', body: { code, nickname, steamId } });
setAccess(info.matchId, { token: res.playerToken, kind: 'player' });
const existing = getAccess(info.matchId);
// Der Organisator behält seinen Zugang und bekommt den Spieler-Zugang dazu.
if (existing?.kind === 'organizer') setPlayerToken(info.matchId, res.playerToken);
else setAccess(info.matchId, { token: res.playerToken, kind: 'player' });
navigate(`/m/${info.matchId}`);
} catch (err) {
setSubmitError(err.message);
+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>
);
+7
View File
@@ -192,6 +192,13 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.stage-note .btn { margin: 8px 0 8px; }
.stage-note .pool-preview { margin-top: 16px; }
.missing-list { margin: 0 0 12px; padding-left: 1.1em; color: var(--muted); }
.self-join { margin: 18px 0 4px; padding: 16px; background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--radius); max-width: 560px; }
.self-join h3 { margin-bottom: 4px; }
.self-join .field { margin-bottom: 12px; }
.self-join-buttons { display: flex; flex-wrap: wrap; gap: 10px; }
.self-join-buttons .btn { flex: 1 1 180px; margin: 0; }
.self-join-A:not(:disabled) { border-color: var(--team-a); color: var(--team-a); }
.self-join-B:not(:disabled) { border-color: var(--team-b); color: var(--team-b); }
.missing-list li { margin-bottom: 4px; }
.pool-preview { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; }
.pool-chip {