Frontend, README und Konfigurationsvorlage
React-Oberfläche mit Übersicht, Match-Erstellung, Beitritt über Team- und Zuschauercodes, Teamkarten, visuellem Map-Veto, Serie und Serverpanel. README beschreibt Einrichtung, Umgebungsvariablen, Rollen, Veto-Abläufe, Serveradapter und CSTV-Zuschauerzugang. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
104aa1a67b
commit
568ccf9b09
24 files changed
+1938
-1
No files matched your search
@@ -0,0 +1,92 @@
|
||||
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';
|
||||
|
||||
function useCode() {
|
||||
const { code } = useParams();
|
||||
const [info, setInfo] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
useEffect(() => {
|
||||
api('/access', { method: 'POST', body: { code } }).then(setInfo).catch((e) => setError(e.message));
|
||||
}, [code]);
|
||||
return { code, info, error };
|
||||
}
|
||||
|
||||
export function JoinPage() {
|
||||
const { code, info, error } = useCode();
|
||||
const navigate = useNavigate();
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [steamId, setSteamId] = useState('');
|
||||
const [submitError, setSubmitError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
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 });
|
||||
}, [info, code, navigate]);
|
||||
|
||||
if (error) return <AccessError message={error} />;
|
||||
if (!info) return <p className="muted">Prüfe Code …</p>;
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
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' });
|
||||
navigate(`/m/${info.matchId}`);
|
||||
} catch (err) {
|
||||
setSubmitError(err.message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={`join-card team-${info.team}`} onSubmit={submit}>
|
||||
<p className="muted">{info.title}</p>
|
||||
<h1>Beitreten als {info.teamName}</h1>
|
||||
<label className="field">
|
||||
<span>Spielername</span>
|
||||
<input value={nickname} onChange={(e) => setNickname(e.target.value)} required minLength={2} maxLength={24} autoFocus />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>SteamID64 (optional)</span>
|
||||
<input value={steamId} onChange={(e) => setSteamId(e.target.value)} inputMode="numeric" placeholder="7656119…" pattern="\d{17}" />
|
||||
<small className="hint">Hilft dem Organisator, dich auf dem Server zuzuordnen.</small>
|
||||
</label>
|
||||
{submitError && <div className="alert alert-error">{submitError}</div>}
|
||||
<button className="btn btn-primary btn-lg" disabled={busy}>{busy ? 'Trete bei …' : `${info.teamName} beitreten`}</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function WatchPage() {
|
||||
const { code, info, error } = useCode();
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
if (!info) return;
|
||||
if (info.access !== 'spectator') {
|
||||
navigate(`/join/${code}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
// Spieler oder Organisator behalten ihren stärkeren Zugang.
|
||||
if (!getAccess(info.matchId)) setAccess(info.matchId, { token: code, kind: 'spectator' });
|
||||
navigate(`/m/${info.matchId}`, { replace: true });
|
||||
}, [info, code, navigate]);
|
||||
if (error) return <AccessError message={error} />;
|
||||
return <p className="muted">Öffne Zuschauerzugang …</p>;
|
||||
}
|
||||
|
||||
function AccessError({ message }) {
|
||||
return (
|
||||
<div className="empty">
|
||||
<h2>Code nicht gültig</h2>
|
||||
<p className="muted">{message}. Prüfe den Link oder frag den Organisator nach einem neuen Code.</p>
|
||||
<Link to="/" className="btn">Zur Übersicht</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../lib/api.js';
|
||||
import { FORMAT_LABEL } from '../lib/labels.js';
|
||||
import { setAccess } from '../lib/session.js';
|
||||
|
||||
export default function CreateMatch({ config }) {
|
||||
const navigate = useNavigate();
|
||||
const [form, setForm] = useState({ teamA: '', teamB: '', mode: 'competitive', format: 'bo3', vetoStart: 'coin', password: '' });
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const pool = config?.pools.find((p) => p.mode === form.mode);
|
||||
const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }));
|
||||
|
||||
// Wird ein Format für den neuen Modus nicht unterstützt, auf das erste unterstützte wechseln.
|
||||
useEffect(() => {
|
||||
if (pool && !pool.formats[form.format]?.supported) {
|
||||
const first = Object.keys(pool.formats).find((f) => pool.formats[f].supported);
|
||||
if (first) setForm((f) => ({ ...f, format: first }));
|
||||
}
|
||||
}, [pool, form.format]);
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api('/matches', { method: 'POST', body: form });
|
||||
setAccess(res.id, { token: res.organizerToken, kind: 'organizer' });
|
||||
navigate(`/m/${res.id}`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!config) return <p className="muted">Lade Einstellungen …</p>;
|
||||
|
||||
return (
|
||||
<form className="create" onSubmit={submit}>
|
||||
<h1>Match erstellen</h1>
|
||||
<p className="lead">Mit dem Erstellen der Lobby wird sofort ein Server angefordert, damit er während des Vetos hochfahren kann.</p>
|
||||
|
||||
<div className="create-teams">
|
||||
<label className="field team-field-a">
|
||||
<span>Team A</span>
|
||||
<input value={form.teamA} onChange={set('teamA')} maxLength={32} required placeholder="z. B. Nordlicht" />
|
||||
</label>
|
||||
<span className="vs big">vs.</span>
|
||||
<label className="field team-field-b">
|
||||
<span>Team B</span>
|
||||
<input value={form.teamB} onChange={set('teamB')} maxLength={32} required placeholder="z. B. Hafenkante" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset className="choice">
|
||||
<legend>Spielmodus</legend>
|
||||
<div className="choice-row">
|
||||
{config.pools.map((p) => (
|
||||
<label key={p.mode} className={`choice-card ${form.mode === p.mode ? 'is-on' : ''}`}>
|
||||
<input type="radio" name="mode" value={p.mode} checked={form.mode === p.mode} onChange={set('mode')} />
|
||||
<strong>{p.teamSize} gegen {p.teamSize}</strong>
|
||||
<span>{p.label}</span>
|
||||
<small>{p.maps.length} Maps im Pool</small>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="choice">
|
||||
<legend>Serie</legend>
|
||||
<div className="choice-row">
|
||||
{Object.entries(pool.formats).map(([f, info]) => (
|
||||
<label key={f} className={`choice-card ${form.format === f ? 'is-on' : ''} ${info.supported ? '' : 'is-off'}`}
|
||||
title={info.supported ? undefined : info.reason}>
|
||||
<input type="radio" name="format" value={f} checked={form.format === f} onChange={set('format')} disabled={!info.supported} />
|
||||
<strong>{FORMAT_LABEL[f]}</strong>
|
||||
<span>{info.supported ? `${f.slice(2)} Map${f === 'bo1' ? '' : 's'}` : 'Mit diesem Map-Pool nicht möglich'}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{pool.formats[form.format]?.flow && <FlowPreview flow={pool.formats[form.format].flow} />}
|
||||
</fieldset>
|
||||
|
||||
<label className="field">
|
||||
<span>Wer beginnt das Veto?</span>
|
||||
<select value={form.vetoStart} onChange={set('vetoStart')}>
|
||||
<option value="coin">Münzwurf beim Veto-Start</option>
|
||||
<option value="A">{form.teamA || 'Team A'}</option>
|
||||
<option value="B">{form.teamB || 'Team B'}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{config.createRequiresPassword && (
|
||||
<label className="field">
|
||||
<span>Organisator-Passwort</span>
|
||||
<input type="password" value={form.password} onChange={set('password')} required autoComplete="current-password" />
|
||||
</label>
|
||||
)}
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<button className="btn btn-primary btn-lg" disabled={busy}>{busy ? 'Erstelle Lobby …' : 'Lobby erstellen'}</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const STEP_TEXT = { ban: 'Ban', pick: 'Pick', side: 'Seite', decider: 'Entscheidungsmap' };
|
||||
|
||||
function FlowPreview({ flow }) {
|
||||
return (
|
||||
<ol className="flow-preview" aria-label="Veto-Ablauf">
|
||||
{flow.map((raw, i) => {
|
||||
const [type, actor] = raw.split(':');
|
||||
return (
|
||||
<li key={i} className={`flow-step flow-${type} ${actor ? `flow-${actor}` : ''}`}>
|
||||
<span>{STEP_TEXT[type]}</span>
|
||||
{actor && <small>{actor === 'X' ? 'Erstes Team' : 'Zweites Team'}</small>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
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 { 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';
|
||||
|
||||
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);
|
||||
});
|
||||
const { state, error, connected } = useMatch(id, token);
|
||||
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]);
|
||||
|
||||
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 });
|
||||
if (success) setToast({ tone: 'ok', text: success });
|
||||
} catch (err) {
|
||||
setToast({ tone: 'error', text: err.message });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}, [busy, id, token]);
|
||||
|
||||
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.role === 'organizer';
|
||||
const server = serverLabel(state.server);
|
||||
const ctx = { state, act, busy, isOrg, viewer };
|
||||
|
||||
return (
|
||||
<div className="match">
|
||||
{!connected && <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>{ROLE_LABEL[viewer.role]}{viewer.team && `, ${state.teams[viewer.team].name}`}</dd></div>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
{viewer.role === 'public' && <PublicNotice />}
|
||||
|
||||
<div className="match-grid">
|
||||
<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} matchId={id} token={token} />}
|
||||
<EventLog events={state.events} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>Warten auf beide Teams</h2>
|
||||
<p className="muted">
|
||||
{missing.length === 0
|
||||
? 'Beide Teams sind bereit.'
|
||||
: missing.map((t) => `${t.name}: ${t.readiness.count}/${t.teamSize} Spieler, ${t.readiness.allReady ? 'alle bereit' : 'noch nicht alle bereit'}`).join(' – ')}
|
||||
</p>
|
||||
{isOrg ? (
|
||||
<button className="btn btn-primary btn-lg" disabled={!state.canStartVeto || !!busy}
|
||||
onClick={() => act('veto/start', null, 'Veto gestartet')}>
|
||||
Veto starten
|
||||
</button>
|
||||
) : (
|
||||
<p className="small muted">Der Organisator startet das Veto, sobald beide Teams bereit sind.</p>
|
||||
)}
|
||||
<MapPoolPreview maps={state.maps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.status === 'veto') return <VetoBoard {...ctx} />;
|
||||
return (
|
||||
<>
|
||||
<SeriesList {...ctx} />
|
||||
<VetoBoard {...ctx} compact />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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' }}>{m.name}</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../lib/api.js';
|
||||
import { FORMAT_LABEL, serverLabel, timeAgo } from '../lib/labels.js';
|
||||
import { getAccess } from '../lib/session.js';
|
||||
import { useOverview } from '../lib/useMatch.js';
|
||||
|
||||
const GROUPS = [
|
||||
{ key: 'open', title: 'Offen', empty: 'Keine offenen Lobbys. Erstelle ein Match, um zu starten.' },
|
||||
{ key: 'running', title: 'Laufend', empty: 'Gerade läuft kein Match.' },
|
||||
{ key: 'closed', title: 'Abgeschlossen', empty: 'Noch keine abgeschlossenen Matches.' },
|
||||
];
|
||||
|
||||
export default function Overview() {
|
||||
const { matches } = useOverview();
|
||||
return (
|
||||
<div className="overview">
|
||||
<section className="overview-head">
|
||||
<div>
|
||||
<h1>Match-Lobbys</h1>
|
||||
<p className="lead">Teams treten mit ihrem Einladungscode bei, die Captains führen das Map-Veto, der Server wird automatisch eingerichtet.</p>
|
||||
</div>
|
||||
<CodeEntry />
|
||||
</section>
|
||||
|
||||
{matches === null ? (
|
||||
<p className="muted">Lade Matches …</p>
|
||||
) : (
|
||||
<div className="groups">
|
||||
{GROUPS.map((g) => {
|
||||
const list = matches.filter((m) => m.group === g.key);
|
||||
return (
|
||||
<section key={g.key} className={`group group-${g.key}`}>
|
||||
<h2>{g.title} <span className="count">{list.length}</span></h2>
|
||||
{list.length === 0 ? <p className="muted small">{g.empty}</p> : (
|
||||
<ul className="match-list">
|
||||
{list.map((m) => <MatchRow key={m.id} match={m} />)}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchRow({ match: m }) {
|
||||
const access = getAccess(m.id);
|
||||
const server = serverLabel(m.server);
|
||||
return (
|
||||
<li>
|
||||
<Link to={`/m/${m.id}`} className="match-row">
|
||||
<div className="match-row-teams">
|
||||
<span className="team-a">{m.teamA}</span>
|
||||
<span className="vs">vs.</span>
|
||||
<span className="team-b">{m.teamB}</span>
|
||||
</div>
|
||||
<div className="match-row-meta">
|
||||
<span>{m.modeLabel}</span>
|
||||
<span>{FORMAT_LABEL[m.format]}</span>
|
||||
{m.group === 'open' && <span>{m.players.A + m.players.B}/{m.players.teamSize * 2} Spieler</span>}
|
||||
<span className={`tone-${server.tone}`}>{m.statusLabel}</span>
|
||||
{access && <span className="mine">Dein Zugang</span>}
|
||||
<span className="muted">{timeAgo(m.createdAt)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeEntry() {
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api('/access', { method: 'POST', body: { code } });
|
||||
navigate(res.access === 'spectator' ? `/watch/${code.trim()}` : `/join/${code.trim()}`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<form className="code-entry" onSubmit={submit}>
|
||||
<label htmlFor="code">Einladungs- oder Zuschauercode</label>
|
||||
<div className="row">
|
||||
<input id="code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="ABCD-EFGH" autoComplete="off" spellCheck="false" />
|
||||
<button className="btn" disabled={code.replace(/[^a-z0-9]/gi, '').length < 8}>Beitreten</button>
|
||||
</div>
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user