359 lines
12 KiB
TypeScript
359 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|