Refactor BSC Score to Astro, TypeScript, and modular architecture

This commit is contained in:
Cursor Agent
2025-06-24 11:44:19 +00:00
parent bcf793b9e3
commit 6f626c9977
30 changed files with 1836 additions and 497 deletions

View File

@@ -1,360 +0,0 @@
import { h } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks';
import GameList from './GameList.jsx';
import GameDetail from './GameDetail.jsx';
import { Player1Step, Player2Step, Player3Step, GameTypeStep, RaceToStep } from './NewGame.jsx';
import Modal from './Modal.jsx';
import ValidationModal from './ValidationModal.jsx';
import GameCompletionModal from './GameCompletionModal.jsx';
import FullscreenToggle from './FullscreenToggle.jsx';
const LOCAL_STORAGE_KEY = 'bscscore_games';
/**
* Main App component for BSC Score
* @returns {import('preact').VNode}
*/
const App = () => {
const [games, setGames] = useState([]);
const [currentGameId, setCurrentGameId] = useState(null);
const [playerNameHistory, setPlayerNameHistory] = useState([]);
const [screen, setScreen] = useState('game-list');
const [modal, setModal] = useState({ open: false, gameId: null });
const [validation, setValidation] = useState({ open: false, message: '' });
const [completionModal, setCompletionModal] = useState({ open: false, game: null });
const [filter, setFilter] = useState('all');
const [newGameStep, setNewGameStep] = useState(null);
const [newGameData, setNewGameData] = useState({ player1: '', player2: '', player3: '', gameType: '', raceTo: '' });
// Load games from localStorage on mount
useEffect(() => {
const savedGames = localStorage.getItem(LOCAL_STORAGE_KEY);
if (savedGames) {
setGames(JSON.parse(savedGames));
}
}, []);
// Save games to localStorage whenever games change
useEffect(() => {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(games));
// Update player name history
const nameLastUsed = {};
games.forEach(game => {
if (game.player1) nameLastUsed[game.player1] = Math.max(nameLastUsed[game.player1] || 0, new Date(game.updatedAt).getTime());
if (game.player2) nameLastUsed[game.player2] = Math.max(nameLastUsed[game.player2] || 0, new Date(game.updatedAt).getTime());
if (game.player3) nameLastUsed[game.player3] = Math.max(nameLastUsed[game.player3] || 0, new Date(game.updatedAt).getTime());
});
setPlayerNameHistory(
[...new Set(Object.keys(nameLastUsed))].sort((a, b) => nameLastUsed[b] - nameLastUsed[a])
);
}, [games]);
// Navigation handlers
const showGameList = useCallback(() => {
setScreen('game-list');
setCurrentGameId(null);
}, []);
const showNewGame = useCallback(() => {
setScreen('new-game');
setCurrentGameId(null);
setNewGameStep('player1');
setNewGameData({ player1: '', player2: '', player3: '', gameType: '', raceTo: '' });
}, []);
const showGameDetail = useCallback((id) => {
setCurrentGameId(id);
setScreen('game-detail');
}, []);
// Game creation
const handleCreateGame = useCallback(({ player1, player2, player3, gameType, raceTo }) => {
const newGame = {
id: Date.now(),
gameType,
raceTo: parseInt(raceTo, 10),
status: 'active',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
log: [],
undoStack: [],
};
if (gameType === '14/1 endlos') {
const players = [{ name: player1, score: 0, consecutiveFouls: 0 }, { name: player2, score: 0, consecutiveFouls: 0 }];
if (player3) {
players.push({ name: player3, score: 0, consecutiveFouls: 0 });
}
newGame.players = players;
newGame.currentPlayer = null; // Set to null, will be chosen in GameDetail141
newGame.ballsOnTable = 15;
} else {
newGame.player1 = player1;
newGame.player2 = player2;
newGame.score1 = 0;
newGame.score2 = 0;
if (player3) {
newGame.player3 = player3;
newGame.score3 = 0;
}
}
setGames(g => [newGame, ...g]);
return newGame.id;
}, []);
// Game update for 14.1
const handleGameAction = useCallback((updatedGame) => {
const originalGame = games.find(game => game.id === currentGameId);
if (!originalGame) return;
// Add the original state to the undo stack before updating
const newUndoStack = [...(originalGame.undoStack || []), originalGame];
const gameWithHistory = {
...updatedGame,
undoStack: newUndoStack,
updatedAt: new Date().toISOString(),
};
setGames(games => games.map(game => (game.id === currentGameId ? gameWithHistory : game)));
// Check for raceTo completion
if (gameWithHistory.raceTo) {
const winner = gameWithHistory.players.find(p => p.score >= gameWithHistory.raceTo);
if (winner) {
setCompletionModal({ open: true, game: gameWithHistory });
}
}
}, [games, currentGameId]);
const handleUndo = useCallback(() => {
const game = games.find(g => g.id === currentGameId);
if (!game || !game.undoStack || game.undoStack.length === 0) return;
const lastState = game.undoStack[game.undoStack.length - 1];
const newUndoStack = game.undoStack.slice(0, -1);
setGames(g => g.map(gme => (gme.id === currentGameId ? { ...lastState, undoStack: newUndoStack } : gme)));
}, [games, currentGameId]);
const handleForfeit = useCallback(() => {
const game = games.find(g => g.id === currentGameId);
if (!game) return;
const winner = game.players.find((p, idx) => idx !== game.currentPlayer);
// In a 2 player game, this is simple. For >2, we need a winner selection.
// For now, assume the *other* player wins. This is fine for 2 players.
// We'll mark the game as complete with a note about the forfeit.
const forfeitedGame = {
...game,
status: 'completed',
winner: winner.name,
forfeitedBy: game.players[game.currentPlayer].name,
updatedAt: new Date().toISOString(),
};
setGames(g => g.map(gme => (gme.id === currentGameId ? forfeitedGame : gme)));
setCompletionModal({ open: true, game: forfeitedGame });
}, [games, currentGameId]);
// Score update
const handleUpdateScore = useCallback((player, change) => {
setGames(games => games.map(game => {
if (game.id !== currentGameId || game.status === 'completed') return game;
const updated = { ...game };
if (player === 1) updated.score1 = Math.max(0, updated.score1 + change);
if (player === 2) updated.score2 = Math.max(0, updated.score2 + change);
if (player === 3) updated.score3 = Math.max(0, updated.score3 + change);
updated.updatedAt = new Date().toISOString();
// Check for raceTo completion
if (updated.raceTo && (updated.score1 >= updated.raceTo || updated.score2 >= updated.raceTo || (updated.player3 && updated.score3 >= updated.raceTo))) {
setCompletionModal({ open: true, game: updated });
}
return updated;
}));
setCompletionModal({ open: false, game: null });
setScreen('game-detail');
}, [currentGameId]);
// Finish game
const handleFinishGame = useCallback(() => {
const game = games.find(g => g.id === currentGameId);
if (!game) return;
setCompletionModal({ open: true, game });
}, [games, currentGameId]);
const handleConfirmCompletion = useCallback(() => {
setGames(games => games.map(game => {
if (game.id !== currentGameId) return game;
return { ...game, status: 'completed', updatedAt: new Date().toISOString() };
}));
setCompletionModal({ open: false, game: null });
setScreen('game-detail');
}, [currentGameId]);
const handleRematch = useCallback(() => {
const completedGame = games.find(g => g.id === currentGameId);
if (!completedGame) return;
const newId = handleCreateGame({
player1: completedGame.player1,
player2: completedGame.player2,
player3: completedGame.player3,
gameType: completedGame.gameType,
raceTo: completedGame.raceTo,
});
setCompletionModal({ open: false, game: null });
showGameDetail(newId);
}, [games, currentGameId, handleCreateGame, showGameDetail]);
// Delete game
const handleDeleteGame = useCallback((id) => {
setModal({ open: true, gameId: id });
}, []);
const handleConfirmDelete = useCallback(() => {
setGames(games => games.filter(g => g.id !== modal.gameId));
setModal({ open: false, gameId: null });
setScreen('game-list');
}, [modal.gameId]);
const handleCancelDelete = useCallback(() => {
setModal({ open: false, gameId: null });
}, []);
// Validation modal
const showValidation = useCallback((message) => {
setValidation({ open: true, message });
}, []);
const closeValidation = useCallback(() => {
setValidation({ open: false, message: '' });
}, []);
// Step handlers
const handlePlayer1Next = (name) => {
setNewGameData(data => ({ ...data, player1: name }));
setNewGameStep('player2');
};
const handlePlayer2Next = (name) => {
setNewGameData(data => ({ ...data, player2: name }));
setNewGameStep('player3');
};
const handlePlayer3Next = (name) => {
setNewGameData(data => ({ ...data, player3: name }));
setNewGameStep('gameType');
};
const handleGameTypeNext = (type) => {
setNewGameData(data => ({ ...data, gameType: type, raceTo: type === '14/1 endlos' ? '150' : '50' }));
setNewGameStep('raceTo');
};
const handleRaceToNext = (raceTo) => {
const finalData = { ...newGameData, raceTo };
const newId = handleCreateGame(finalData);
showGameDetail(newId);
setNewGameStep(null);
setNewGameData({ player1: '', player2: '', player3: '', gameType: '', raceTo: '' });
};
const handleCancelNewGame = useCallback(() => {
setScreen('game-list');
setNewGameStep(null);
setNewGameData({ player1: '', player2: '', player3: '', gameType: '', raceTo: '' });
}, []);
return (
<div className="screen-container">
{screen === 'game-list' && (
<div className="screen active">
<div className="screen-content">
<button className="nav-button new-game-button" onClick={showNewGame} aria-label="Neues Spiel starten">Neues Spiel</button>
<GameList
games={games}
filter={filter}
onShowGameDetail={showGameDetail}
onDeleteGame={handleDeleteGame}
setFilter={setFilter}
/>
</div>
</div>
)}
{screen === 'new-game' && (
<div className="screen active">
<div className="screen-content">
{newGameStep === 'player1' && (
<Player1Step
playerNameHistory={playerNameHistory}
onNext={handlePlayer1Next}
onCancel={handleCancelNewGame}
initialValue={newGameData.player1}
/>
)}
{newGameStep === 'player2' && (
<Player2Step
playerNameHistory={playerNameHistory}
onNext={handlePlayer2Next}
onCancel={() => setNewGameStep('player1')}
initialValue={newGameData.player2}
/>
)}
{newGameStep === 'player3' && (
<Player3Step
playerNameHistory={playerNameHistory}
onNext={handlePlayer3Next}
onCancel={() => setNewGameStep('player2')}
initialValue={newGameData.player3}
/>
)}
{newGameStep === 'gameType' && (
<GameTypeStep
onNext={handleGameTypeNext}
onCancel={() => setNewGameStep('player3')}
initialValue={newGameData.gameType}
/>
)}
{newGameStep === 'raceTo' && (
<RaceToStep
onNext={handleRaceToNext}
onCancel={() => setNewGameStep('gameType')}
initialValue={newGameData.raceTo}
gameType={newGameData.gameType}
/>
)}
</div>
</div>
)}
{screen === 'game-detail' && (
<div className="screen active">
<div className="screen-content">
<GameDetail
game={games.find(g => g.id === currentGameId)}
onUpdateScore={handleUpdateScore}
onFinishGame={handleFinishGame}
onUpdateGame={handleGameAction}
onUndo={handleUndo}
onForfeit={handleForfeit}
onBack={showGameList}
/>
</div>
</div>
)}
<Modal
open={modal.open}
title="Spiel löschen"
message="Möchten Sie das Spiel wirklich löschen?"
onCancel={handleCancelDelete}
onConfirm={handleConfirmDelete}
/>
<ValidationModal
open={validation.open}
message={validation.message}
onClose={closeValidation}
/>
<GameCompletionModal
open={completionModal.open}
game={completionModal.game}
onConfirm={handleConfirmCompletion}
onClose={() => setCompletionModal({ open: false, game: null })}
onRematch={handleRematch}
/>
<FullscreenToggle />
</div>
);
};
export default App;

244
src/components/App.tsx Normal file
View File

@@ -0,0 +1,244 @@
import { h } from 'preact';
import { useEffect } 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, EndlosGame } from '../types/game';
import { Layout } from './ui/Layout';
import GameListScreen from './screens/GameListScreen';
import NewGameScreen from './screens/NewGameScreen';
import GameDetailScreen from './screens/GameDetailScreen';
import Modal from './Modal';
import ValidationModal from './ValidationModal';
import GameCompletionModal from './GameCompletionModal';
import FullscreenToggle from './FullscreenToggle';
/**
* Main App component for BSC Score
*/
export default function App() {
// State management hooks
const gameState = useGameState();
const navigation = useNavigation();
const newGameWizard = useNewGameWizard();
const modal = useModal();
const validationModal = useValidationModal();
const completionModal = useCompletionModal();
// Game lifecycle handlers
const handleCreateGame = (gameData: any) => {
const gameId = gameState.addGame(gameData);
newGameWizard.resetWizard();
navigation.showGameDetail(gameId);
};
const handleUpdateScore = (player: number, change: number) => {
if (!navigation.currentGameId) return;
const game = gameState.getGameById(navigation.currentGameId);
if (!game || game.status === 'completed' || 'players' in game) return;
const updatedGame = GameService.updateGameScore(game as StandardGame, player, change);
gameState.updateGame(navigation.currentGameId, updatedGame);
// Check for completion
if (GameService.isGameCompleted(updatedGame)) {
completionModal.openCompletionModal(updatedGame);
}
};
const handleGameAction = (updatedGame: EndlosGame) => {
if (!navigation.currentGameId) return;
const originalGame = gameState.getGameById(navigation.currentGameId);
if (!originalGame) return;
// Add undo state
const gameWithHistory = {
...updatedGame,
undoStack: [...(originalGame.undoStack || []), originalGame],
updatedAt: new Date().toISOString(),
};
gameState.updateGame(navigation.currentGameId, gameWithHistory);
// Check for completion
if (GameService.isGameCompleted(gameWithHistory)) {
completionModal.openCompletionModal(gameWithHistory);
}
};
const handleUndo = () => {
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);
gameState.updateGame(navigation.currentGameId, {
...lastState,
undoStack: newUndoStack,
});
};
const handleForfeit = () => {
if (!navigation.currentGameId) return;
const game = gameState.getGameById(navigation.currentGameId);
if (!game || !('players' in game)) return;
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 handleFinishGame = () => {
if (!navigation.currentGameId) return;
const game = gameState.getGameById(navigation.currentGameId);
if (!game) return;
completionModal.openCompletionModal(game);
};
const handleConfirmCompletion = () => {
if (!navigation.currentGameId) return;
gameState.updateGame(navigation.currentGameId, {
...gameState.getGameById(navigation.currentGameId)!,
status: 'completed',
updatedAt: new Date().toISOString(),
});
completionModal.closeCompletionModal();
};
const handleRematch = () => {
if (!navigation.currentGameId) return;
const completedGame = gameState.getGameById(navigation.currentGameId);
if (!completedGame) return;
let gameData;
if ('players' in completedGame) {
gameData = {
player1: completedGame.players[0]?.name || '',
player2: completedGame.players[1]?.name || '',
player3: completedGame.players[2]?.name || '',
gameType: completedGame.gameType,
raceTo: completedGame.raceTo.toString(),
};
} else {
gameData = {
player1: completedGame.player1,
player2: completedGame.player2,
player3: completedGame.player3 || '',
gameType: completedGame.gameType,
raceTo: completedGame.raceTo.toString(),
};
}
const newGameId = gameState.addGame(gameData);
completionModal.closeCompletionModal();
navigation.showGameDetail(newGameId);
};
const handleDeleteGame = (gameId: number) => {
modal.openModal(gameId);
};
const handleConfirmDelete = () => {
if (modal.modal.gameId) {
gameState.deleteGame(modal.modal.gameId);
}
modal.closeModal();
navigation.showGameList();
};
return (
<Layout>
{navigation.screen === 'game-list' && (
<GameListScreen
games={gameState.getFilteredGames()}
filter={gameState.filter}
onFilterChange={gameState.setFilter}
onShowGameDetail={navigation.showGameDetail}
onDeleteGame={handleDeleteGame}
onShowNewGame={() => {
newGameWizard.startWizard();
navigation.showNewGame();
}}
/>
)}
{navigation.screen === 'new-game' && (
<NewGameScreen
step={newGameWizard.newGameStep}
data={newGameWizard.newGameData}
playerHistory={gameState.getPlayerNameHistory()}
onStepChange={newGameWizard.nextStep}
onDataChange={newGameWizard.updateGameData}
onCreateGame={handleCreateGame}
onCancel={() => {
newGameWizard.resetWizard();
navigation.showGameList();
}}
onShowValidation={validationModal.showValidation}
/>
)}
{navigation.screen === 'game-detail' && navigation.currentGameId && (
<GameDetailScreen
game={gameState.getGameById(navigation.currentGameId)}
onUpdateScore={handleUpdateScore}
onFinishGame={handleFinishGame}
onUpdateGame={handleGameAction}
onUndo={handleUndo}
onForfeit={handleForfeit}
onBack={navigation.showGameList}
/>
)}
<Modal
open={modal.modal.open}
title="Spiel löschen"
message="Möchten Sie das Spiel wirklich löschen?"
onCancel={modal.closeModal}
onConfirm={handleConfirmDelete}
/>
<ValidationModal
open={validationModal.validation.open}
message={validationModal.validation.message}
onClose={validationModal.closeValidation}
/>
<GameCompletionModal
open={completionModal.completionModal.open}
game={completionModal.completionModal.game}
onConfirm={handleConfirmCompletion}
onClose={completionModal.closeCompletionModal}
onRematch={handleRematch}
/>
<FullscreenToggle />
</Layout>
);
}

View File

@@ -0,0 +1,33 @@
---
// This is an Astro component that properly leverages SSR and islands
---
<!-- Use Astro's islands architecture for better performance -->
<!-- Only hydrate the interactive app component when needed -->
<div id="app-root">
<slot name="app-content" />
</div>
<style>
#app-root {
min-height: 100vh;
width: 100%;
}
/* Progressive enhancement styles */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>
<script>
// Client-side progressive enhancement
document.addEventListener('DOMContentLoaded', () => {
// Add any progressive enhancement here
console.log('BSC Score App initialized');
});
</script>

View File

@@ -1,62 +0,0 @@
import { h } from 'preact';
import styles from './GameList.module.css';
/**
* List of games with filter and delete options.
* @param {object} props
* @param {object[]} props.games
* @param {string} props.filter
* @param {Function} props.setFilter
* @param {Function} props.onShowGameDetail
* @param {Function} props.onDeleteGame
* @returns {import('preact').VNode}
*/
const GameList = ({ games, filter = 'all', setFilter, onShowGameDetail, onDeleteGame }) => {
// Filter and sort games
const filteredGames = games
.filter(game => {
if (filter === 'active') return game.status === 'active';
if (filter === 'completed') return game.status === 'completed';
return true;
})
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
return (
<div className={styles['game-list'] + ' ' + styles['games-container']}>
<div className={styles['filter-buttons']}>
<button className={styles['filter-button'] + (filter === 'all' ? ' ' + styles['active'] : '')} onClick={() => setFilter('all')} aria-label="Alle Spiele anzeigen">Alle</button>
<button className={styles['filter-button'] + (filter === 'active' ? ' ' + styles['active'] : '')} onClick={() => setFilter('active')} aria-label="Nur aktive Spiele anzeigen">Aktiv</button>
<button className={styles['filter-button'] + (filter === 'completed' ? ' ' + styles['active'] : '')} onClick={() => setFilter('completed')} aria-label="Nur abgeschlossene Spiele anzeigen">Abgeschlossen</button>
</div>
{filteredGames.length === 0 ? (
<div className={styles['empty-state']}>Keine Spiele vorhanden</div>
) : (
filteredGames.map(game => {
const playerNames = game.player3
? `${game.player1} vs ${game.player2} vs ${game.player3}`
: `${game.player1} vs ${game.player2}`;
const scores = game.player3
? `${game.score1} - ${game.score2} - ${game.score3}`
: `${game.score1} - ${game.score2}`;
return (
<div
className={
styles['game-item'] + ' ' + (game.status === 'completed' ? styles['completed'] : styles['active'])
}
key={game.id}
>
<div className={styles['game-info']} onClick={() => onShowGameDetail(game.id)} role="button" tabIndex={0} aria-label={`Details für Spiel ${playerNames}`}>
<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>
<button className={styles['delete-button']} onClick={() => onDeleteGame(game.id)} aria-label={`Spiel löschen: ${playerNames}`}></button>
</div>
);
})
)}
</div>
);
};
export default GameList;

114
src/components/GameList.tsx Normal file
View File

@@ -0,0 +1,114 @@
import { h } from 'preact';
import { Card } from './ui/Card';
import { Button } from './ui/Button';
import styles from './GameList.module.css';
import type { Game, GameFilter, StandardGame } from '../types/game';
interface GameListProps {
games: Game[];
filter: GameFilter;
setFilter: (filter: GameFilter) => void;
onShowGameDetail: (gameId: number) => void;
onDeleteGame: (gameId: number) => void;
}
export default function GameList({
games,
filter = 'all',
setFilter,
onShowGameDetail,
onDeleteGame
}: GameListProps) {
const filteredGames = games
.filter(game => {
if (filter === 'active') return game.status === 'active';
if (filter === 'completed') return game.status === 'completed';
return true;
})
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const getPlayerNames = (game: Game): string => {
if ('players' in game) {
return game.players.map(p => p.name).join(' vs ');
} else {
const standardGame = game as StandardGame;
return standardGame.player3
? `${standardGame.player1} vs ${standardGame.player2} vs ${standardGame.player3}`
: `${standardGame.player1} vs ${standardGame.player2}`;
}
};
const getScores = (game: Game): string => {
if ('players' in game) {
return game.players.map(p => p.score).join(' - ');
} else {
const standardGame = game as StandardGame;
return standardGame.player3
? `${standardGame.score1} - ${standardGame.score2} - ${standardGame.score3}`
: `${standardGame.score1} - ${standardGame.score2}`;
}
};
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' },
];
return (
<div className={styles['game-list'] + ' ' + styles['games-container']}>
<div className={styles['filter-buttons']}>
{filterButtons.map(({ key, label, ariaLabel }) => (
<Button
key={key}
variant={filter === key ? 'primary' : 'secondary'}
size="small"
onClick={() => setFilter(key)}
aria-label={ariaLabel}
>
{label}
</Button>
))}
</div>
{filteredGames.length === 0 ? (
<div className={styles['empty-state']}>Keine Spiele vorhanden</div>
) : (
filteredGames.map(game => {
const playerNames = getPlayerNames(game);
const scores = getScores(game);
return (
<Card
key={game.id}
variant="elevated"
className={game.status === 'completed' ? styles['completed'] : styles['active']}
>
<div
className={styles['game-info']}
onClick={() => onShowGameDetail(game.id)}
role="button"
tabIndex={0}
aria-label={`Details für Spiel ${playerNames}`}
>
<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>
<Button
variant="danger"
size="small"
onClick={() => onDeleteGame(game.id)}
aria-label={`Spiel löschen: ${playerNames}`}
>
🗑
</Button>
</Card>
);
})
)}
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { h } from 'preact';
import { Screen } from '../ui/Layout';
import GameDetail from '../GameDetail';
import type { Game, EndlosGame } from '../../types/game';
interface GameDetailScreenProps {
game?: Game;
onUpdateScore: (player: number, change: number) => void;
onFinishGame: () => void;
onUpdateGame: (game: EndlosGame) => void;
onUndo: () => void;
onForfeit: () => void;
onBack: () => void;
}
export default function GameDetailScreen({
game,
onUpdateScore,
onFinishGame,
onUpdateGame,
onUndo,
onForfeit,
onBack,
}: GameDetailScreenProps) {
if (!game) {
return (
<Screen>
<div>Game not found</div>
</Screen>
);
}
return (
<Screen>
<GameDetail
game={game}
onUpdateScore={onUpdateScore}
onFinishGame={onFinishGame}
onUpdateGame={onUpdateGame}
onUndo={onUndo}
onForfeit={onForfeit}
onBack={onBack}
/>
</Screen>
);
}

View File

@@ -0,0 +1,45 @@
import { h } from 'preact';
import { Button } from '../ui/Button';
import { Screen } from '../ui/Layout';
import GameList from '../GameList';
import type { Game, GameFilter } from '../../types/game';
interface GameListScreenProps {
games: Game[];
filter: GameFilter;
onFilterChange: (filter: GameFilter) => void;
onShowGameDetail: (gameId: number) => void;
onDeleteGame: (gameId: number) => void;
onShowNewGame: () => void;
}
export default function GameListScreen({
games,
filter,
onFilterChange,
onShowGameDetail,
onDeleteGame,
onShowNewGame,
}: GameListScreenProps) {
return (
<Screen>
<Button
variant="primary"
size="large"
onClick={onShowNewGame}
aria-label="Neues Spiel starten"
style={{ width: '100%', marginBottom: '24px' }}
>
+ Neues Spiel
</Button>
<GameList
games={games}
filter={filter}
onShowGameDetail={onShowGameDetail}
onDeleteGame={onDeleteGame}
setFilter={onFilterChange}
/>
</Screen>
);
}

View File

@@ -0,0 +1,121 @@
import { h } from 'preact';
import { Screen } from '../ui/Layout';
import { Player1Step, Player2Step, Player3Step, GameTypeStep, RaceToStep } from '../NewGame';
import type { NewGameStep, NewGameData } from '../../types/game';
interface NewGameScreenProps {
step: NewGameStep;
data: NewGameData;
playerHistory: string[];
onStepChange: (step: NewGameStep) => void;
onDataChange: (data: Partial<NewGameData>) => void;
onCreateGame: (data: NewGameData) => void;
onCancel: () => void;
onShowValidation: (message: string) => void;
}
export default function NewGameScreen({
step,
data,
playerHistory,
onStepChange,
onDataChange,
onCreateGame,
onCancel,
onShowValidation,
}: NewGameScreenProps) {
const handlePlayer1Next = (name: string) => {
onDataChange({ player1: name });
onStepChange('player2');
};
const handlePlayer2Next = (name: string) => {
onDataChange({ player2: name });
onStepChange('player3');
};
const handlePlayer3Next = (name: string) => {
onDataChange({ player3: name });
onStepChange('gameType');
};
const handleGameTypeNext = (type: string) => {
onDataChange({
gameType: type as any, // Type assertion for now, could be improved with proper validation
raceTo: type === '14/1 endlos' ? '150' : '50'
});
onStepChange('raceTo');
};
const handleRaceToNext = (raceTo: string) => {
const finalData = { ...data, raceTo };
onCreateGame(finalData);
};
const handleStepBack = () => {
switch (step) {
case 'player2':
onStepChange('player1');
break;
case 'player3':
onStepChange('player2');
break;
case 'gameType':
onStepChange('player3');
break;
case 'raceTo':
onStepChange('gameType');
break;
default:
onCancel();
}
};
return (
<Screen>
{step === 'player1' && (
<Player1Step
playerNameHistory={playerHistory}
onNext={handlePlayer1Next}
onCancel={onCancel}
initialValue={data.player1}
/>
)}
{step === 'player2' && (
<Player2Step
playerNameHistory={playerHistory}
onNext={handlePlayer2Next}
onCancel={handleStepBack}
initialValue={data.player2}
/>
)}
{step === 'player3' && (
<Player3Step
playerNameHistory={playerHistory}
onNext={handlePlayer3Next}
onCancel={handleStepBack}
initialValue={data.player3}
/>
)}
{step === 'gameType' && (
<GameTypeStep
onNext={handleGameTypeNext}
onCancel={handleStepBack}
initialValue={data.gameType}
/>
)}
{step === 'raceTo' && (
<RaceToStep
onNext={handleRaceToNext}
onCancel={handleStepBack}
initialValue={data.raceTo}
gameType={data.gameType}
/>
)}
</Screen>
);
}

View File

@@ -0,0 +1,82 @@
/* Design tokens */
:root {
--color-primary: #ff9800;
--color-primary-hover: #ffa726;
--color-secondary: #333;
--color-secondary-hover: #444;
--color-danger: #f44336;
--color-danger-hover: #ef5350;
--color-white: #fff;
--border-radius: 6px;
--transition: all 0.2s ease;
}
.button {
border: none;
border-radius: var(--border-radius);
cursor: pointer;
font-weight: 600;
transition: var(--transition);
touch-action: manipulation;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
text-decoration: none;
user-select: none;
}
.button:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Variants */
.primary {
background: var(--color-primary);
color: var(--color-white);
}
.primary:hover:not(.disabled) {
background: var(--color-primary-hover);
}
.secondary {
background: var(--color-secondary);
color: var(--color-white);
}
.secondary:hover:not(.disabled) {
background: var(--color-secondary-hover);
}
.danger {
background: var(--color-danger);
color: var(--color-white);
}
.danger:hover:not(.disabled) {
background: var(--color-danger-hover);
}
/* Sizes */
.small {
padding: 8px 16px;
font-size: 0.875rem;
}
.medium {
padding: 12px 24px;
font-size: 1rem;
}
.large {
padding: 18px 32px;
font-size: 1.25rem;
}
/* States */
.disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -0,0 +1,32 @@
import { h } from 'preact';
import type { ButtonProps } from '../../types/ui';
import styles from './Button.module.css';
export function Button({
variant = 'secondary',
size = 'medium',
disabled = false,
children,
onClick,
'aria-label': ariaLabel,
...rest
}: ButtonProps) {
const classNames = [
styles.button,
styles[variant],
styles[size],
disabled && styles.disabled,
].filter(Boolean).join(' ');
return (
<button
className={classNames}
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
{...rest}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,53 @@
.card {
border-radius: var(--border-radius);
transition: var(--transition);
}
.default {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.elevated {
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08);
}
.outlined {
background: transparent;
border: 2px solid rgba(255, 255, 255, 0.2);
}
.clickable {
cursor: pointer;
border: none;
text-align: left;
width: 100%;
}
.clickable:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateY(-1px);
}
.clickable:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Padding variants */
.padding-none {
padding: 0;
}
.padding-small {
padding: 8px;
}
.padding-medium {
padding: 16px;
}
.padding-large {
padding: 24px;
}

View File

@@ -0,0 +1,34 @@
import { h } from 'preact';
import styles from './Card.module.css';
interface CardProps {
children: any;
variant?: 'default' | 'elevated' | 'outlined';
padding?: 'none' | 'small' | 'medium' | 'large';
className?: string;
onClick?: () => void;
}
export function Card({
children,
variant = 'default',
padding = 'medium',
className = '',
onClick
}: CardProps) {
const classNames = [
styles.card,
styles[variant],
styles[`padding-${padding}`],
onClick && styles.clickable,
className,
].filter(Boolean).join(' ');
const Component = onClick ? 'button' : 'div';
return (
<Component className={classNames} onClick={onClick}>
{children}
</Component>
);
}

View File

@@ -0,0 +1,24 @@
.layout {
min-height: 100vh;
background-color: #1a1a1a;
color: white;
}
.content {
max-width: 800px;
margin: 0 auto;
padding: 16px;
}
.screen {
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
}
@media (max-width: 768px) {
.content {
padding: 12px;
}
}

View File

@@ -0,0 +1,30 @@
import { h } from 'preact';
import styles from './Layout.module.css';
interface LayoutProps {
children: any;
className?: string;
}
export function Layout({ children, className = '' }: LayoutProps) {
return (
<div className={`${styles.layout} ${className}`}>
<div className={styles.content}>
{children}
</div>
</div>
);
}
interface ScreenProps {
children: any;
className?: string;
}
export function Screen({ children, className = '' }: ScreenProps) {
return (
<div className={`${styles.screen} ${className}`}>
{children}
</div>
);
}