feat: add setup button to edit game details

This commit is contained in:
Frank Schwenk
2026-09-22 16:04:47 +02:00
parent 678977ab23
commit c876a80b7e
11 changed files with 814 additions and 7 deletions
@@ -0,0 +1,358 @@
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>
);
}