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

+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';
}
}