feat: add setup button to edit game details
This commit is contained in:
@@ -38,6 +38,11 @@ export const VALIDATION_MESSAGES = {
|
||||
RACE_TO_REQUIRED: 'Race-to Wert ist erforderlich',
|
||||
RACE_TO_INVALID: 'Race-to Wert muss eine positive Zahl sein',
|
||||
DUPLICATE_PLAYER_NAMES: 'Spielernamen müssen eindeutig sein',
|
||||
SCORE_INVALID: 'Punkte müssen ganze Zahlen ≥ 0 sein',
|
||||
BREAK_RULE_REQUIRED: 'Break-Regel ist erforderlich',
|
||||
BREAK_ORDER_INVALID: 'Break-Reihenfolge ist ungültig',
|
||||
CURRENT_BREAKER_INVALID: 'Aktueller Breaker ist ungültig',
|
||||
RACE_TO_BELOW_SCORE: 'Race-to muss mindestens dem höchsten Punktestand entsprechen',
|
||||
} as const;
|
||||
|
||||
export const BREAKPOINTS = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Game, StandardGame, EndlosGame } from './types';
|
||||
import type { Game, GameSetupData, StandardGame, EndlosGame } from './types';
|
||||
|
||||
/**
|
||||
* Game utility functions for common operations
|
||||
@@ -12,6 +12,97 @@ export function isStandardGame(game: Game): game is StandardGame {
|
||||
return !('players' in game);
|
||||
}
|
||||
|
||||
function setupActivePlayerCount(data: GameSetupData): number {
|
||||
return data.player3?.trim() ? 3 : 2;
|
||||
}
|
||||
|
||||
function defaultBreakOrder(playerCount: number): number[] {
|
||||
return playerCount === 3 ? [1, 2, 3] : [1, 2];
|
||||
}
|
||||
|
||||
function normalizeBreakFields(
|
||||
data: GameSetupData,
|
||||
playerCount: number,
|
||||
): { breakOrder: number[]; currentBreakerIdx: number } {
|
||||
const expectedPlayers = playerCount === 3 ? [1, 2, 3] : [1, 2];
|
||||
const order = data.breakOrder ?? [];
|
||||
const orderValid =
|
||||
order.length === playerCount &&
|
||||
new Set(order).size === playerCount &&
|
||||
order.every((p) => expectedPlayers.includes(p)) &&
|
||||
expectedPlayers.every((p) => order.includes(p));
|
||||
|
||||
if (!orderValid) {
|
||||
return { breakOrder: defaultBreakOrder(playerCount), currentBreakerIdx: 0 };
|
||||
}
|
||||
|
||||
const idx = data.currentBreakerIdx;
|
||||
if (!Number.isInteger(idx) || idx < 0 || idx >= order.length) {
|
||||
return { breakOrder: order, currentBreakerIdx: 0 };
|
||||
}
|
||||
|
||||
return { breakOrder: order, currentBreakerIdx: idx };
|
||||
}
|
||||
|
||||
export function gameToSetupData(game: StandardGame): GameSetupData {
|
||||
const playerCount = game.player3?.trim() ? 3 : 2;
|
||||
const breakOrder = game.breakOrder ?? defaultBreakOrder(playerCount);
|
||||
|
||||
return {
|
||||
player1: game.player1,
|
||||
player2: game.player2,
|
||||
player3: game.player3,
|
||||
score1: game.score1,
|
||||
score2: game.score2,
|
||||
score3: game.score3,
|
||||
gameType: game.gameType,
|
||||
raceTo: game.raceTo,
|
||||
breakRule: game.breakRule ?? 'winnerbreak',
|
||||
breakOrder,
|
||||
currentBreakerIdx: game.currentBreakerIdx ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function applySetupData(game: StandardGame, data: GameSetupData): StandardGame {
|
||||
const playerCount = setupActivePlayerCount(data);
|
||||
const { breakOrder, currentBreakerIdx } = normalizeBreakFields(data, playerCount);
|
||||
|
||||
const scores = [data.score1, data.score2];
|
||||
if (playerCount === 3) {
|
||||
scores.push(data.score3 ?? 0);
|
||||
}
|
||||
|
||||
let status = game.status;
|
||||
if (data.raceTo !== Infinity && scores.some((score) => score >= data.raceTo)) {
|
||||
status = 'completed';
|
||||
}
|
||||
|
||||
const updated: StandardGame = {
|
||||
...game,
|
||||
player1: data.player1.trim(),
|
||||
player2: data.player2.trim(),
|
||||
score1: data.score1,
|
||||
score2: data.score2,
|
||||
gameType: data.gameType,
|
||||
raceTo: data.raceTo,
|
||||
breakRule: data.breakRule,
|
||||
breakOrder,
|
||||
currentBreakerIdx,
|
||||
status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (playerCount === 3 && data.player3?.trim()) {
|
||||
updated.player3 = data.player3.trim();
|
||||
updated.score3 = data.score3 ?? 0;
|
||||
} else {
|
||||
delete updated.player3;
|
||||
delete updated.score3;
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function formatGameTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
|
||||
@@ -71,4 +71,18 @@ export interface NewGameData {
|
||||
|
||||
export type NewGameStep = 'player1' | 'player2' | 'player3' | 'gameType' | 'raceTo' | 'breakRule' | 'breakOrder' | null;
|
||||
|
||||
export interface GameSetupData {
|
||||
player1: string;
|
||||
player2: string;
|
||||
player3?: string;
|
||||
score1: number;
|
||||
score2: number;
|
||||
score3?: number;
|
||||
gameType: GameType;
|
||||
raceTo: number;
|
||||
breakRule: BreakRule;
|
||||
breakOrder: number[];
|
||||
currentBreakerIdx: number;
|
||||
}
|
||||
|
||||
export type GameFilter = 'all' | 'active' | 'completed';
|
||||
@@ -1,5 +1,5 @@
|
||||
import { APP_CONFIG, VALIDATION_MESSAGES } from './constants';
|
||||
import type { NewGameData } from './types';
|
||||
import type { GameSetupData, NewGameData } from './types';
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
@@ -80,6 +80,84 @@ export function sanitizePlayerName(name: string): string {
|
||||
.replace(/[^\w\s-]/g, ''); // Remove special characters except spaces and hyphens
|
||||
}
|
||||
|
||||
function activePlayerCountFromSetup(data: GameSetupData): number {
|
||||
return data.player3?.trim() ? 3 : 2;
|
||||
}
|
||||
|
||||
function isValidScore(value: number | undefined, required: boolean): boolean {
|
||||
if (value === undefined) return !required;
|
||||
return Number.isInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
export function validateGameSetupData(data: GameSetupData): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
const player1Validation = validatePlayerName(data.player1);
|
||||
const player2Validation = validatePlayerName(data.player2);
|
||||
errors.push(...player1Validation.errors);
|
||||
errors.push(...player2Validation.errors);
|
||||
|
||||
const playerNames = [data.player1.trim(), data.player2.trim()];
|
||||
const hasThirdPlayer = Boolean(data.player3?.trim());
|
||||
|
||||
if (hasThirdPlayer) {
|
||||
const player3Validation = validatePlayerName(data.player3!);
|
||||
errors.push(...player3Validation.errors);
|
||||
playerNames.push(data.player3!.trim());
|
||||
}
|
||||
|
||||
const nonEmptyNames = playerNames.filter((name) => name.length > 0);
|
||||
if (new Set(nonEmptyNames).size !== nonEmptyNames.length) {
|
||||
errors.push(VALIDATION_MESSAGES.DUPLICATE_PLAYER_NAMES);
|
||||
}
|
||||
|
||||
const thirdScoreRequired = hasThirdPlayer;
|
||||
if (!isValidScore(data.score1, true) || !isValidScore(data.score2, true) || !isValidScore(data.score3, thirdScoreRequired)) {
|
||||
errors.push(VALIDATION_MESSAGES.SCORE_INVALID);
|
||||
}
|
||||
|
||||
if (!data.gameType) {
|
||||
errors.push(VALIDATION_MESSAGES.GAME_TYPE_REQUIRED);
|
||||
}
|
||||
|
||||
const raceTo = data.raceTo;
|
||||
if (raceTo !== Infinity && (!Number.isInteger(raceTo) || raceTo <= 0)) {
|
||||
errors.push(VALIDATION_MESSAGES.RACE_TO_INVALID);
|
||||
} else if (raceTo !== Infinity) {
|
||||
const maxScore = Math.max(data.score1, data.score2, data.score3 ?? 0);
|
||||
if (raceTo < maxScore) {
|
||||
errors.push(VALIDATION_MESSAGES.RACE_TO_BELOW_SCORE);
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.breakRule) {
|
||||
errors.push(VALIDATION_MESSAGES.BREAK_RULE_REQUIRED);
|
||||
}
|
||||
|
||||
const activeCount = activePlayerCountFromSetup(data);
|
||||
const expectedPlayers = activeCount === 3 ? [1, 2, 3] : [1, 2];
|
||||
const order = data.breakOrder ?? [];
|
||||
const orderValid =
|
||||
order.length === activeCount &&
|
||||
new Set(order).size === activeCount &&
|
||||
order.every((p) => expectedPlayers.includes(p)) &&
|
||||
expectedPlayers.every((p) => order.includes(p));
|
||||
|
||||
if (!orderValid) {
|
||||
errors.push(VALIDATION_MESSAGES.BREAK_ORDER_INVALID);
|
||||
}
|
||||
|
||||
const breakerIdx = data.currentBreakerIdx;
|
||||
if (!Number.isInteger(breakerIdx) || breakerIdx < 0 || breakerIdx >= order.length) {
|
||||
errors.push(VALIDATION_MESSAGES.CURRENT_BREAKER_INVALID);
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateRaceTo(value: string): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import styles from './GameDetail.module.css';
|
||||
import { Button } from '@lib/ui/Button';
|
||||
import { getGameWinner, isStandardGame } from '@lib/domain/gameUtils';
|
||||
import type { Game, EndlosGame } from '@lib/domain/types';
|
||||
import type { Game, EndlosGame, GameSetupData } from '@lib/domain/types';
|
||||
import { SetupButton } from './SetupButton';
|
||||
import { GameSetupModal } from './GameSetupModal';
|
||||
|
||||
interface GameDetailProps {
|
||||
game: Game | undefined;
|
||||
@@ -13,6 +15,7 @@ interface GameDetailProps {
|
||||
onUndo?: () => void;
|
||||
onForfeit?: () => void;
|
||||
onBack: () => void;
|
||||
onUpdateGameSetup?: (setupData: GameSetupData) => void;
|
||||
}
|
||||
|
||||
interface AnimatedScoreProps {
|
||||
@@ -49,8 +52,9 @@ function AnimatedScore({ value, tapping }: AnimatedScoreProps) {
|
||||
/**
|
||||
* Game detail view for a single game.
|
||||
*/
|
||||
const GameDetail = ({ game, onFinishGame, onUpdateScore, onUndo, onBack }: GameDetailProps) => {
|
||||
const GameDetail = ({ game, onFinishGame, onUpdateScore, onUndo, onBack, onUpdateGameSetup }: GameDetailProps) => {
|
||||
const [tappingPlayer, setTappingPlayer] = useState<number | null>(null);
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
|
||||
if (!game || !isStandardGame(game)) return null;
|
||||
|
||||
@@ -154,6 +158,17 @@ const GameDetail = ({ game, onFinishGame, onUpdateScore, onUndo, onBack }: GameD
|
||||
})}
|
||||
</div>
|
||||
|
||||
<SetupButton onClick={() => setSetupOpen(true)} disabled={isCompleted} />
|
||||
<GameSetupModal
|
||||
game={standardGame}
|
||||
open={setupOpen}
|
||||
onSave={(data) => {
|
||||
onUpdateGameSetup?.(data);
|
||||
setSetupOpen(false);
|
||||
}}
|
||||
onCancel={() => setSetupOpen(false)}
|
||||
/>
|
||||
|
||||
<div className={styles.gameDetailControls}>
|
||||
<Button variant="secondary" size="medium" onClick={onBack} aria-label="Zurück zur Liste">
|
||||
Zurück
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
.modalContent {
|
||||
width: min(560px, 100%);
|
||||
max-height: calc(var(--app-height) - var(--space-xl) * 2);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
display: block;
|
||||
margin-bottom: var(--space-sm);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.playerRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.playerRow:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.fieldRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.textInput,
|
||||
.numberInput {
|
||||
flex: 1 1 140px;
|
||||
min-height: var(--touch-target-comfortable);
|
||||
padding: var(--space-md);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-base);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.textInput:focus,
|
||||
.numberInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-light);
|
||||
}
|
||||
|
||||
.numberInput {
|
||||
flex: 0 1 100px;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.choiceRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.choiceBtn {
|
||||
min-width: 72px;
|
||||
min-height: var(--touch-target-comfortable);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.choiceBtn:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.choiceBtnSelected {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.raceGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.endlosRow {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.togglePlayerBtn {
|
||||
width: 100%;
|
||||
min-height: var(--touch-target-comfortable);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.errors {
|
||||
color: var(--color-danger);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-danger);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.errors ul {
|
||||
margin: 0;
|
||||
padding-left: var(--space-lg);
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.footerActions {
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/* SetupButton — bottom-left; mirrors FullscreenToggle placement */
|
||||
.setupButton {
|
||||
position: fixed;
|
||||
bottom: calc(var(--space-md) + var(--safe-area-bottom));
|
||||
left: calc(var(--space-md) + var(--safe-area-left));
|
||||
width: var(--touch-target-comfortable);
|
||||
height: var(--touch-target-comfortable);
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-secondary);
|
||||
border: 2px solid var(--color-border);
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 9999;
|
||||
transition: background-color var(--transition-base), opacity var(--transition-base);
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.setupButton:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.setupButton:active:not(:disabled) {
|
||||
opacity: 0.8;
|
||||
background-color: var(--color-secondary-hover);
|
||||
}
|
||||
|
||||
.setupButton:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.setupButton svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.setupButton {
|
||||
width: var(--touch-target-large);
|
||||
height: var(--touch-target-large);
|
||||
}
|
||||
|
||||
.setupButton svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { h } from 'preact';
|
||||
import styles from './SetupButton.module.css';
|
||||
|
||||
interface SetupButtonProps {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SetupButton({ onClick, disabled }: SetupButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.setupButton}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title="Spiel einrichten"
|
||||
aria-label="Spiel einrichten"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Spiel einrichten</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user