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:
1 parent
470b12f232
commit
4152f9c0a6
15 files changed
+248
-59
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user