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.
This commit is contained in:
mregosar committed 2026-10-25 17:02:18 +01:00
1 parent f08297dd0d
commit f7520a6ca7
24 files changed
+1938 -1

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import { Link, Route, Routes } from 'react-router-dom';
import { api } from './lib/api.js';
import Overview from './pages/Overview.jsx';
import CreateMatch from './pages/CreateMatch.jsx';
import MatchPage from './pages/MatchPage.jsx';
import { JoinPage, WatchPage } from './pages/AccessPages.jsx';
export default function App() {
const [config, setConfig] = useState(null);
const [configError, setConfigError] = useState(null);
useEffect(() => {
api('/config').then(setConfig).catch((e) => setConfigError(e.message));
}, []);
return (
<div className="app">
<header className="topbar">
<Link to="/" className="brand" aria-label="Zur Übersicht">
<svg viewBox="0 0 32 32" width="26" height="26" aria-hidden="true">
<path d="M4 4h24l-4 24H8z" fill="var(--ct)" />
<path d="M10 10l12 12M22 10L10 22" stroke="var(--bg)" strokeWidth="3" />
</svg>
<span>CS2 Mapveto</span>
</Link>
{config?.server.simulated && (
<span className="demo-flag" title="SERVER_ADAPTER=demo – es werden keine echten CS2-Server gestartet">
Demo-Modus: keine echten Server
</span>
)}
<nav>
<Link to="/" className="nav-link">Übersicht</Link>
<Link to="/neu" className="btn btn-primary btn-sm">Match erstellen</Link>
</nav>
</header>
<main className="content">
{configError && <div className="alert alert-error">Backend nicht erreichbar: {configError}</div>}
<Routes>
<Route path="/" element={<Overview />} />
<Route path="/neu" element={<CreateMatch config={config} />} />
<Route path="/m/:id" element={<MatchPage />} />
<Route path="/join/:code" element={<JoinPage config={config} />} />
<Route path="/watch/:code" element={<WatchPage />} />
<Route path="*" element={<div className="empty"><h2>Seite nicht gefunden</h2><Link to="/">Zur Übersicht</Link></div>} />
</Routes>
</main>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { useState } from 'react';
export default function CopyField({ label, value, secret = false }) {
const [copied, setCopied] = useState(false);
const [revealed, setRevealed] = useState(!secret);
const copy = async () => {
try {
await navigator.clipboard.writeText(value);
} catch {
// Fallback für Browser ohne Clipboard-API (z. B. HTTP ohne localhost)
const el = document.createElement('textarea');
el.value = value;
document.body.appendChild(el);
el.select();
document.execCommand('copy');
el.remove();
}
setCopied(true);
setTimeout(() => setCopied(false), 1800);
};
return (
<div className="copy-field">
{label && <span className="copy-label">{label}</span>}
<div className="copy-row">
<code className={revealed ? '' : 'blurred'} onClick={() => setRevealed(true)}>{value}</code>
{secret && !revealed && <button type="button" className="link-btn" onClick={() => setRevealed(true)}>Anzeigen</button>}
<button type="button" className="btn btn-sm" onClick={copy}>{copied ? 'Kopiert' : 'Kopieren'}</button>
</div>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { clock } from '../lib/labels.js';
export default function EventLog({ events }) {
return (
<section className="panel event-log">
<h2>Verlauf</h2>
{events.length === 0 ? <p className="muted small">Noch keine Ereignisse.</p> : (
<ol>
{events.map((e, i) => (
<li key={`${e.at}-${i}`} className={`ev-${e.type}`}>
<time>{clock(e.at)}</time>
<span>{e.message}</span>
</li>
))}
</ol>
)}
</section>
);
}
+37
View File
@@ -0,0 +1,37 @@
import CopyField from './CopyField.jsx';
export default function OrganizerPanel({ state, act, busy, matchId, token }) {
const { codes, links } = state.organizer;
const open = !['finished', 'cancelled'].includes(state.status);
const orgLink = `${window.location.origin}/m/${matchId}#org=${token}`;
const regen = (target, label) =>
window.confirm(`Neuen Code für ${label} erzeugen? Der alte Link funktioniert dann nicht mehr. Bereits beigetretene Spieler bleiben im Team.`) &&
act('codes/regenerate', { target }, `Neuer Code für ${label}`);
return (
<section className="panel organizer-panel">
<h2>Einladungen</h2>
<p className="small muted">Jeder Link führt nur in das jeweilige Team. Gib ihn ausschließlich an dieses Team weiter.</p>
{[
['A', state.teams.A.name, 'team-A'],
['B', state.teams.B.name, 'team-B'],
['spectator', 'Zuschauer (Stream, Kommentatoren)', 'spectator'],
].map(([key, label, cls]) => (
<div key={key} className={`invite ${cls}`}>
<div className="invite-head">
<strong>{label}</strong>
<span className="invite-code">{codes[key]}</span>
{open && <button className="link-btn" disabled={!!busy} onClick={() => regen(key, label)}>Neuer Code</button>}
</div>
<CopyField value={links[key]} />
</div>
))}
<details className="org-link">
<summary>Organisator-Zugang auf einem anderen Gerät öffnen</summary>
<p className="small muted">Dieser Link gibt volle Kontrolle über das Match. Nicht weitergeben.</p>
<CopyField value={orgLink} secret />
</details>
</section>
);
}
+30
View File
@@ -0,0 +1,30 @@
const STATUS = { pending: 'Ausstehend', next: 'Wird eingerichtet', live: 'Aktuell', done: 'Gespielt' };
export default function SeriesList({ state }) {
const { teams } = state;
return (
<div className="series">
<h2>Map-Reihenfolge</h2>
<ol className="series-list">
{state.series.map((s) => {
const map = state.maps.find((m) => m.id === s.map);
const ct = s.ctTeam ? teams[s.ctTeam].name : null;
const t = s.ctTeam ? teams[s.ctTeam === 'A' ? 'B' : 'A'].name : null;
return (
<li key={s.number} className={`series-item is-${s.status}`} style={{ '--map': map?.color ?? '#556' }}>
<span className="series-no">Map {s.number}</span>
<strong className="series-map">{s.mapName}</strong>
<span className="series-origin">
{s.pickedBy ? `Pick von ${teams[s.pickedBy].name}` : 'Entscheidungsmap'}
</span>
<span className="series-sides">
{ct ? <><span className="side-ct">CT: {ct}</span> <span className="side-t">T: {t}</span></> : 'Seiten im Spiel festlegen'}
</span>
<span className="series-status">{STATUS[s.status]}</span>
</li>
);
})}
</ol>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { serverLabel } from '../lib/labels.js';
import CopyField from './CopyField.jsx';
const RETRY = {
start_failed: 'Server neu starten',
failed: 'Server neu starten',
stopped: 'Server neu starten',
config_failed: 'Konfiguration wiederholen',
ready: 'Konfiguration starten',
};
const ACTIVE = ['starting', 'ready', 'configuring', 'configured', 'config_failed', 'failed'];
export default function ServerPanel({ state, isOrg, act, busy }) {
const { server } = state;
const label = serverLabel(server);
const closed = ['finished', 'cancelled'].includes(state.status);
const sim = server?.simulated;
const canRetry = server && !closed && RETRY[server.status] && (server.status !== 'ready' || state.status === 'server_setup');
const hasNext = state.series.some((s) => s.number === (state.currentMapNumber ?? 0) + 1);
return (
<section className={`panel server-panel tone-border-${label.tone}`}>
<h2>Spielserver</h2>
<div className="server-status">
<span className={`status-dot tone-${label.tone} ${['starting', 'configuring'].includes(server?.status) ? 'pulse' : ''}`} />
<strong className={`tone-${label.tone}`}>{label.text}</strong>
</div>
{server?.message && <p className="small">{server.message}</p>}
{sim && (
<div className="alert alert-demo">
Demo-Adapter aktiv: Es läuft kein echter CS2-Server. Start, Konfiguration und Verbindungsdaten sind simuliert.
Wie die echte Anbindung eingerichtet wird, steht in der README.
</div>
)}
{!server && <p className="muted small">Für dieses Match wurde noch kein Server angefordert.</p>}
{server && ['starting', 'ready'].includes(server.status) && state.status !== 'server_setup' && (
<p className="muted small">
{sim ? 'Der Start wird während des Vetos simuliert.' : 'Der Server fährt während des Vetos hoch.'} Die Verbindungsdaten erscheinen nach dem Veto.
</p>
)}
{server?.connect && (
<div className="connect">
<h3>{sim ? 'Spieler-Verbindung (Demo-Beispiel, nicht verbindbar)' : 'Verbindung für Spieler'}</h3>
<CopyField label="In die CS2-Konsole einfügen" value={server.connect.command} secret />
{!sim && <a className="btn btn-primary" href={server.connect.steamUrl}>In CS2 verbinden</a>}
<p className="small muted">Server {server.connect.host}:{server.connect.port}. Das Passwort ist nur für Spieler dieses Matches sichtbar.</p>
</div>
)}
{server?.gotv && (
<div className="connect gotv">
<h3>{sim ? 'CSTV/GOTV (Demo-Beispiel, nicht verbindbar)' : 'Zuschauen über CSTV (GOTV)'}</h3>
<CopyField label="Für Stream und Kommentatoren" value={server.gotv.command} secret />
<p className="small muted">
Übertragung mit {server.gotv.delaySeconds} Sekunden Verzögerung gegen Ghosting. Zuschauer belegen keinen Spielerplatz.
</p>
</div>
)}
{state.viewer.role === 'spectator' && server?.status === 'configured' && !server.gotv && (
<p className="small muted">CSTV ist auf diesem Server deaktiviert.</p>
)}
{isOrg && server && !closed && (
<div className="org-controls">
{canRetry && (
<button className="btn btn-primary" disabled={!!busy} onClick={() => act('server/retry', null, 'Erneuter Versuch gestartet')}>
{RETRY[server.status]}
</button>
)}
{server.status === 'configured' && (
<button className="btn" disabled={!!busy} onClick={() => act('server/warmup-end', null, 'Aufwärmphase beendet')}>
Aufwärmphase beenden
</button>
)}
{server.status === 'configured' && state.status === 'live' && hasNext && (
<button className="btn" disabled={!!busy} onClick={() => act('series/next', null, 'Nächste Map wird geladen')}>
Nächste Map laden
</button>
)}
{ACTIVE.includes(server.status) && (
<button className="btn btn-ghost" disabled={!!busy}
onClick={() => window.confirm('Server wirklich stoppen?') && act('server/stop', null, 'Server gestoppt')}>
Server stoppen
</button>
)}
</div>
)}
{isOrg && !closed && (
<div className="org-controls">
{['live', 'server_setup'].includes(state.status) && (
<button className="btn" disabled={!!busy}
onClick={() => window.confirm('Match abschließen und Server herunterfahren?') && act('finish', null, 'Match abgeschlossen')}>
Match abschließen
</button>
)}
<button className="btn btn-ghost danger" disabled={!!busy}
onClick={() => window.confirm('Match abbrechen? Das kann nicht rückgängig gemacht werden.') && act('cancel', null, 'Match abgebrochen')}>
Match abbrechen
</button>
</div>
)}
{server && ACTIVE.includes(server.status) && (
<p className="small muted">Ungenutzte Server werden nach {server.idleTimeoutMinutes} Minuten automatisch heruntergefahren.</p>
)}
</section>
);
}
+64
View File
@@ -0,0 +1,64 @@
export default function TeamCard({ team, side, state, viewer, isOrg, act, busy }) {
const isOwnTeam = viewer.team === side;
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 { readiness } = team;
return (
<section className={`team-card team-${side} ${readiness.ready ? 'is-ready' : ''}`} aria-label={`Team ${team.name}`}>
<header>
<h2>{team.name}</h2>
<span className={`ready-state ${readiness.ready ? 'on' : ''}`}>
{readiness.ready ? 'Bereit' : `${readiness.count}/${team.teamSize}`}
</span>
</header>
<ul className="roster">
{team.players.map((p) => (
<li key={p.id} className={`${p.isYou ? 'is-you' : ''}`}>
<span className={`presence ${p.online ? 'on' : ''}`} title={p.online ? 'Online' : 'Offline'} />
<span className="nick">
{p.nickname}
{p.isYou && <small> (du)</small>}
</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) ? (
<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 && (
<button className="link-btn danger" disabled={!!busy} onClick={() => act('kick', { playerId: p.id }, `${p.nickname} entfernt`)}>
Entfernen
</button>
)}
</span>
) : null}
</li>
))}
{Array.from({ length: emptySlots }, (_, i) => (
<li key={`empty-${i}`} className="slot-empty">Freier Platz</li>
))}
</ul>
{me && inLobby && (
<div className="team-actions">
<button className={`btn ${me.ready ? '' : 'btn-primary'}`} disabled={!!busy}
onClick={() => act('ready', { ready: !me.ready })}>
{me.ready ? 'Doch nicht bereit' : 'Ich bin bereit'}
</button>
<button className="link-btn" disabled={!!busy}
onClick={() => window.confirm('Lobby wirklich verlassen?') && act('leave', null, 'Du hast das Team verlassen')}>
Team verlassen
</button>
</div>
)}
{inLobby && !readiness.enough && <p className="small muted">Es fehlen noch {team.teamSize - readiness.count} Spieler.</p>}
</section>
);
}
+117
View File
@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { clock } from '../lib/labels.js';
const STEP_SHORT = { ban: 'Ban', pick: 'Pick', side: 'Seite', decider: 'Decider' };
export default function VetoBoard({ state, viewer, act, busy, compact = false }) {
const { veto, teams } = state;
const { current } = veto;
const [selected, setSelected] = useState(null);
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 choosingMap = yourTurn && (current.type === 'ban' || current.type === 'pick');
const sideMap = current?.type === 'side' ? mapsById[current.map] : null;
const submitMap = () => act('veto/action', { stepIndex: current.index, map: selected });
const submitSide = (side) => act('veto/action', { stepIndex: current.index, side });
return (
<div className={`veto ${compact ? 'veto-compact' : ''}`}>
{current && (
<div className={`turn turn-${current.team} ${yourTurn ? 'is-you' : ''}`} aria-live="polite">
<span className="turn-team">{teams[current.team].name}</span>
<span className="turn-action">
{current.type === 'ban' && 'bannt eine Map'}
{current.type === 'pick' && 'wählt eine Map'}
{current.type === 'side' && `wählt die Startseite auf ${sideMap?.name}`}
</span>
<span className="turn-hint">
{yourTurn
? 'Du bist am Zug.'
: viewer.team === current.team
? 'Dein Captain entscheidet.'
: viewer.role === 'spectator' || viewer.role === 'organizer'
? 'Live-Ansicht – nur Captains können wählen.'
: 'Das andere Team ist am Zug.'}
</span>
</div>
)}
{!current && veto.done && !compact && <div className="turn turn-done">Veto abgeschlossen</div>}
<div className="map-grid" role="list">
{veto.maps.map((m) => {
const meta = mapsById[m.id];
const selectable = choosingMap && m.status === 'available';
const isSel = selected === m.id;
return (
<button
key={m.id}
type="button"
role="listitem"
className={`map-tile status-${m.status} ${m.by ? `by-${m.by}` : ''} ${isSel ? `is-selected sel-${current.type}` : ''} ${sideMap?.id === m.id ? 'is-side' : ''}`}
style={{ '--map': meta?.color ?? '#556' }}
disabled={!selectable || !!busy}
onClick={() => setSelected(isSel ? null : m.id)}
aria-pressed={isSel}
aria-label={`${meta.name}: ${statusText(m, teams)}`}
>
<span className="map-art" aria-hidden="true" />
<span className="map-name">{meta.name}</span>
<span className="map-status">{statusText(m, teams)}</span>
</button>
);
})}
</div>
{choosingMap && (
<div className="veto-confirm">
<button className={`btn btn-lg ${current.type === 'ban' ? 'btn-danger' : 'btn-primary'}`} disabled={!selected || !!busy} onClick={submitMap}>
{selected
? `${mapsById[selected].name} ${current.type === 'ban' ? 'bannen' : 'wählen'}`
: `Map zum ${current.type === 'ban' ? 'Bannen' : 'Wählen'} antippen`}
</button>
</div>
)}
{yourTurn && current.type === 'side' && (
<div className="side-choice">
<p>Auf welcher Seite startet {teams[viewer.team].name} auf {sideMap?.name}?</p>
<div className="row">
<button className="btn btn-ct btn-lg" disabled={!!busy} onClick={() => submitSide('CT')}>Counter-Terrorists</button>
<button className="btn btn-t btn-lg" disabled={!!busy} onClick={() => submitSide('T')}>Terrorists</button>
</div>
</div>
)}
<ol className="step-track" aria-label="Veto-Ablauf">
{veto.steps.map((s) => (
<li key={s.index} className={`step step-${s.type} ${s.team ? `step-${s.team}` : ''} ${s.done ? 'done' : ''} ${current?.index === s.index ? 'now' : ''}`}
title={s.team ? `${teams[s.team].name}: ${STEP_SHORT[s.type]}` : 'Übrig gebliebene Map'}>
<span>{STEP_SHORT[s.type]}</span>
</li>
))}
</ol>
{veto.history.length > 0 && !compact && (
<ol className="veto-history">
{veto.history.map((h) => (
<li key={h.index} className={`hist-${h.type} ${h.team ? `hist-${h.team}` : ''}`}>
<time>{clock(h.at)}</time> {h.text}
</li>
))}
</ol>
)}
</div>
);
}
function statusText(m, teams) {
switch (m.status) {
case 'banned': return `Gebannt von ${teams[m.by].name}`;
case 'picked': return `Gewählt von ${teams[m.by].name}`;
case 'decider': return 'Entscheidungsmap';
default: return 'Verfügbar';
}
}
+26
View File
@@ -0,0 +1,26 @@
export class ApiError extends Error {
constructor(message, status, code) {
super(message);
this.status = status;
this.code = code;
}
}
export async function api(path, { method = 'GET', body, token } = {}) {
let res;
try {
res = await fetch(`/api${path}`, {
method,
headers: {
...(body ? { 'content-type': 'application/json' } : {}),
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
} catch {
throw new ApiError('Keine Verbindung zum Server. Prüfe deine Internetverbindung.', 0, 'network');
}
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new ApiError(data.error ?? `Fehler ${res.status}`, res.status, data.code);
return data;
}
+51
View File
@@ -0,0 +1,51 @@
export const FORMAT_LABEL = { bo1: 'Best of 1', bo3: 'Best of 3', bo5: 'Best of 5' };
export const ROLE_LABEL = {
organizer: 'Organisator',
captain: 'Captain',
player: 'Spieler',
spectator: 'Zuschauer',
public: 'Gast',
};
/** Serverstatus in Worte fassen. Simulierte Server werden nie als laufend bezeichnet. */
export function serverLabel(server) {
if (!server) return { text: 'Kein Server', tone: 'muted' };
const sim = server.simulated;
switch (server.status) {
case 'starting':
return { text: sim ? 'Demo: Start wird simuliert' : 'Server startet', tone: 'pending' };
case 'ready':
return { text: sim ? 'Demo: simuliert bereit' : 'Server bereit', tone: sim ? 'demo' : 'ok' };
case 'configuring':
return { text: sim ? 'Demo: Konfiguration simuliert' : 'Server wird konfiguriert', tone: 'pending' };
case 'configured':
return { text: sim ? 'Demo: simuliert konfiguriert' : 'Server läuft', tone: sim ? 'demo' : 'ok' };
case 'start_failed':
return { text: 'Serverstart fehlgeschlagen', tone: 'error' };
case 'config_failed':
return { text: 'Konfiguration fehlgeschlagen', tone: 'error' };
case 'failed':
return { text: 'Server ausgefallen', tone: 'error' };
case 'stopped':
return { text: 'Server gestoppt', tone: 'muted' };
default:
return { text: server.status, tone: 'muted' };
}
}
export const STEP_VERB = { ban: 'bannt eine Map', pick: 'wählt eine Map', side: 'wählt die Startseite', decider: 'Entscheidungsmap' };
export function timeAgo(ts) {
const s = Math.round((Date.now() - ts) / 1000);
if (s < 60) return 'gerade eben';
const m = Math.round(s / 60);
if (m < 60) return `vor ${m} Min.`;
const h = Math.round(m / 60);
if (h < 24) return `vor ${h} Std.`;
return new Date(ts).toLocaleDateString('de-DE');
}
export function clock(ts) {
return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
+34
View File
@@ -0,0 +1,34 @@
// Zugangsdaten pro Match im Browser. Nach Neuladen oder Verbindungsabbruch
// meldet sich der Client damit automatisch wieder an.
const KEY = (matchId) => `cs2mv:match:${matchId}`;
export function getAccess(matchId) {
try {
return JSON.parse(localStorage.getItem(KEY(matchId))) ?? null;
} catch {
return null;
}
}
export function setAccess(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) {
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;
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useRef, useState } from 'react';
import { io } from 'socket.io-client';
import { api } from './api.js';
let shared = null;
export function getSocket() {
if (!shared) shared = io({ path: '/socket.io', transports: ['websocket', 'polling'] });
return shared;
}
/** Abonniert den Live-Zustand eines Matches. Meldet sich nach jedem Reconnect neu an. */
export function useMatch(matchId, token) {
const [state, setState] = useState(null);
const [error, setError] = useState(null);
const [connected, setConnected] = useState(false);
const tokenRef = useRef(token);
tokenRef.current = token;
useEffect(() => {
const socket = getSocket();
const subscribe = () => socket.emit('match:subscribe', { matchId, token: tokenRef.current });
const onState = (s) => {
if (s.id !== matchId) return;
setState(s);
setError(null);
};
const onError = (e) => setError(e);
const onConnect = () => {
setConnected(true);
subscribe();
};
const onDisconnect = () => setConnected(false);
socket.on('match:state', onState);
socket.on('match:error', onError);
socket.on('connect', onConnect);
socket.on('disconnect', onDisconnect);
if (socket.connected) onConnect();
// Erster Stand per REST, falls der Socket noch verbindet; neuere Socket-Stände haben Vorrang.
let active = true;
api(`/matches/${matchId}`, { token })
.then((s) => active && setState((prev) => (prev && prev.updatedAt >= s.updatedAt ? prev : s)))
.catch((e) => active && setError({ error: e.message, code: e.code }));
return () => {
active = false;
socket.off('match:state', onState);
socket.off('match:error', onError);
socket.off('connect', onConnect);
socket.off('disconnect', onDisconnect);
};
}, [matchId, token]);
return { state, error, connected };
}
export function useOverview() {
const [matches, setMatches] = useState(null);
const [connected, setConnected] = useState(false);
useEffect(() => {
const socket = getSocket();
const onConnect = () => {
setConnected(true);
socket.emit('overview:subscribe');
};
const onOverview = (d) => setMatches(d.matches);
const onDisconnect = () => setConnected(false);
socket.on('overview', onOverview);
socket.on('connect', onConnect);
socket.on('disconnect', onDisconnect);
if (socket.connected) onConnect();
return () => {
socket.off('overview', onOverview);
socket.off('connect', onConnect);
socket.off('disconnect', onDisconnect);
};
}, []);
return { matches, connected };
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx';
import './styles.css';
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
+92
View File
@@ -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>
);
}
+124
View File
@@ -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>
);
}
+174
View File
@@ -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>
);
}
+97
View File
@@ -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>
);
}
+366
View File
@@ -0,0 +1,366 @@
/* ---------- Tokens ---------- */
:root {
--bg: #10151f;
--surface: #161d2a;
--surface-2: #1d2636;
--line: #2a3448;
--line-strong: #3a4760;
--text: #e6ebf2;
--muted: #8a96ab;
--team-a: #3cc6c0;
--team-b: #e86fa0;
--ct: #6ca8e8;
--t: #dda84a;
--ok: #49d48f;
--pending: #e8c25a;
--error: #f0605d;
--demo: #b39bff;
--display: 'Barlow Condensed', 'Arial Narrow', sans-serif;
--body: 'Barlow', system-ui, sans-serif;
--radius: 6px;
--chamfer: 14px;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
background: var(--bg);
color: var(--text);
font: 400 16px/1.5 var(--body);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 { font-family: var(--display); font-weight: 600; line-height: 1.1; margin: 0 0 .5em; letter-spacing: .01em; }
h1 { font-size: clamp(2rem, 4vw, 2.75rem); }
h2 { font-size: 1.375rem; }
h3 { font-size: 1.0625rem; }
p { margin: 0 0 .75em; }
a { color: var(--ct); }
code { font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; font-size: .875em; }
.muted { color: var(--muted); }
.small { font-size: .875rem; }
.lead { color: var(--muted); max-width: 60ch; font-size: 1.0625rem; }
:focus-visible { outline: 2px solid var(--ct); outline-offset: 2px; }
.tone-ok { color: var(--ok); }
.tone-pending { color: var(--pending); }
.tone-error { color: var(--error); }
.tone-demo { color: var(--demo); }
.tone-muted { color: var(--muted); }
.team-a { color: var(--team-a); }
.team-b { color: var(--team-b); }
/* ---------- Layout ---------- */
.topbar {
display: flex; align-items: center; gap: 16px;
padding: 12px clamp(16px, 4vw, 40px);
border-bottom: 1px solid var(--line);
background: var(--surface);
position: sticky; top: 0; z-index: 10;
}
.brand { display: flex; align-items: center; gap: 10px; color: var(--text); text-decoration: none; font: 700 1.25rem var(--display); }
.topbar nav { margin-left: auto; display: flex; align-items: center; gap: 16px; }
.nav-link { color: var(--muted); text-decoration: none; }
.nav-link:hover { color: var(--text); }
.demo-flag {
font-size: .8125rem; color: var(--demo); border: 1px solid color-mix(in srgb, var(--demo) 50%, transparent);
padding: 2px 10px; border-radius: 999px;
}
.content { max-width: 1320px; margin: 0 auto; padding: 32px clamp(16px, 4vw, 40px) 80px; }
/* ---------- Buttons & Felder ---------- */
.btn {
--btn: var(--surface-2);
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
font: 600 .9375rem var(--body);
color: var(--text); background: var(--btn);
border: 1px solid var(--line-strong); border-radius: var(--radius);
padding: 9px 16px; cursor: pointer; text-decoration: none;
transition: background .15s, border-color .15s;
}
.btn:hover:not(:disabled) { border-color: var(--muted); }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn-primary { background: var(--team-a); border-color: var(--team-a); color: #06201f; }
.btn-primary:hover:not(:disabled) { background: #5ad6d0; border-color: #5ad6d0; }
.btn-danger { background: var(--error); border-color: var(--error); color: #2a0606; }
.btn-ct { background: color-mix(in srgb, var(--ct) 22%, var(--surface)); border-color: var(--ct); }
.btn-t { background: color-mix(in srgb, var(--t) 22%, var(--surface)); border-color: var(--t); }
.btn-ghost { background: transparent; }
.btn-sm { padding: 5px 12px; font-size: .875rem; }
.btn-lg { padding: 13px 22px; font-size: 1.0625rem; }
.btn.danger, .link-btn.danger { color: var(--error); }
.link-btn { background: none; border: 0; padding: 0; color: var(--ct); font: inherit; font-size: .875rem; cursor: pointer; }
.link-btn:disabled { opacity: .5; cursor: not-allowed; }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; }
.field > span { font-weight: 600; font-size: .9375rem; }
input, select {
font: inherit; color: var(--text); background: var(--bg);
border: 1px solid var(--line-strong); border-radius: var(--radius); padding: 10px 12px; min-width: 0;
}
input:focus, select:focus { border-color: var(--ct); outline: none; }
.hint { color: var(--muted); font-size: .8125rem; }
.field-error { color: var(--error); font-size: .875rem; margin-top: 6px; }
.row { display: flex; gap: 10px; flex-wrap: wrap; }
.alert { border: 1px solid var(--line-strong); background: var(--surface); padding: 12px 16px; border-radius: var(--radius); margin-bottom: 16px; }
.alert-error { border-color: var(--error); color: #ffc9c7; }
.alert-demo { border-color: color-mix(in srgb, var(--demo) 55%, transparent); background: color-mix(in srgb, var(--demo) 9%, var(--surface)); color: #ded3ff; font-size: .875rem; }
.empty { text-align: center; padding: 80px 16px; }
/* ---------- Übersicht ---------- */
.overview-head { display: flex; gap: 32px; justify-content: space-between; align-items: flex-end; flex-wrap: wrap; margin-bottom: 40px; }
.code-entry { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px; width: min(100%, 380px); }
.code-entry label { display: block; font-weight: 600; margin-bottom: 8px; }
.code-entry input { flex: 1; text-transform: uppercase; letter-spacing: .08em; }
.groups { display: grid; gap: 40px; }
.group h2 { display: flex; align-items: baseline; gap: 10px; }
.count { font: 500 .9375rem var(--body); color: var(--muted); }
.match-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
.match-row {
display: flex; flex-wrap: wrap; gap: 8px 24px; align-items: center; justify-content: space-between;
padding: 14px 4px; border-bottom: 1px solid var(--line); color: var(--text); text-decoration: none;
}
.match-row:hover { background: var(--surface); }
.match-row-teams { font: 600 1.375rem var(--display); display: flex; gap: 10px; align-items: baseline; }
.vs { color: var(--muted); font: 500 .9375rem var(--body); }
.vs.big { font-size: 1.25rem; align-self: center; }
.match-row-meta { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: .875rem; color: var(--muted); }
.mine { color: var(--team-a); }
.group-closed .match-row-teams { opacity: .7; }
/* ---------- Match erstellen ---------- */
.create, .join-card { max-width: 720px; }
.create-teams { display: grid; grid-template-columns: 1fr auto 1fr; gap: 16px; align-items: end; }
.team-field-a input { border-left: 4px solid var(--team-a); }
.team-field-b input { border-left: 4px solid var(--team-b); }
.choice { border: 0; padding: 0; margin: 8px 0 22px; }
.choice legend { font-weight: 600; margin-bottom: 8px; }
.choice-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; }
.choice-card {
position: relative; display: flex; flex-direction: column; gap: 2px;
padding: 14px 16px; border: 1px solid var(--line-strong); border-radius: var(--radius); background: var(--surface); cursor: pointer;
}
.choice-card input { position: absolute; opacity: 0; pointer-events: none; }
.choice-card strong { font: 600 1.375rem var(--display); }
.choice-card span { color: var(--muted); font-size: .875rem; }
.choice-card small { color: var(--muted); }
.choice-card.is-on { border-color: var(--team-a); box-shadow: inset 0 0 0 1px var(--team-a); }
.choice-card.is-off { opacity: .45; cursor: not-allowed; }
.choice-card:has(input:focus-visible) { outline: 2px solid var(--ct); outline-offset: 2px; }
.flow-preview { list-style: none; display: flex; flex-wrap: wrap; gap: 4px; padding: 0; margin: 12px 0 0; }
.flow-step { font-size: .75rem; padding: 3px 8px; border-radius: 3px; background: var(--surface-2); display: flex; flex-direction: column; line-height: 1.2; }
.flow-step small { color: var(--muted); font-size: .6875rem; }
.flow-ban { box-shadow: inset 0 -2px 0 var(--error); }
.flow-pick { box-shadow: inset 0 -2px 0 var(--ok); }
.flow-decider { box-shadow: inset 0 -2px 0 var(--text); }
.join-card { margin: 0 auto; background: var(--surface); border: 1px solid var(--line); border-top: 4px solid var(--team-a); padding: 28px; border-radius: var(--radius); }
.join-card.team-B { border-top-color: var(--team-b); }
/* ---------- Match-Seite ---------- */
.match-head { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px 40px; align-items: flex-end; margin-bottom: 28px; }
.versus h1 { display: flex; flex-wrap: wrap; align-items: baseline; gap: .1em .4em; font-size: clamp(2.25rem, 5.5vw, 4rem); font-weight: 700; margin: 0; }
.match-facts { display: flex; flex-wrap: wrap; gap: 12px 28px; margin: 0; }
.match-facts dt { color: var(--muted); font-size: .8125rem; }
.match-facts dd { margin: 0; font-weight: 600; }
.match-grid { display: grid; grid-template-columns: minmax(220px, 280px) minmax(0, 1fr) minmax(220px, 280px); gap: 20px; align-items: start; }
.stage { min-width: 0; }
.stage-note { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 24px; }
.stage-note .btn { margin: 8px 0 20px; }
.pool-preview { display: flex; flex-wrap: wrap; gap: 6px; }
.pool-chip { padding: 4px 10px; border-left: 3px solid var(--map); background: var(--surface-2); font-size: .875rem; border-radius: 3px; }
.team-card { --team: var(--team-a); background: var(--surface); border: 1px solid var(--line); border-top: 4px solid var(--team); border-radius: var(--radius); padding: 16px; }
.team-card.team-B { --team: var(--team-b); }
.team-card header { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.team-card h2 { color: var(--team); font-size: 1.625rem; margin: 0 0 10px; overflow-wrap: anywhere; }
.ready-state { font-size: .8125rem; color: var(--muted); white-space: nowrap; }
.ready-state.on { color: var(--ok); font-weight: 600; }
.roster { list-style: none; padding: 0; margin: 0 0 12px; }
.roster li { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; padding: 8px 0; border-bottom: 1px solid var(--line); }
.roster li.is-you .nick { color: var(--team); }
.nick { font-weight: 600; flex: 1; min-width: 0; overflow-wrap: anywhere; }
.nick small { font-weight: 400; color: var(--muted); }
.presence { width: 8px; height: 8px; border-radius: 50%; background: var(--line-strong); flex: none; }
.presence.on { background: var(--ok); }
.captain { font-size: .75rem; padding: 1px 6px; border: 1px solid var(--team); color: var(--team); border-radius: 3px; }
.ready-dot { font-size: .75rem; color: var(--muted); }
.ready-dot.on { color: var(--ok); }
.row-actions { flex-basis: 100%; display: flex; gap: 14px; padding-left: 16px; }
.slot-empty { color: var(--muted); font-style: italic; font-size: .9375rem; border-bottom-style: dashed !important; }
.team-actions { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
.team-card.is-ready { box-shadow: 0 0 0 1px color-mix(in srgb, var(--team) 35%, transparent); }
/* ---------- Veto ---------- */
.veto { display: grid; gap: 16px; }
.turn {
--team: var(--team-a);
display: grid; grid-template-columns: auto 1fr; gap: 2px 12px; align-items: baseline;
padding: 14px 18px; background: color-mix(in srgb, var(--team) 10%, var(--surface));
border-left: 4px solid var(--team); border-radius: var(--radius);
}
.turn-B { --team: var(--team-b); }
.turn-team { font: 700 1.75rem var(--display); color: var(--team); }
.turn-action { font: 600 1.375rem var(--display); }
.turn-hint { grid-column: 1 / -1; color: var(--muted); font-size: .9375rem; }
.turn.is-you { box-shadow: 0 0 0 1px var(--team); }
.turn.is-you .turn-hint { color: var(--text); font-weight: 600; }
.turn-done { font: 600 1.375rem var(--display); --team: var(--ok); }
.map-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
.map-tile {
--by: var(--line-strong);
position: relative; display: flex; flex-direction: column; justify-content: flex-end; text-align: left;
aspect-ratio: 4 / 3; padding: 12px; border: 0; color: var(--text); font: inherit; cursor: default;
background: var(--surface-2);
clip-path: polygon(0 0, calc(100% - var(--chamfer)) 0, 100% var(--chamfer), 100% 100%, var(--chamfer) 100%, 0 calc(100% - var(--chamfer)));
transition: transform .15s, filter .25s;
}
.map-tile.by-A { --by: var(--team-a); }
.map-tile.by-B { --by: var(--team-b); }
.map-art {
position: absolute; inset: 0; z-index: 0;
background:
repeating-linear-gradient(135deg, transparent 0 9px, color-mix(in srgb, var(--map) 16%, transparent) 9px 10px),
radial-gradient(120% 90% at 85% 0%, color-mix(in srgb, var(--map) 70%, transparent), transparent 60%),
linear-gradient(180deg, color-mix(in srgb, var(--map) 35%, var(--surface-2)), var(--surface) 85%);
}
.map-name, .map-status { position: relative; z-index: 1; }
.map-name { font: 700 1.625rem/1 var(--display); text-shadow: 0 1px 8px rgba(0, 0, 0, .5); }
.map-status { font-size: .8125rem; color: color-mix(in srgb, var(--text) 75%, transparent); margin-top: 4px; }
.map-tile:not(:disabled) { cursor: pointer; }
.map-tile:not(:disabled):hover { transform: translateY(-2px); }
.map-tile:focus-visible { outline: none; }
.map-tile:focus-visible::after { content: ''; position: absolute; inset: 0; z-index: 3; box-shadow: inset 0 0 0 3px var(--ct); }
/* Gebannt: entsättigt, mit diagonalem Strich in der Farbe des bannenden Teams */
.map-tile.status-banned { filter: grayscale(.9) brightness(.6); }
.map-tile.status-banned::after {
content: ''; position: absolute; inset: 0; z-index: 2; pointer-events: none;
background: linear-gradient(to top right, transparent calc(50% - 3px), var(--by) calc(50% - 3px), var(--by) calc(50% + 3px), transparent calc(50% + 3px));
}
.map-tile.status-banned .map-status { color: var(--text); }
/* Gewählt, Entscheidungsmap, Auswahl: Rahmen als Overlay über dem Map-Bild */
.map-tile.status-picked::after, .map-tile.status-decider::after, .map-tile.is-selected::after, .map-tile.is-side::after {
content: ''; position: absolute; inset: 0; z-index: 2; pointer-events: none;
box-shadow: inset 0 0 0 3px var(--frame, var(--by));
}
.map-tile.status-picked .map-status { color: var(--by); font-weight: 600; }
.map-tile.status-decider { --by: var(--text); }
.map-tile.status-decider .map-status { font-weight: 600; }
.map-tile.is-selected { transform: translateY(-2px); }
.map-tile.is-selected.sel-ban { --frame: var(--error); }
.map-tile.is-selected.sel-pick { --frame: var(--ok); }
.map-tile.is-side::after { animation: side-pulse 1.6s ease-in-out infinite; }
@keyframes side-pulse { 50% { box-shadow: inset 0 0 0 3px var(--pending); } }
.veto-confirm .btn { width: 100%; }
.side-choice { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px; }
.side-choice .btn { flex: 1; }
.step-track { list-style: none; display: flex; flex-wrap: wrap; gap: 4px; padding: 0; margin: 0; }
.step {
--team: var(--muted);
font-size: .75rem; padding: 4px 8px; border-radius: 3px; background: var(--surface);
border-bottom: 2px solid color-mix(in srgb, var(--team) 45%, transparent); color: var(--muted);
}
.step-A { --team: var(--team-a); }
.step-B { --team: var(--team-b); }
.step.done { color: var(--text); background: var(--surface-2); border-bottom-color: var(--team); }
.step.now { color: var(--bg); background: var(--team); border-bottom-color: var(--team); font-weight: 600; }
.step-decider.now, .step-decider.done { --team: var(--text); }
.veto-history { list-style: none; padding: 0; margin: 0; font-size: .9375rem; border-top: 1px solid var(--line); }
.veto-history li { padding: 6px 0 6px 12px; border-bottom: 1px solid var(--line); border-left: 3px solid transparent; }
.veto-history li.hist-A { border-left-color: var(--team-a); }
.veto-history li.hist-B { border-left-color: var(--team-b); }
.veto-history time, .event-log time { color: var(--muted); font-size: .8125rem; margin-right: 8px; font-variant-numeric: tabular-nums; }
.veto-compact .map-grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; }
.veto-compact .map-tile { aspect-ratio: 16 / 9; padding: 8px; --chamfer: 10px; }
.veto-compact .map-name { font-size: 1.125rem; }
/* ---------- Serie ---------- */
.series { margin-bottom: 20px; }
.series-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
.series-item {
display: grid; grid-template-columns: auto 1fr auto; gap: 2px 16px; align-items: baseline;
padding: 14px 16px; background: var(--surface); border-left: 4px solid var(--map); border-radius: var(--radius);
}
.series-no { color: var(--muted); font-size: .875rem; }
.series-map { font: 700 1.625rem var(--display); }
.series-status { font-size: .875rem; color: var(--muted); text-align: right; grid-column: 3; grid-row: 1; }
.series-origin, .series-sides { grid-column: 2 / -1; font-size: .875rem; color: var(--muted); }
.series-item.is-live { box-shadow: inset 0 0 0 1px var(--map); }
.series-item.is-live .series-status, .series-item.is-next .series-status { color: var(--pending); font-weight: 600; }
.series-item.is-done { opacity: .6; }
.side-ct { color: var(--ct); }
.side-t { color: var(--t); margin-left: 12px; }
/* ---------- Panels unten ---------- */
.match-lower { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; margin-top: 28px; align-items: start; }
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 18px; min-width: 0; }
.server-status { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; font-size: 1.0625rem; }
.status-dot { width: 10px; height: 10px; border-radius: 50%; background: currentColor; flex: none; }
.status-dot.pulse { animation: pulse 1.2s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: .3; } }
.tone-border-error { border-color: color-mix(in srgb, var(--error) 60%, transparent); }
.connect { border-top: 1px solid var(--line); padding-top: 14px; margin-top: 14px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; justify-items: start; }
.connect .copy-field { width: 100%; }
.org-controls { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.copy-field { display: grid; grid-template-columns: minmax(0, 1fr); gap: 4px; }
.copy-label { font-size: .8125rem; color: var(--muted); }
.copy-row { display: flex; gap: 8px; align-items: center; }
.copy-row code { flex: 1; min-width: 0; background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius); padding: 8px 10px; overflow-x: auto; white-space: nowrap; }
.copy-row code.blurred { filter: blur(5px); cursor: pointer; user-select: none; }
.invite { padding: 12px 0; border-top: 1px solid var(--line); border-left: 3px solid transparent; padding-left: 12px; }
.invite.team-A { border-left-color: var(--team-a); }
.invite.team-B { border-left-color: var(--team-b); }
.invite.spectator { border-left-color: var(--muted); }
.invite-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 12px; margin-bottom: 6px; }
.invite-code { font: 600 1.125rem var(--display); letter-spacing: .1em; }
.invite-head .link-btn { margin-left: auto; }
.org-link { margin-top: 12px; }
.org-link summary { cursor: pointer; color: var(--muted); font-size: .875rem; }
.event-log ol { list-style: none; padding: 0; margin: 0; max-height: 360px; overflow-y: auto; font-size: .875rem; }
.event-log li { display: flex; gap: 4px; padding: 5px 0; border-bottom: 1px solid var(--line); }
.event-log li.ev-server span { color: color-mix(in srgb, var(--text) 80%, var(--ct)); }
.toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 50;
padding: 12px 18px; border-radius: var(--radius); background: var(--surface-2); border: 1px solid var(--line-strong);
max-width: min(560px, calc(100vw - 32px)); box-shadow: 0 8px 30px rgba(0, 0, 0, .45);
}
.toast-error { border-color: var(--error); }
.toast-ok { border-color: var(--ok); }
.reconnect { background: var(--pending); color: #2a2000; padding: 8px 14px; border-radius: var(--radius); margin-bottom: 16px; font-weight: 600; }
/* ---------- Responsiv ---------- */
@media (max-width: 1040px) {
.match-grid { grid-template-columns: 1fr 1fr; }
.stage { grid-column: 1 / -1; grid-row: 1; }
}
@media (max-width: 640px) {
.match-grid { grid-template-columns: 1fr; }
.topbar { flex-wrap: wrap; gap: 8px 16px; }
.demo-flag { order: 3; }
.create-teams { grid-template-columns: 1fr; }
.vs.big { justify-self: center; }
.map-grid { grid-template-columns: repeat(2, 1fr); }
.series-item { grid-template-columns: 1fr auto; }
.series-no { grid-column: 1 / -1; }
.series-status { grid-column: 2; grid-row: 2; }
.series-origin, .series-sides { grid-column: 1 / -1; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}