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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user