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:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
1
src/components/PlayerInputStep.tsx
Normal file
1
src/components/PlayerInputStep.tsx
Normal file
@@ -0,0 +1 @@
|
||||
import { h } from "preact";
|
||||
@@ -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(() => {
|
||||
@@ -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">
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user