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:
1 parent
f08297dd0d
commit
f7520a6ca7
24 files changed
+1938
-1
No files matched your search
@@ -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