Files
Cs2-Mapveto/client/src/components/VetoBoard.jsx
T
SPP UserandClaude Opus 5.5 d560c15fef Veto-Timer als Leiste in Teamfarbe
Die verbleibende Zeit des Veto-Schritts erscheint als Leiste mit
Sekundenanzeige, in den letzten 10 Sekunden rot. Der Server schickt
die Schrittdauer mit, damit die Leiste zu VETO_STEP_SECONDS passt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:02:46 +01:00

159 lines
6.8 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import { clock } from '../lib/labels.js';
import MapImage from './MapImage.jsx';
import SideIcon from './SideIcon.jsx';
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 left = useCountdown(veto.remainingMs);
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
const yourTurn = !!current && viewer.isCaptain && 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.team
? 'Live-Ansicht – nur Captains können wählen.'
: 'Das andere Team ist am Zug.'}
</span>
{left !== null && <TimerBar left={left} totalMs={(veto.stepSeconds || 30) * 1000} />}
</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)}`}
>
<MapImage map={meta} />
<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">
<MapImage map={sideMap} className="side-choice-art" />
<div className="side-choice-body">
<p>Auf welcher Seite startet <strong>{teams[viewer.team].name}</strong> auf <strong>{sideMap?.name}</strong>?</p>
<div className="side-options">
<button className="side-option side-option-ct" disabled={!!busy} onClick={() => submitSide('CT')}>
<SideIcon side="CT" size={44} />
<span className="side-option-name">Counter-Terrorists</span>
<span className="side-option-sub">Start auf CT, Gegner beginnt als T</span>
</button>
<button className="side-option side-option-t" disabled={!!busy} onClick={() => submitSide('T')}>
<SideIcon side="T" size={44} />
<span className="side-option-name">Terrorists</span>
<span className="side-option-sub">Start auf T, Gegner beginnt als CT</span>
</button>
</div>
</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>
<small>{s.team ? teams[s.team].name : 'automatisch'}</small>
</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';
}
}
/** Leiste für die verbleibende Zeit des Veto-Schritts. In den letzten 10 Sekunden wird sie rot. */
function TimerBar({ left, totalMs }) {
const seconds = Math.ceil(left / 1000);
const percent = Math.max(0, Math.min(100, (left / totalMs) * 100));
return (
<div className={`turn-timer ${left <= 10_000 ? 'is-low' : ''}`} role="timer" aria-label={`Noch ${seconds} Sekunden`}>
<div className="turn-bar"><span style={{ width: `${percent}%` }} /></div>
<span className="turn-seconds">{seconds} s</span>
</div>
);
}
/** Verbleibende Zeit des Veto-Schritts: der Server setzt sie bei jeder Änderung, danach zählt die Anzeige lokal herunter. */
function useCountdown(ms) {
const [left, setLeft] = useState(ms ?? null);
useEffect(() => {
setLeft(ms ?? null);
if (ms == null) return undefined;
const started = Date.now();
const t = setInterval(() => setLeft(Math.max(0, ms - (Date.now() - started))), 250);
return () => clearInterval(t);
}, [ms]);
return left;
}