Files
bscscore/src/lib/features/game-detail/GameSetupModal.tsx
T
2026-09-22 16:04:47 +02:00

359 lines
12 KiB
TypeScript
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 { h } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import modalStyles from '@lib/ui/Modal.module.css';
import styles from './GameSetupModal.module.css';
import { Button } from '@lib/ui/Button';
import { GAME_TYPES, RACE_TO_INFINITY, RACE_TO_QUICK_PICKS } from '@lib/domain/constants';
import { gameToSetupData } from '@lib/domain/gameUtils';
import { validateGameSetupData } from '@lib/domain/validation';
import type { BreakRule, GameSetupData, GameType, StandardGame } from '@lib/domain/types';
interface GameSetupModalProps {
game: StandardGame;
open: boolean;
onSave: (setupData: GameSetupData) => void;
onCancel: () => void;
}
type RaceToState = number | typeof RACE_TO_INFINITY;
interface FormState {
player1: string;
player2: string;
player3: string;
hasPlayer3: boolean;
score1: number;
score2: number;
score3: number;
gameType: GameType;
raceTo: RaceToState;
raceToInput: string;
breakRule: BreakRule;
breakOrder: number[];
currentBreakerIdx: number;
}
function setupToFormState(setup: GameSetupData): FormState {
const hasPlayer3 = Boolean(setup.player3?.trim());
const raceToIsInfinity = setup.raceTo === Infinity;
return {
player1: setup.player1,
player2: setup.player2,
player3: setup.player3 ?? '',
hasPlayer3,
score1: setup.score1,
score2: setup.score2,
score3: setup.score3 ?? 0,
gameType: setup.gameType,
raceTo: raceToIsInfinity ? RACE_TO_INFINITY : setup.raceTo,
raceToInput: raceToIsInfinity ? '' : String(setup.raceTo),
breakRule: setup.breakRule,
breakOrder: [...setup.breakOrder],
currentBreakerIdx: setup.currentBreakerIdx,
};
}
function parseRaceToValue(raceTo: RaceToState, raceToInput: string): number {
if (raceTo === RACE_TO_INFINITY) return Infinity;
if (typeof raceTo === 'number' && Number.isFinite(raceTo)) return raceTo;
const parsed = parseInt(raceToInput, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
function formToSetupData(form: FormState): GameSetupData {
const data: GameSetupData = {
player1: form.player1,
player2: form.player2,
score1: form.score1,
score2: form.score2,
gameType: form.gameType,
raceTo: parseRaceToValue(form.raceTo, form.raceToInput),
breakRule: form.breakRule,
breakOrder: form.breakOrder,
currentBreakerIdx: form.currentBreakerIdx,
};
if (form.hasPlayer3) {
data.player3 = form.player3;
data.score3 = form.score3;
}
return data;
}
function choiceClass(selected: boolean): string {
return `${styles.choiceBtn} ${selected ? styles.choiceBtnSelected : ''}`;
}
export function GameSetupModal({ game, open, onSave, onCancel }: GameSetupModalProps) {
const [form, setForm] = useState<FormState>(() => setupToFormState(gameToSetupData(game)));
const [errors, setErrors] = useState<string[]>([]);
useEffect(() => {
if (open) {
setForm(setupToFormState(gameToSetupData(game)));
setErrors([]);
}
}, [open, game]);
if (!open) return null;
const activeBreakerPlayer = form.breakOrder[form.currentBreakerIdx] ?? form.breakOrder[0];
const playerLabels = [
{ num: 1, name: form.player1, score: form.score1 },
{ num: 2, name: form.player2, score: form.score2 },
...(form.hasPlayer3 ? [{ num: 3, name: form.player3, score: form.score3 }] : []),
];
const updateForm = (patch: Partial<FormState>) => setForm((prev) => ({ ...prev, ...patch }));
const togglePlayer3 = () => {
if (form.hasPlayer3) {
const order = [1, 2];
let idx = form.currentBreakerIdx;
const currentPlayer = form.breakOrder[idx];
if (currentPlayer === 3) idx = 0;
else idx = Math.min(idx, order.length - 1);
updateForm({
hasPlayer3: false,
player3: '',
score3: 0,
breakOrder: order,
currentBreakerIdx: idx,
});
} else {
const order = [1, 2, 3];
updateForm({
hasPlayer3: true,
player3: 'Spieler 3',
score3: 0,
breakOrder: order,
currentBreakerIdx: Math.min(form.currentBreakerIdx, order.length - 1),
});
}
};
const selectBreaker = (playerNum: number) => {
const idx = form.breakOrder.indexOf(playerNum);
if (idx >= 0) updateForm({ currentBreakerIdx: idx });
};
const handleRaceQuickPick = (value: number) => {
updateForm({ raceTo: value, raceToInput: String(value) });
};
const handleRaceInfinity = () => {
updateForm({ raceTo: RACE_TO_INFINITY, raceToInput: '' });
};
const handleRaceInput = (e: Event) => {
const value = (e.target as HTMLInputElement).value;
const parsed = parseInt(value, 10);
if (value === '') {
updateForm({ raceToInput: '', raceTo: 0 });
return;
}
if (!Number.isNaN(parsed)) {
updateForm({ raceToInput: value, raceTo: parsed });
}
};
const handleSave = () => {
const data = formToSetupData(form);
const result = validateGameSetupData(data);
if (!result.isValid) {
setErrors(result.errors);
return;
}
setErrors([]);
onSave(data);
};
const raceToNumber =
form.raceTo === RACE_TO_INFINITY ? null : typeof form.raceTo === 'number' ? form.raceTo : parseInt(form.raceToInput, 10);
return (
<div
className={`${modalStyles.modal} ${modalStyles.show}`}
role="dialog"
aria-modal="true"
aria-labelledby="game-setup-modal-title"
>
<div className={`${modalStyles['modal-content']} ${styles.modalContent}`}>
<div className={modalStyles['modal-header']}>
<span className={modalStyles['modal-title']} id="game-setup-modal-title">
Spiel einrichten
</span>
<button
type="button"
className={modalStyles['close-button']}
onClick={onCancel}
aria-label="Schließen"
>
×
</button>
</div>
<div className={modalStyles['modal-body']}>
<Button
variant="secondary"
size="medium"
className={styles.togglePlayerBtn}
onClick={togglePlayer3}
type="button"
>
{form.hasPlayer3 ? '3. Spieler entfernen' : '3. Spieler hinzufügen'}
</Button>
{playerLabels.map(({ num, name, score }) => (
<div key={num} className={styles.playerRow}>
<span className={styles.sectionTitle}>Spieler {num}</span>
<div className={styles.fieldRow}>
<input
type="text"
className={styles.textInput}
value={num === 1 ? form.player1 : num === 2 ? form.player2 : form.player3}
onInput={(e) => {
const v = (e.target as HTMLInputElement).value;
if (num === 1) updateForm({ player1: v });
else if (num === 2) updateForm({ player2: v });
else updateForm({ player3: v });
}}
aria-label={`Name Spieler ${num}`}
autoComplete="off"
/>
<input
type="number"
min={0}
step={1}
inputMode="numeric"
className={styles.numberInput}
value={score}
onInput={(e) => {
const v = parseInt((e.target as HTMLInputElement).value, 10);
const safe = Number.isNaN(v) ? 0 : Math.max(0, v);
if (num === 1) updateForm({ score1: safe });
else if (num === 2) updateForm({ score2: safe });
else updateForm({ score3: safe });
}}
aria-label={`Punkte Spieler ${num}`}
/>
</div>
</div>
))}
<section className={styles.section} aria-labelledby="setup-game-type">
<span className={styles.sectionTitle} id="setup-game-type">Spieltyp</span>
<div className={styles.choiceRow}>
{GAME_TYPES.map(({ value, label }) => (
<button
key={value}
type="button"
className={choiceClass(form.gameType === value)}
onClick={() => updateForm({ gameType: value })}
>
{label}
</button>
))}
</div>
</section>
<section className={styles.section} aria-labelledby="setup-race-to">
<span className={styles.sectionTitle} id="setup-race-to">Race To</span>
<div className={styles.endlosRow}>
<button
type="button"
className={choiceClass(form.raceTo === RACE_TO_INFINITY)}
onClick={handleRaceInfinity}
>
Endlos
</button>
</div>
<div className={styles.raceGrid}>
{RACE_TO_QUICK_PICKS.map((value) => (
<button
key={value}
type="button"
className={choiceClass(raceToNumber === value)}
onClick={() => handleRaceQuickPick(value)}
>
{value}
</button>
))}
</div>
<div className={styles.fieldRow} style={{ marginTop: 'var(--space-sm)' }}>
<input
type="number"
min={1}
step={1}
inputMode="numeric"
className={styles.textInput}
value={form.raceTo === RACE_TO_INFINITY ? '' : form.raceToInput}
onInput={handleRaceInput}
placeholder="Eigener Wert"
aria-label="Race To manuelle Eingabe"
/>
</div>
</section>
<section className={styles.section} aria-labelledby="setup-break-rule">
<span className={styles.sectionTitle} id="setup-break-rule">Break-Regel</span>
<div className={styles.choiceRow}>
{(
[
{ key: 'winnerbreak' as BreakRule, label: 'Winnerbreak' },
{ key: 'wechselbreak' as BreakRule, label: 'Wechselbreak' },
] as const
).map((opt) => (
<button
key={opt.key}
type="button"
className={choiceClass(form.breakRule === opt.key)}
onClick={() => updateForm({ breakRule: opt.key })}
>
{opt.label}
</button>
))}
</div>
</section>
<section className={styles.section} aria-labelledby="setup-breaker">
<span className={styles.sectionTitle} id="setup-breaker">Wer breakt?</span>
<div className={styles.choiceRow}>
{playerLabels.map(({ num, name }) => (
<button
key={num}
type="button"
className={choiceClass(activeBreakerPlayer === num)}
onClick={() => selectBreaker(num)}
>
{name.trim() || `Spieler ${num}`}
</button>
))}
</div>
</section>
{errors.length > 0 && (
<div className={styles.errors} role="alert">
<ul>
{errors.map((err) => (
<li key={err}>{err}</li>
))}
</ul>
</div>
)}
</div>
<div className={modalStyles['modal-footer']}>
<div className={styles.footerActions}>
<Button variant="secondary" size="large" onClick={onCancel} type="button">
Abbrechen
</Button>
<Button variant="primary" size="large" onClick={handleSave} type="button">
Übernehmen
</Button>
</div>
</div>
</div>
</div>
);
}