feat(storage): migrate to IndexedDB with localStorage fallback and async app flow

- Add IndexedDB service with schema, indexes, and player stats
- Migrate GameService to async IndexedDB and auto-migrate from localStorage
- Update hooks and App handlers to async; add error handling and UX feedback
- Convert remaining JSX components to TSX
- Add test utility for IndexedDB and migration checks
- Extend game types with sync fields for future online sync
This commit is contained in:
Frank Schwenk
2025-10-30 09:36:17 +01:00
parent e89ae1039d
commit 8085d2ecc8
20 changed files with 1288 additions and 277 deletions

View File

@@ -1,12 +1,12 @@
import { h } from 'preact';
import { useEffect } from 'preact/hooks';
import { useEffect, useCallback } from 'preact/hooks';
import { useGameState } from '../hooks/useGameState';
import { useNavigation, useNewGameWizard } from '../hooks/useNavigation';
import { useModal, useValidationModal, useCompletionModal } from '../hooks/useModal';
import { GameService } from '../services/gameService';
import type { StandardGame } from '../types/game';
import type { StandardGame, Game, EndlosGame } from '../types/game';
import { Layout } from './ui/Layout';
import GameListScreen from './screens/GameListScreen';
@@ -30,72 +30,92 @@ export default function App() {
const completionModal = useCompletionModal();
// Game lifecycle handlers
const handleCreateGame = (gameData: any) => {
const gameId = gameState.addGame(gameData);
newGameWizard.resetWizard();
navigation.showGameDetail(gameId);
};
const handleCreateGame = useCallback(async (gameData: any) => {
try {
const gameId = await gameState.addGame(gameData);
newGameWizard.resetWizard();
navigation.showGameDetail(gameId);
} catch (error) {
console.error('Failed to create game:', error);
validationModal.showValidation('Failed to create game. Please try again.');
}
}, [gameState.addGame, newGameWizard.resetWizard, navigation.showGameDetail, validationModal.showValidation]);
const handleUpdateScore = (player: number, change: number) => {
const handleUpdateScore = useCallback(async (player: number, change: number) => {
if (!navigation.currentGameId) return;
const game = gameState.getGameById(navigation.currentGameId);
if (!game || game.status === 'completed') return;
const updatedGame = GameService.updateGameScore(game as StandardGame, player, change);
// Add undo state for standard games
const gameWithHistory = {
...updatedGame,
undoStack: [...(game.undoStack || []), game],
updatedAt: new Date().toISOString(),
};
gameState.updateGame(navigation.currentGameId, gameWithHistory);
// Check for completion
if (GameService.isGameCompleted(gameWithHistory)) {
completionModal.openCompletionModal(gameWithHistory);
try {
const updatedGame = GameService.updateGameScore(game as StandardGame, player, change);
// Add undo state for standard games
const gameWithHistory = {
...updatedGame,
undoStack: [...(game.undoStack || []), game],
updatedAt: new Date().toISOString(),
};
await gameState.updateGame(navigation.currentGameId, gameWithHistory);
// Check for completion
if (GameService.isGameCompleted(gameWithHistory)) {
completionModal.openCompletionModal(gameWithHistory);
}
} catch (error) {
console.error('Failed to update score:', error);
validationModal.showValidation('Failed to update score. Please try again.');
}
};
}, [navigation.currentGameId, gameState.getGameById, gameState.updateGame, completionModal.openCompletionModal, validationModal.showValidation]);
const handleUndo = () => {
const handleUndo = useCallback(async () => {
if (!navigation.currentGameId) return;
const game = gameState.getGameById(navigation.currentGameId);
if (!game?.undoStack?.length) return;
const lastState = game.undoStack[game.undoStack.length - 1];
const newUndoStack = game.undoStack.slice(0, -1);
try {
const lastState = game.undoStack[game.undoStack.length - 1];
const newUndoStack = game.undoStack.slice(0, -1);
gameState.updateGame(navigation.currentGameId, {
...lastState,
undoStack: newUndoStack,
});
};
await gameState.updateGame(navigation.currentGameId, {
...lastState,
undoStack: newUndoStack,
} as Game);
} catch (error) {
console.error('Failed to undo:', error);
validationModal.showValidation('Failed to undo. Please try again.');
}
}, [navigation.currentGameId, gameState.getGameById, gameState.updateGame, validationModal.showValidation]);
const handleForfeit = () => {
const handleForfeit = async () => {
if (!navigation.currentGameId) return;
const game = gameState.getGameById(navigation.currentGameId);
if (!game || !('players' in game)) return;
const currentPlayerIndex = game.currentPlayer;
if (currentPlayerIndex === null) return;
try {
const currentPlayerIndex = game.currentPlayer;
if (currentPlayerIndex === null) return;
const winner = game.players.find((_, idx) => idx !== currentPlayerIndex);
const forfeitedGame = {
...game,
status: 'completed' as const,
winner: winner?.name,
forfeitedBy: game.players[currentPlayerIndex].name,
updatedAt: new Date().toISOString(),
};
gameState.updateGame(navigation.currentGameId, forfeitedGame);
completionModal.openCompletionModal(forfeitedGame);
const winner = game.players.find((_, idx) => idx !== currentPlayerIndex);
const forfeitedGame = {
...game,
status: 'completed' as const,
winner: winner?.name,
forfeitedBy: game.players[currentPlayerIndex].name,
updatedAt: new Date().toISOString(),
};
await gameState.updateGame(navigation.currentGameId, forfeitedGame);
completionModal.openCompletionModal(forfeitedGame);
} catch (error) {
console.error('Failed to forfeit game:', error);
validationModal.showValidation('Failed to forfeit game. Please try again.');
}
};
const handleFinishGame = () => {
@@ -107,19 +127,24 @@ export default function App() {
completionModal.openCompletionModal(game);
};
const handleConfirmCompletion = () => {
const handleConfirmCompletion = async () => {
if (!navigation.currentGameId) return;
gameState.updateGame(navigation.currentGameId, {
...gameState.getGameById(navigation.currentGameId)!,
status: 'completed',
updatedAt: new Date().toISOString(),
});
completionModal.closeCompletionModal();
try {
await gameState.updateGame(navigation.currentGameId, {
...gameState.getGameById(navigation.currentGameId)!,
status: 'completed',
updatedAt: new Date().toISOString(),
});
completionModal.closeCompletionModal();
} catch (error) {
console.error('Failed to complete game:', error);
validationModal.showValidation('Failed to complete game. Please try again.');
}
};
const handleRematch = () => {
const handleRematch = async () => {
if (!navigation.currentGameId) return;
const completedGame = gameState.getGameById(navigation.currentGameId);
@@ -144,28 +169,38 @@ export default function App() {
};
}
const newGameId = gameState.addGame(gameData);
completionModal.closeCompletionModal();
navigation.showGameDetail(newGameId);
try {
const newGameId = await gameState.addGame(gameData);
completionModal.closeCompletionModal();
navigation.showGameDetail(newGameId);
} catch (error) {
console.error('Failed to create rematch:', error);
validationModal.showValidation('Failed to create rematch. Please try again.');
}
};
const handleDeleteGame = (gameId: number) => {
modal.openModal(gameId);
};
const handleConfirmDelete = () => {
const handleConfirmDelete = async () => {
if (modal.modal.gameId) {
gameState.deleteGame(modal.modal.gameId);
try {
await gameState.deleteGame(modal.modal.gameId);
modal.closeModal();
navigation.showGameList();
} catch (error) {
console.error('Failed to delete game:', error);
validationModal.showValidation('Failed to delete game. Please try again.');
}
}
modal.closeModal();
navigation.showGameList();
};
return (
<Layout>
{navigation.screen === 'game-list' && (
<GameListScreen
games={gameState.getFilteredGames()}
games={gameState.games}
filter={gameState.filter}
onFilterChange={gameState.setFilter}
onShowGameDetail={navigation.showGameDetail}
@@ -198,7 +233,16 @@ export default function App() {
game={gameState.getGameById(navigation.currentGameId)}
onUpdateScore={handleUpdateScore}
onFinishGame={handleFinishGame}
onUpdateGame={async (game: EndlosGame) => {
try {
await gameState.updateGame(navigation.currentGameId!, game);
} catch (error) {
console.error('Failed to update game:', error);
validationModal.showValidation('Failed to update game. Please try again.');
}
}}
onUndo={handleUndo}
onForfeit={handleForfeit}
onBack={navigation.showGameList}
/>
)}

View File

@@ -1,19 +1,20 @@
import { h } from 'preact';
import modalStyles from './Modal.module.css';
import styles from './GameCompletionModal.module.css';
import type { Game } from '../types/game';
interface GameCompletionModalProps {
open: boolean;
game: Game | null;
onConfirm: () => void;
onClose: () => void;
onRematch: () => void;
}
/**
* Modal shown when a game is completed.
* @param {object} props
* @param {boolean} props.open
* @param {object} props.game
* @param {Function} props.onConfirm
* @param {Function} props.onClose
* @param {Function} props.onRematch
* @returns {import('preact').VNode|null}
*/
const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }) => {
const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }: GameCompletionModalProps) => {
if (!open || !game) return null;
const playerNames = [game.player1, game.player2, game.player3].filter(Boolean);

View File

@@ -1,30 +1,36 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import styles from './GameDetail.module.css';
import Toast from './Toast.jsx';
import Toast from './Toast';
import type { Game, EndlosGame } from '../types/game';
interface GameDetailProps {
game: Game | undefined;
onFinishGame: () => void;
onUpdateScore: (player: number, change: number) => void;
onUpdateGame?: (game: EndlosGame) => void;
onUndo?: () => void;
onForfeit?: () => void;
onBack: () => void;
}
/**
* Game detail view for a single game.
* @param {object} props
* @param {object} props.game
* @param {Function} props.onFinishGame
* @param {Function} props.onUpdateScore
* @param {Function} props.onUpdateGame
* @param {Function} props.onUndo
* @param {Function} props.onForfeit
* @param {Function} props.onBack
* @returns {import('preact').VNode|null}
*/
const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, onForfeit, onBack }) => {
const [toast, setToast] = useState({ show: false, message: '', type: 'info' });
const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, onForfeit, onBack }: GameDetailProps) => {
const [toast, setToast] = useState<{ show: boolean; message: string; type: 'success' | 'error' | 'info' }>({
show: false,
message: '',
type: 'info'
});
if (!game) return null;
const showToast = (message, type = 'info') => {
const showToast = (message: string, type: 'success' | 'error' | 'info' = 'info') => {
setToast({ show: true, message, type });
};
const handleScoreUpdate = (playerIndex, change) => {
const handleScoreUpdate = (playerIndex: number, change: number) => {
onUpdateScore(playerIndex, change);
const playerName = [game.player1, game.player2, game.player3][playerIndex - 1];
const action = change > 0 ? 'Punkt hinzugefügt' : 'Punkt abgezogen';
@@ -33,6 +39,7 @@ const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, o
const isCompleted = game.status === 'completed';
const playerNames = [game.player1, game.player2, game.player3].filter(Boolean);
const scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]);
@@ -62,7 +69,15 @@ const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, o
className={styles['score']}
id={`score${idx + 1}`}
onClick={() => !isCompleted && onUpdateScore(idx + 1, 1)}
aria-label={`Aktueller Punktestand für ${name}: ${scores[idx]}. Klicken zum Erhöhen.`}
onKeyDown={(e) => {
if (!isCompleted && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onUpdateScore(idx + 1, 1);
}
}}
role="button"
tabIndex={isCompleted ? -1 : 0}
aria-label={`Aktueller Punktestand für ${name}: ${scores[idx]}. Klicken oder Enter drücken zum Erhöhen.`}
>
{scores[idx]}
</span>
@@ -72,12 +87,14 @@ const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, o
disabled={isCompleted}
onClick={() => handleScoreUpdate(idx+1, -1)}
aria-label={`Punkt abziehen für ${name}`}
title={`Punkt abziehen für ${name}`}
>-</button>
<button
className={styles['score-button']}
disabled={isCompleted}
onClick={() => handleScoreUpdate(idx+1, 1)}
aria-label={`Punkt hinzufügen für ${name}`}
title={`Punkt hinzufügen für ${name}`}
>+</button>
</div>
</div>

View File

@@ -49,10 +49,10 @@ export default function GameList({
}
};
const filterButtons: Array<{ key: GameFilter; label: string; ariaLabel: string }> = [
{ key: 'all', label: 'Alle', ariaLabel: 'Alle Spiele anzeigen' },
{ key: 'active', label: 'Aktiv', ariaLabel: 'Nur aktive Spiele anzeigen' },
{ key: 'completed', label: 'Abgeschlossen', ariaLabel: 'Nur abgeschlossene Spiele anzeigen' },
const filterButtons = [
{ key: 'all' as const, label: 'Alle', ariaLabel: 'Alle Spiele anzeigen' },
{ key: 'active' as const, label: 'Aktiv', ariaLabel: 'Nur aktive Spiele anzeigen' },
{ key: 'completed' as const, label: 'Abgeschlossen', ariaLabel: 'Nur abgeschlossene Spiele anzeigen' },
];
return (
@@ -87,15 +87,26 @@ export default function GameList({
<div
className={styles['game-info']}
onClick={() => onShowGameDetail(game.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onShowGameDetail(game.id);
}
}}
role="button"
tabIndex={0}
aria-label={`Details für Spiel ${playerNames}`}
aria-describedby={`game-${game.id}-description`}
>
<div className={styles['game-type']}>
{game.gameType}{game.raceTo ? ` | ${game.raceTo}` : ''}
</div>
<div className={styles['player-names']}>{playerNames}</div>
<div className={styles['game-scores']}>{scores}</div>
<div id={`game-${game.id}-description`} className="sr-only">
{game.gameType} Spiel zwischen {playerNames} mit dem Stand {scores}.
{game.status === 'completed' ? 'Abgeschlossen' : 'Aktiv'}
</div>
</div>
<Button
variant="danger"

View File

@@ -1,17 +1,18 @@
import { h } from 'preact';
import styles from './Modal.module.css';
interface ModalProps {
open: boolean;
title: string;
message: string;
onCancel: () => void;
onConfirm: () => void;
}
/**
* Generic modal dialog for confirmation.
* @param {object} props
* @param {boolean} props.open
* @param {string} props.title
* @param {string} props.message
* @param {Function} props.onCancel
* @param {Function} props.onConfirm
* @returns {import('preact').VNode|null}
*/
const Modal = ({ open, title, message, onCancel, onConfirm }) => {
const Modal = ({ open, title, message, onCancel, onConfirm }: ModalProps) => {
if (!open) return null;
return (
<div className={styles['modal'] + ' ' + styles['show']} role="dialog" aria-modal="true" aria-labelledby="modal-title">

View File

@@ -2,8 +2,26 @@ import { h } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import styles from './NewGame.module.css';
import modalStyles from './PlayerSelectModal.module.css';
import {
UI_CONSTANTS,
WIZARD_STEPS,
GAME_TYPE_OPTIONS,
RACE_TO_QUICK_PICKS,
RACE_TO_DEFAULT,
RACE_TO_INFINITY,
ERROR_MESSAGES,
ARIA_LABELS,
FORM_CONFIG,
ERROR_STYLES
} from '../utils/constants';
const PlayerSelectModal = ({ players, onSelect, onClose }) => (
interface PlayerSelectModalProps {
players: string[];
onSelect: (player: string) => void;
onClose: () => void;
}
export const PlayerSelectModal = ({ players, onSelect, onClose }: PlayerSelectModalProps) => (
<div className={modalStyles.modalOverlay} onClick={onClose}>
<div className={modalStyles.modalContent} onClick={e => e.stopPropagation()}>
<div className={modalStyles.modalHeader}>
@@ -21,21 +39,22 @@ const PlayerSelectModal = ({ players, onSelect, onClose }) => (
</div>
);
interface PlayerStepProps {
playerNameHistory: string[];
onNext: (name: string) => void;
onCancel: () => void;
initialValue?: string;
}
/**
* Player 1 input step for multi-step game creation wizard.
* @param {object} props
* @param {string[]} props.playerNameHistory
* @param {Function} props.onNext
* @param {Function} props.onCancel
* @param {string} [props.initialValue]
* @returns {import('preact').VNode}
*/
const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }) => {
const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }: PlayerStepProps) => {
const [player1, setPlayer1] = useState(initialValue);
const [error, setError] = useState(null);
const [error, setError] = useState<string | null>(null);
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
const [isModalOpen, setIsModalOpen] = useState(false);
const inputRef = useRef(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!player1) {
@@ -49,22 +68,41 @@ const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
}
}, [player1, playerNameHistory]);
const handleSubmit = (e) => {
const handleSubmit = (e: Event) => {
e.preventDefault();
if (!player1.trim()) {
setError('Bitte Namen für Spieler 1 eingeben');
const trimmedName = player1.trim();
if (!trimmedName) {
setError(ERROR_MESSAGES.PLAYER1_REQUIRED);
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.setAttribute('aria-invalid', 'true');
}
return;
}
if (trimmedName.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH) {
setError(`Spielername darf maximal ${FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen lang sein`);
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.setAttribute('aria-invalid', 'true');
}
return;
}
setError(null);
onNext(player1.trim());
if (inputRef.current) {
inputRef.current.setAttribute('aria-invalid', 'false');
}
onNext(trimmedName);
};
const handleQuickPick = (name) => {
const handleQuickPick = (name: string) => {
setError(null);
onNext(name);
};
const handleModalSelect = (name) => {
const handleModalSelect = (name: string) => {
setIsModalOpen(false);
handleQuickPick(name);
};
@@ -77,28 +115,62 @@ const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
return (
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spieler 1 Eingabe" autoComplete="off">
<div className={styles['screen-title']}>Neues Spiel Schritt 1/5</div>
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
<div className={styles['screen-title']}>Neues Spiel Schritt {WIZARD_STEPS.PLAYER1}/{UI_CONSTANTS.TOTAL_WIZARD_STEPS}</div>
<div className={styles['progress-indicator']} style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_MEDIUM }}>
<span className={styles['progress-dot'] + ' ' + styles['active']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
</div>
<div className={styles['player-input'] + ' ' + styles['player1-input']} style={{ marginBottom: 32, position: 'relative' }}>
<label htmlFor="player1-input" style={{ fontSize: '1.3rem', fontWeight: 600 }}>Spieler 1</label>
<div className={styles['player-input'] + ' ' + styles['player1-input']} style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_LARGE, position: 'relative' }}>
<label htmlFor="player1-input" style={{ fontSize: UI_CONSTANTS.LABEL_FONT_SIZE, fontWeight: 600 }}>Spieler 1</label>
<div style={{ position: 'relative', width: '100%' }}>
<input
id="player1-input"
className={styles['name-input']}
placeholder="Name Spieler 1"
value={player1}
onInput={e => setPlayer1(e.target.value)}
onInput={(e: Event) => {
const target = e.target as HTMLInputElement;
const value = target.value;
setPlayer1(value);
// Real-time validation feedback
if (value.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH) {
setError(`Spielername darf maximal ${FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen lang sein`);
target.setAttribute('aria-invalid', 'true');
} else if (value.trim() && error) {
setError(null);
target.setAttribute('aria-invalid', 'false');
}
}}
autoComplete="off"
aria-label="Name Spieler 1"
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
aria-describedby="player1-help"
style={{
fontSize: UI_CONSTANTS.INPUT_FONT_SIZE,
minHeight: UI_CONSTANTS.INPUT_MIN_HEIGHT,
marginTop: 12,
marginBottom: 12,
width: '100%',
paddingRight: UI_CONSTANTS.INPUT_PADDING_RIGHT
}}
ref={inputRef}
/>
<div id="player1-help" className="sr-only">
Geben Sie den Namen für Spieler 1 ein. Maximal {FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen erlaubt.
</div>
{player1.length > FORM_CONFIG.CHARACTER_COUNT_WARNING_THRESHOLD && (
<div style={{
fontSize: '0.875rem',
color: player1.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH ? '#f44336' : '#ff9800',
marginTop: '4px',
textAlign: 'right'
}}>
{player1.length}/{FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen
</div>
)}
{player1 && (
<button
type="button"
@@ -127,25 +199,41 @@ const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
</div>
{filteredNames.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 8 }}>
{filteredNames.slice(0, 10).map((name, idx) => (
{filteredNames.slice(0, UI_CONSTANTS.MAX_QUICK_PICKS).map((name, idx) => (
<button
type="button"
key={name + idx}
className={styles['quick-pick-btn']}
style={{ fontSize: '1.1rem', padding: '12px 20px', borderRadius: 8, background: '#333', color: '#fff', border: 'none', cursor: 'pointer' }}
style={{
fontSize: UI_CONSTANTS.QUICK_PICK_FONT_SIZE,
padding: UI_CONSTANTS.QUICK_PICK_PADDING,
borderRadius: 8,
background: '#333',
color: '#fff',
border: 'none',
cursor: 'pointer'
}}
onClick={() => handleQuickPick(name)}
aria-label={`Schnellauswahl: ${name}`}
aria-label={ARIA_LABELS.QUICK_PICK(name)}
>
{name}
</button>
))}
{playerNameHistory.length > 10 && (
{playerNameHistory.length > UI_CONSTANTS.MAX_QUICK_PICKS && (
<button
type="button"
className={styles['quick-pick-btn']}
style={{ fontSize: '1.1rem', padding: '12px 20px', borderRadius: 8, background: '#333', color: '#fff', border: 'none', cursor: 'pointer' }}
style={{
fontSize: UI_CONSTANTS.QUICK_PICK_FONT_SIZE,
padding: UI_CONSTANTS.QUICK_PICK_PADDING,
borderRadius: 8,
background: '#333',
color: '#fff',
border: 'none',
cursor: 'pointer'
}}
onClick={() => setIsModalOpen(true)}
aria-label="Weitere Spieler anzeigen"
aria-label={ARIA_LABELS.SHOW_MORE_PLAYERS}
>
...
</button>
@@ -153,7 +241,20 @@ const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
</div>
)}
</div>
{error && <div className={styles['validation-error']} style={{ marginBottom: 16 }}>{error}</div>}
{error && (
<div
className={styles['validation-error']}
style={{
marginBottom: 16,
...ERROR_STYLES.CONTAINER
}}
role="alert"
aria-live="polite"
>
<span style={ERROR_STYLES.ICON}></span>
{error}
</div>
)}
{isModalOpen && (
<PlayerSelectModal
players={playerNameHistory}
@@ -188,18 +289,12 @@ const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
/**
* Player 2 input step for multi-step game creation wizard.
* @param {object} props
* @param {string[]} props.playerNameHistory
* @param {Function} props.onNext
* @param {Function} props.onCancel
* @param {string} [props.initialValue]
* @returns {import('preact').VNode}
*/
const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }) => {
const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }: PlayerStepProps) => {
const [player2, setPlayer2] = useState(initialValue);
const [error, setError] = useState(null);
const [error, setError] = useState<string | null>(null);
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
const inputRef = useRef(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!player2) {
@@ -213,7 +308,7 @@ const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
}
}, [player2, playerNameHistory]);
const handleSubmit = (e) => {
const handleSubmit = (e: Event) => {
e.preventDefault();
if (!player2.trim()) {
setError('Bitte Namen für Spieler 2 eingeben');
@@ -223,7 +318,7 @@ const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
onNext(player2.trim());
};
const handleQuickPick = (name) => {
const handleQuickPick = (name: string) => {
setError(null);
onNext(name);
};
@@ -252,7 +347,10 @@ const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
className={styles['name-input']}
placeholder="Name Spieler 2"
value={player2}
onInput={e => setPlayer2(e.target.value)}
onInput={(e: Event) => {
const target = e.target as HTMLInputElement;
setPlayer2(target.value);
}}
autoComplete="off"
aria-label="Name Spieler 2"
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
@@ -326,17 +424,11 @@ const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
/**
* Player 3 input step for multi-step game creation wizard.
* @param {object} props
* @param {string[]} props.playerNameHistory
* @param {Function} props.onNext
* @param {Function} props.onCancel
* @param {string} [props.initialValue]
* @returns {import('preact').VNode}
*/
const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }) => {
const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }: PlayerStepProps) => {
const [player3, setPlayer3] = useState(initialValue);
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
const inputRef = useRef(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!player3) {
@@ -350,13 +442,13 @@ const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
}
}, [player3, playerNameHistory]);
const handleSubmit = (e) => {
const handleSubmit = (e: Event) => {
e.preventDefault();
// Player 3 is optional, so always allow submission
onNext(player3.trim());
};
const handleQuickPick = (name) => {
const handleQuickPick = (name: string) => {
onNext(name);
};
@@ -365,7 +457,7 @@ const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
if (inputRef.current) inputRef.current.focus();
};
const handleSkip = (e) => {
const handleSkip = (e: Event) => {
e.preventDefault();
onNext('');
};
@@ -388,7 +480,10 @@ const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
className={styles['name-input']}
placeholder="Name Spieler 3 (optional)"
value={player3}
onInput={e => setPlayer3(e.target.value)}
onInput={(e: Event) => {
const target = e.target as HTMLInputElement;
setPlayer3(target.value);
}}
autoComplete="off"
aria-label="Name Spieler 3"
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
@@ -469,23 +564,24 @@ const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
);
};
interface GameTypeStepProps {
onNext: (type: string) => void;
onCancel: () => void;
initialValue?: string;
}
/**
* Game Type selection step for multi-step game creation wizard.
* @param {object} props
* @param {Function} props.onNext
* @param {Function} props.onCancel
* @param {string} [props.initialValue]
* @returns {import('preact').VNode}
*/
const GameTypeStep = ({ onNext, onCancel, initialValue = '' }) => {
const GameTypeStep = ({ onNext, onCancel, initialValue = '' }: GameTypeStepProps) => {
const [gameType, setGameType] = useState(initialValue);
const gameTypes = ['8-Ball', '9-Ball', '10-Ball'];
const handleSelect = (selectedType) => {
const handleSelect = (selectedType: string) => {
setGameType(selectedType);
};
const handleSubmit = (e) => {
const handleSubmit = (e: Event) => {
e.preventDefault();
if (gameType) {
onNext(gameType);
@@ -551,19 +647,20 @@ const GameTypeStep = ({ onNext, onCancel, initialValue = '' }) => {
);
};
interface RaceToStepProps {
onNext: (raceTo: string | number) => void;
onCancel: () => void;
initialValue?: string | number;
gameType?: string;
}
/**
* Race To selection step for multi-step game creation wizard.
* @param {object} props
* @param {Function} props.onNext
* @param {Function} props.onCancel
* @param {string|number} [props.initialValue]
* @param {string} [props.gameType]
* @returns {import('preact').VNode}
*/
const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }) => {
const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }: RaceToStepProps) => {
const quickPicks = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const defaultValue = 5;
const [raceTo, setRaceTo] = useState(initialValue !== '' ? initialValue : defaultValue);
const [raceTo, setRaceTo] = useState<string | number>(initialValue !== '' ? initialValue : defaultValue);
useEffect(() => {
if ((initialValue === '' || initialValue === undefined) && raceTo !== defaultValue) {
@@ -574,19 +671,20 @@ const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }) => {
}
}, [gameType, initialValue, defaultValue]);
const handleQuickPick = (value) => {
const handleQuickPick = (value: number) => {
// For endlos (endless) games, use Infinity to prevent automatic completion
setRaceTo(value === 0 ? 'Infinity' : value);
};
const handleInputChange = (e) => {
setRaceTo(e.target.value);
const handleInputChange = (e: Event) => {
const target = e.target as HTMLInputElement;
setRaceTo(target.value);
};
const handleSubmit = (e) => {
const handleSubmit = (e: Event) => {
e.preventDefault();
// Handle Infinity for endlos games, otherwise parse as integer
const raceToValue = raceTo === 'Infinity' ? Infinity : (parseInt(raceTo, 10) || 0);
const raceToValue = raceTo === 'Infinity' ? Infinity : (parseInt(String(raceTo), 10) || 0);
onNext(raceToValue);
};

View File

@@ -0,0 +1 @@
import { h } from "preact";

View File

@@ -2,17 +2,20 @@ import { h } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import styles from './Toast.module.css';
type ToastType = 'success' | 'error' | 'info';
interface ToastProps {
show: boolean;
message: string;
type?: ToastType;
onClose: () => void;
duration?: number;
}
/**
* Toast notification component for user feedback
* @param {object} props
* @param {boolean} props.show
* @param {string} props.message
* @param {string} props.type - 'success', 'error', 'info'
* @param {Function} props.onClose
* @param {number} [props.duration=3000]
* @returns {import('preact').VNode|null}
*/
const Toast = ({ show, message, type = 'info', onClose, duration = 3000 }) => {
const Toast = ({ show, message, type = 'info', onClose, duration = 3000 }: ToastProps) => {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {

View File

@@ -1,15 +1,16 @@
import { h } from 'preact';
import styles from './Modal.module.css';
interface ValidationModalProps {
open: boolean;
message: string;
onClose: () => void;
}
/**
* Modal for displaying validation errors.
* @param {object} props
* @param {boolean} props.open
* @param {string} props.message
* @param {Function} props.onClose
* @returns {import('preact').VNode|null}
*/
const ValidationModal = ({ open, message, onClose }) => {
const ValidationModal = ({ open, message, onClose }: ValidationModalProps) => {
if (!open) return null;
return (
<div className={styles['modal'] + ' ' + styles['show']} id="validation-modal" role="alertdialog" aria-modal="true" aria-labelledby="validation-modal-title">

View File

@@ -9,7 +9,10 @@ interface LayoutProps {
export function Layout({ children, className = '' }: LayoutProps) {
return (
<div className={`${styles.layout} ${className}`}>
<div className={styles.content}>
<a href="#main-content" className="skip-link">
Zum Hauptinhalt springen
</a>
<div className={styles.content} id="main-content">
{children}
</div>
</div>