13 Commits

Author SHA1 Message Date
Frank Schwenk
68434f885d feat(game-14.1): Implement undo, forfeit, log, and stats
- Implements a robust undo feature using a state stack.
- Adds a 'Forfeit' button to allow a player to concede the game.
- Introduces a 'Game Log' to track all turns, fouls, and re-racks.
- Calculates and displays post-game statistics (highest run, avg. pots/turn).
- All changes are related to issue #21.
2025-06-22 11:05:51 +02:00
Frank Schwenk
aa5ef1c5b2 feat(14.1): Implement foul system
Implements a comprehensive foul system for the 14.1 game mode as per issue #20.

- **`src/components/GameDetail141.jsx`**

    - Adds `handleFoul` function to manage standard (-1pt) and break (-2pt) fouls.

    - Implements logic for the 3-consecutive-foul rule, applying a -15pt penalty.

    - Adds foul counters and a visual warning for players with 2 consecutive fouls.

    - Resets the consecutive foul counter on a legal (non-foul) turn.

- **`src/components/GameDetail.module.css`**

    - Adds styles for foul buttons (`.foul-btn`).

    - Adds styles for the foul counter indicator (`.foul-indicator`) and warning (`.foul-warning`).

This commit fulfills the requirements for issue #20. The `.gitea` file is left unstaged as it points to the next issue to be worked on.
2025-06-22 10:43:21 +02:00
Frank Schwenk
6a25c18153 feat(game-14-1): Implement re-rack logic and scoring hooks
Implements the re-rack functionality and prepares the scoring system for foul integration, as detailed in issue #19.

- Adds '+14' and '+15' re-rack buttons to the game view.
- Creates a  function to update the number of balls on the table.
- Modifies the  function to accept an optional  argument, preparing it for the next phase of development.

Closes #19
2025-06-20 16:07:38 +02:00
Frank Schwenk
a71c65852d feat(game-14-1): Implement phase 1 foundation
Implements the foundational UI and logic for the 14.1 Endless game mode, as detailed in issue #18.

- Adds a new  component to handle the specific game view.
- Introduces a modal within the game view to select the starting player.
- Replaces text input with a button grid for selecting remaining balls.
- Updates  to correctly initialize the 14.1 game state.

Closes #18
2025-06-20 15:35:38 +02:00
Frank Schwenk
875e9c8795 feat: Implement rematch functionality
Refs #17

Adds a 'Rematch' button to the game completion modal.

Introduces a 'handleRematch' function in 'App.jsx' to create a new game with the same players and settings as the previous game.

The 'onRematch' handler is passed down to the 'GameCompletionModal' component.

Adds '.gitea' to '.gitignore' to prevent tracking local issue context.
2025-06-20 13:35:42 +02:00
Frank Schwenk
429d479f69 feat: Implement score increment on click and cleanup CSS
- Implemented score increment functionality directly on the score display in GameDetail.jsx.

- Deleted obsolete ValidationModal.module.css to finalize style consolidation.

- This resolves all outstanding tasks for the refactor and adds a minor feature enhancement.

- Closes #1
2025-06-20 13:01:30 +02:00
Frank Schwenk
dbc173f57b feat: optimize player selection for touch input
- Increases the number of quick-pick buttons from 4 to 10.
- Adds a '...' button that appears when more than 10 players exist in history.
- Clicking '...' opens a scrollable modal listing all past players for easy selection.
- This provides a much faster player selection flow on touch devices.

Closes #4
2025-06-20 11:03:37 +02:00
Frank Schwenk
b466dd2a0a feat: complete wizard navigation for all steps
- Adds forward navigation arrows to the 'Game Type' and 'Race To' steps in the new game wizard.
- Unifies navigation logic across all five steps.
- Users can now review their selections before proceeding.

Closes #11
2025-06-20 10:44:27 +02:00
Frank Schwenk
14fd711858 refactor: Update Race To step UI and logic
- Renames 'Offen' to 'Endlos' and moves it to a separate line.
- Extends quick-pick buttons to include 1-9.
- Removes 'Custom' button and makes numeric input always visible.
- Updates placeholder text for the custom input.
- Closes #10
2025-06-20 10:23:14 +02:00
Frank Schwenk
1c77661dbc feat: Implement Game Type selection step
- Adds the 'GameTypeStep' to the new game wizard.
- Features large, touch-friendly buttons for selecting game types.
- Selection automatically proceeds to the next step.
- Includes progress indicator and back navigation.
- Closes #9
2025-06-20 10:16:13 +02:00
Frank Schwenk
47554cdd27 feat: Implement Player 3 input step
- Implemented the third step of the new game creation wizard for Player 3's name input.
- The step is optional and includes a 'Skip' button.
- Includes autosuggestions from player history and quick-pick buttons.
- Aligned styling and layout with previous steps, including fixes for button alignment.
- Closes #8
2025-06-20 10:10:32 +02:00
Frank Schwenk
a2b618ce16 feat(wizard): Player 2 step and multi-step navigation
- Add Player2Step component for player 2 name input
- Implement multi-step wizard logic in App.jsx
- Player 1/2 steps now allow forward/back navigation
- Left arrow on Player 2 returns to Player 1, not abort
- Prepare placeholders for further steps

Closes #7
2025-06-18 20:59:05 +02:00
Frank Schwenk
76ef005cda feat(wizard): Player 1 step UI overhaul
- Quick-pick now advances to next step
- Added clear (×) icon to input field
- Replaced navigation buttons with large left/right arrows
- All controls are touch-friendly and visually prominent

Closes #6
2025-06-18 20:48:28 +02:00
12 changed files with 1538 additions and 119 deletions

2
.gitea
View File

@@ -1 +1 @@
@https://gitea.schwenk.online/froxxxy/bscscore/issues/1 @https://gitea.schwenk.online/froxxxy/bscscore/issues/10

2
.gitignore vendored
View File

@@ -24,4 +24,4 @@ pnpm-debug.log*
.idea/ .idea/
.gitea .gitea
dev/ dev/.gitea

View File

@@ -2,7 +2,7 @@ import { h } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks'; import { useState, useEffect, useCallback } from 'preact/hooks';
import GameList from './GameList.jsx'; import GameList from './GameList.jsx';
import GameDetail from './GameDetail.jsx'; import GameDetail from './GameDetail.jsx';
import NewGame from './NewGame.jsx'; import { Player1Step, Player2Step, Player3Step, GameTypeStep, RaceToStep } from './NewGame.jsx';
import Modal from './Modal.jsx'; import Modal from './Modal.jsx';
import ValidationModal from './ValidationModal.jsx'; import ValidationModal from './ValidationModal.jsx';
import GameCompletionModal from './GameCompletionModal.jsx'; import GameCompletionModal from './GameCompletionModal.jsx';
@@ -23,6 +23,8 @@ const App = () => {
const [validation, setValidation] = useState({ open: false, message: '' }); const [validation, setValidation] = useState({ open: false, message: '' });
const [completionModal, setCompletionModal] = useState({ open: false, game: null }); const [completionModal, setCompletionModal] = useState({ open: false, game: null });
const [filter, setFilter] = useState('all'); 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 // Load games from localStorage on mount
useEffect(() => { useEffect(() => {
@@ -55,6 +57,8 @@ const App = () => {
const showNewGame = useCallback(() => { const showNewGame = useCallback(() => {
setScreen('new-game'); setScreen('new-game');
setCurrentGameId(null); setCurrentGameId(null);
setNewGameStep('player1');
setNewGameData({ player1: '', player2: '', player3: '', gameType: '', raceTo: '' });
}, []); }, []);
const showGameDetail = useCallback((id) => { const showGameDetail = useCallback((id) => {
setCurrentGameId(id); setCurrentGameId(id);
@@ -65,22 +69,92 @@ const App = () => {
const handleCreateGame = useCallback(({ player1, player2, player3, gameType, raceTo }) => { const handleCreateGame = useCallback(({ player1, player2, player3, gameType, raceTo }) => {
const newGame = { const newGame = {
id: Date.now(), id: Date.now(),
player1,
player2,
player3,
score1: 0,
score2: 0,
score3: 0,
gameType, gameType,
raceTo, raceTo: parseInt(raceTo, 10),
status: 'active', status: 'active',
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: 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]); setGames(g => [newGame, ...g]);
return newGame.id; 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 // Score update
const handleUpdateScore = useCallback((player, change) => { const handleUpdateScore = useCallback((player, change) => {
setGames(games => games.map(game => { setGames(games => games.map(game => {
@@ -96,6 +170,8 @@ const App = () => {
} }
return updated; return updated;
})); }));
setCompletionModal({ open: false, game: null });
setScreen('game-detail');
}, [currentGameId]); }, [currentGameId]);
// Finish game // Finish game
@@ -113,6 +189,22 @@ const App = () => {
setScreen('game-detail'); setScreen('game-detail');
}, [currentGameId]); }, [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 // Delete game
const handleDeleteGame = useCallback((id) => { const handleDeleteGame = useCallback((id) => {
setModal({ open: true, gameId: id }); setModal({ open: true, gameId: id });
@@ -134,6 +226,37 @@ const App = () => {
setValidation({ open: false, message: '' }); 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 ( return (
<div className="screen-container"> <div className="screen-container">
{screen === 'game-list' && ( {screen === 'game-list' && (
@@ -153,16 +276,44 @@ const App = () => {
{screen === 'new-game' && ( {screen === 'new-game' && (
<div className="screen active"> <div className="screen active">
<div className="screen-content"> <div className="screen-content">
<NewGame {newGameStep === 'player1' && (
onCreateGame={handleCreateGame} <Player1Step
playerNameHistory={playerNameHistory} playerNameHistory={playerNameHistory}
onCancel={showGameList} onNext={handlePlayer1Next}
onGameCreated={id => { onCancel={handleCancelNewGame}
setCurrentGameId(id); initialValue={newGameData.player1}
setScreen('game-detail'); />
}} )}
initialValues={games[0]} {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}
/>
)}
</div> </div>
</div> </div>
)} )}
@@ -171,8 +322,11 @@ const App = () => {
<div className="screen-content"> <div className="screen-content">
<GameDetail <GameDetail
game={games.find(g => g.id === currentGameId)} game={games.find(g => g.id === currentGameId)}
onFinishGame={handleFinishGame}
onUpdateScore={handleUpdateScore} onUpdateScore={handleUpdateScore}
onFinishGame={handleFinishGame}
onUpdateGame={handleGameAction}
onUndo={handleUndo}
onForfeit={handleForfeit}
onBack={showGameList} onBack={showGameList}
/> />
</div> </div>
@@ -195,6 +349,7 @@ const App = () => {
game={completionModal.game} game={completionModal.game}
onConfirm={handleConfirmCompletion} onConfirm={handleConfirmCompletion}
onClose={() => setCompletionModal({ open: false, game: null })} onClose={() => setCompletionModal({ open: false, game: null })}
onRematch={handleRematch}
/> />
<FullscreenToggle /> <FullscreenToggle />
</div> </div>

View File

@@ -2,6 +2,57 @@ import { h } from 'preact';
import modalStyles from './Modal.module.css'; import modalStyles from './Modal.module.css';
import styles from './GameCompletionModal.module.css'; import styles from './GameCompletionModal.module.css';
const calculateStats = (game) => {
if (game.gameType !== '14/1 endlos' || !game.log) {
return null;
}
const stats = {};
game.players.forEach(p => {
stats[p.name] = {
totalPots: 0,
turnCount: 0,
highestRun: 0,
currentRun: 0,
};
});
for (const entry of game.log) {
if (entry.type === 'turn') {
const playerStats = stats[entry.player];
playerStats.turnCount += 1;
if (entry.ballsPotted > 0) {
playerStats.totalPots += entry.ballsPotted;
playerStats.currentRun += entry.ballsPotted;
} else {
// Run ends on a 0-pot turn
playerStats.highestRun = Math.max(playerStats.highestRun, playerStats.currentRun);
playerStats.currentRun = 0;
}
} else if (entry.type === 'foul') {
const playerStats = stats[entry.player];
playerStats.turnCount += 1;
// Run ends on a foul
playerStats.highestRun = Math.max(playerStats.highestRun, playerStats.currentRun);
playerStats.currentRun = 0;
}
}
// Final check for runs that extend to the end of the game
Object.values(stats).forEach(playerStats => {
playerStats.highestRun = Math.max(playerStats.highestRun, playerStats.currentRun);
});
// Calculate averages
Object.keys(stats).forEach(playerName => {
const ps = stats[playerName];
ps.avgPots = ps.turnCount > 0 ? (ps.totalPots / ps.turnCount).toFixed(2) : 0;
});
return stats;
};
/** /**
* Modal shown when a game is completed. * Modal shown when a game is completed.
* @param {object} props * @param {object} props
@@ -9,18 +60,33 @@ import styles from './GameCompletionModal.module.css';
* @param {object} props.game * @param {object} props.game
* @param {Function} props.onConfirm * @param {Function} props.onConfirm
* @param {Function} props.onClose * @param {Function} props.onClose
* @param {Function} props.onRematch
* @returns {import('preact').VNode|null} * @returns {import('preact').VNode|null}
*/ */
const GameCompletionModal = ({ open, game, onConfirm, onClose }) => { const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }) => {
if (!open || !game) return null; if (!open || !game) return null;
const playerNames = [game.player1, game.player2, game.player3].filter(Boolean);
const scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]); let playerNames, scores, maxScore, winners, winnerText, gameStats;
const maxScore = Math.max(...scores);
// Find all winners (could be a tie) if (game.gameType === '14/1 endlos') {
const winners = playerNames.filter((name, idx) => scores[idx] === maxScore); playerNames = game.players.map(p => p.name);
const winnerText = winners.length > 1 scores = game.players.map(p => p.score);
? `Unentschieden zwischen ${winners.join(' und ')}` gameStats = calculateStats(game);
: `${winners[0]} hat gewonnen!`; } else {
playerNames = [game.player1, game.player2, game.player3].filter(Boolean);
scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]);
}
if (game.forfeitedBy) {
winnerText = `${game.winner} hat gewonnen, da ${game.forfeitedBy} aufgegeben hat.`;
} else {
maxScore = Math.max(...scores);
winners = playerNames.filter((name, idx) => scores[idx] === maxScore);
winnerText = winners.length > 1
? `Unentschieden zwischen ${winners.join(' und ')}`
: `${winners[0]} hat gewonnen!`;
}
return ( return (
<div id="game-completion-modal" className={modalStyles['modal'] + ' ' + modalStyles['show']} role="dialog" aria-modal="true" aria-labelledby="completion-modal-title"> <div id="game-completion-modal" className={modalStyles['modal'] + ' ' + modalStyles['show']} role="dialog" aria-modal="true" aria-labelledby="completion-modal-title">
<div className={modalStyles['modal-content']}> <div className={modalStyles['modal-content']}>
@@ -38,9 +104,24 @@ const GameCompletionModal = ({ open, game, onConfirm, onClose }) => {
))} ))}
</div> </div>
<div className={styles['winner-announcement']}><h3>{winnerText}</h3></div> <div className={styles['winner-announcement']}><h3>{winnerText}</h3></div>
{gameStats && (
<div className={styles['stats-container']}>
<h4 className={styles['stats-title']}>Statistiken</h4>
{playerNames.map(name => (
<div key={name} className={styles['player-stats']}>
<div className={styles['player-name-stats']}>{name}</div>
<div className={styles['stat-item']}>Höchste Serie: <strong>{gameStats[name].highestRun}</strong></div>
<div className={styles['stat-item']}>Punkte / Aufnahme: <strong>{gameStats[name].avgPots}</strong></div>
</div>
))}
</div>
)}
</div> </div>
<div className={modalStyles['modal-footer']}> <div className={modalStyles['modal-footer']}>
<button className={styles['btn'] + ' ' + styles['btn--warning']} onClick={onConfirm} aria-label="Bestätigen">Bestätigen</button> <button className={styles['btn'] + ' ' + styles['btn--warning']} onClick={onConfirm} aria-label="Bestätigen">Bestätigen</button>
<button className={styles['btn'] + ' ' + styles['btn--primary']} onClick={onRematch} aria-label="Rematch">Rematch</button>
<button className={styles['btn']} onClick={onClose} aria-label="Abbrechen">Abbrechen</button> <button className={styles['btn']} onClick={onClose} aria-label="Abbrechen">Abbrechen</button>
</div> </div>
</div> </div>

View File

@@ -34,9 +34,10 @@
font-weight: 700; font-weight: 700;
} }
.winner-announcement h3 { .winner-announcement h3 {
font-size: 1.2rem;
margin: 0; margin: 0;
color: #fff; font-size: 1.5rem;
color: #2c3e50;
text-align: center;
} }
.btn { .btn {
flex: 1; flex: 1;
@@ -65,3 +66,45 @@
padding: 14px 0; padding: 14px 0;
} }
} }
.stats-container {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #dee2e6;
}
.stats-title {
text-align: center;
font-size: 1.3rem;
color: #495057;
margin-bottom: 1rem;
}
.player-stats {
margin-bottom: 1rem;
padding: 0.75rem;
background-color: #f8f9fa;
border-radius: 4px;
}
.player-name-stats {
font-weight: bold;
margin-bottom: 0.5rem;
color: #343a40;
}
.stat-item {
display: flex;
justify-content: space-between;
font-size: 0.95rem;
color: #6c757d;
}
.stat-item strong {
color: #212529;
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 4px;
}

View File

@@ -1,5 +1,6 @@
import { h } from 'preact'; import { h } from 'preact';
import styles from './GameDetail.module.css'; import styles from './GameDetail.module.css';
import GameDetail141 from './GameDetail141.jsx';
/** /**
* Game detail view for a single game. * Game detail view for a single game.
@@ -7,11 +8,19 @@ import styles from './GameDetail.module.css';
* @param {object} props.game * @param {object} props.game
* @param {Function} props.onFinishGame * @param {Function} props.onFinishGame
* @param {Function} props.onUpdateScore * @param {Function} props.onUpdateScore
* @param {Function} props.onUpdateGame
* @param {Function} props.onUndo
* @param {Function} props.onForfeit
* @param {Function} props.onBack * @param {Function} props.onBack
* @returns {import('preact').VNode|null} * @returns {import('preact').VNode|null}
*/ */
const GameDetail = ({ game, onFinishGame, onUpdateScore, onBack }) => { const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, onForfeit, onBack }) => {
if (!game) return null; if (!game) return null;
if (game.gameType === '14/1 endlos') {
return <GameDetail141 game={game} onUpdate={onUpdateGame} onUndo={onUndo} onForfeit={onForfeit} onBack={onBack} />;
}
const isCompleted = game.status === 'completed'; const isCompleted = game.status === 'completed';
const playerNames = [game.player1, game.player2, game.player3].filter(Boolean); const playerNames = [game.player1, game.player2, game.player3].filter(Boolean);
const scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]); const scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]);
@@ -28,7 +37,14 @@ const GameDetail = ({ game, onFinishGame, onUpdateScore, onBack }) => {
key={name + idx} key={name + idx}
> >
<span className={styles['player-name']}>{name}</span> <span className={styles['player-name']}>{name}</span>
<span className={styles['score']} id={`score${idx+1}`}>{scores[idx]}</span> <span
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.`}
>
{scores[idx]}
</span>
<button <button
className={styles['score-button']} className={styles['score-button']}
disabled={isCompleted} disabled={isCompleted}

View File

@@ -123,3 +123,154 @@
width: 100%; width: 100%;
justify-content: center; justify-content: center;
} }
.franky .player-name {
font-weight: bold;
color: #ff8c00; /* Example color */
}
.active-player {
border: 2px solid #4caf50;
box-shadow: 0 0 10px #4caf50;
}
.turn-indicator {
margin: 20px 0;
font-size: 1.2rem;
text-align: center;
}
.potted-balls-container {
margin-top: 2rem;
padding: 1rem;
background: #2a2a2a;
border-radius: 8px;
}
.potted-balls-header {
text-align: center;
font-size: 1.1rem;
margin-bottom: 1rem;
font-weight: 600;
}
.potted-balls-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
gap: 10px;
}
.potted-ball-btn {
padding: 1rem;
font-size: 1.2rem;
font-weight: bold;
border-radius: 8px;
border: 1px solid #444;
background-color: #333;
color: #fff;
cursor: pointer;
transition: background-color 0.2s, transform 0.2s;
}
.potted-ball-btn:hover:not(:disabled) {
background-color: #45a049;
transform: translateY(-2px);
}
.potted-ball-btn:disabled {
background-color: #222;
color: #555;
cursor: not-allowed;
}
.rerack-controls {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
}
.rerack-btn {
padding: 0.8rem 1.5rem;
font-size: 1rem;
font-weight: bold;
border-radius: 8px;
border: 1px solid #444;
background-color: #3a539b;
color: #fff;
cursor: pointer;
transition: background-color 0.2s;
}
.rerack-btn:hover {
background-color: #4a6fbf;
}
.foul-controls {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
}
.foul-btn {
flex-grow: 1;
background-color: #ffc107; /* Amber */
color: #212529;
border: none;
padding: 0.75rem;
border-radius: var(--border-radius);
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s;
}
.foul-btn:hover {
background-color: #ffca2c;
}
.foul-btn:disabled {
background-color: #e0e0e0;
color: #9e9e9e;
cursor: not-allowed;
}
.foul-indicator {
font-size: 1rem;
font-weight: bold;
color: #fff;
background-color: #c0392b;
padding: 4px 8px;
border-radius: 4px;
margin-top: 8px;
display: inline-block;
}
.foul-warning {
background-color: #f39c12;
color: #000;
}
/* Game Log Styles */
.game-log {
width: 100%;
margin-top: 1.5rem;
padding: 1rem;
background-color: #f8f9fa;
border-radius: var(--border-radius);
border: 1px solid #dee2e6;
}
.log-title {
margin-top: 0;
margin-bottom: 0.5rem;
font-size: 1.2rem;
color: #495057;
}
.log-list {
list-style-type: none;
padding: 0;
margin: 0;
max-height: 200px;
overflow-y: auto;
font-size: 0.9rem;
}
.log-entry {
padding: 0.5rem 0.25rem;
border-bottom: 1px solid #e9ecef;
color: #6c757d;
}
.log-entry:last-child {
border-bottom: none;
}

View File

@@ -0,0 +1,208 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import styles from './GameDetail.module.css';
import modalStyles from './PlayerSelectModal.module.css';
const StartingPlayerModal = ({ players, onSelect, onCancel }) => (
<div className={modalStyles.modalOverlay}>
<div className={modalStyles.modalContent} onClick={e => e.stopPropagation()}>
<div className={modalStyles.modalHeader}>
<h3>Welcher Spieler fängt an?</h3>
{/* A cancel button isn't strictly needed if a choice is mandatory */}
</div>
<div className={modalStyles.playerList}>
{players.map((player, index) => (
<button key={player.name} className={modalStyles.playerItem} onClick={() => onSelect(index)}>
{player.name}
</button>
))}
</div>
</div>
</div>
);
const GameLog = ({ log }) => {
if (!log || log.length === 0) {
return null;
}
return (
<div className={styles['game-log']}>
<h4 className={styles['log-title']}>Game Log</h4>
<ul className={styles['log-list']}>
{log.slice().reverse().map((entry, index) => (
<li key={index} className={styles['log-entry']}>
{entry.type === 'rerack' && `Re-Rack (+${entry.ballsAdded} balls).`}
{entry.foul && `Foul by ${entry.player}: ${entry.foul} (${entry.totalDeduction} pts).`}
{entry.ballsPotted !== undefined && `${entry.player}: ${entry.ballsPotted} balls potted. Score: ${entry.newScore}`}
</li>
))}
</ul>
</div>
);
};
const GameDetail141 = ({ game, onUpdate, onUndo, onForfeit, onBack }) => {
const handleSelectStartingPlayer = (playerIndex) => {
onUpdate({
...game,
currentPlayer: playerIndex,
});
};
// If no player is selected yet, show the modal
if (game.currentPlayer === null || game.currentPlayer === undefined) {
return <StartingPlayerModal players={game.players} onSelect={handleSelectStartingPlayer} />;
}
const currentPlayer = game.players[game.currentPlayer];
const handleTurnEnd = (remainingBalls, foulPoints = 0) => {
if (remainingBalls > game.ballsOnTable) {
console.error("Cannot leave more balls than are on the table.");
return;
}
const ballsPotted = game.ballsOnTable - remainingBalls;
const newScore = currentPlayer.score + ballsPotted - foulPoints;
const updatedPlayers = game.players.map((p, index) =>
index === game.currentPlayer ? { ...p, score: newScore, consecutiveFouls: 0 } : p
);
const nextPlayer = (game.currentPlayer + 1) % game.players.length;
onUpdate({
...game,
players: updatedPlayers,
ballsOnTable: remainingBalls,
currentPlayer: nextPlayer,
log: [...(game.log || []), { type: 'turn', player: currentPlayer.name, ballsPotted, foulPoints, newScore, ballsOnTable: remainingBalls }],
});
};
const handleFoul = (foulType) => {
let foulPoints = 0;
let penalty = 0;
const newConsecutiveFouls = (currentPlayer.consecutiveFouls || 0) + 1;
if (foulType === 'standard') {
foulPoints = 1;
} else if (foulType === 'break') {
foulPoints = 2;
}
if (newConsecutiveFouls === 3) {
penalty = 15;
}
const totalDeduction = foulPoints + penalty;
const newScore = currentPlayer.score - totalDeduction;
const updatedPlayers = game.players.map((p, index) => {
if (index === game.currentPlayer) {
return {
...p,
score: newScore,
consecutiveFouls: newConsecutiveFouls === 3 ? 0 : newConsecutiveFouls,
};
}
return p;
});
const nextPlayer = (game.currentPlayer + 1) % game.players.length;
onUpdate({
...game,
players: updatedPlayers,
currentPlayer: nextPlayer,
log: [
...(game.log || []),
{
type: 'foul',
player: currentPlayer.name,
foul: foulType,
foulPoints,
penalty,
totalDeduction,
newScore,
consecutiveFouls: newConsecutiveFouls
},
],
});
};
const handleReRack = (ballsToAdd) => {
const newBallsOnTable = game.ballsOnTable + ballsToAdd;
onUpdate({
...game,
ballsOnTable: newBallsOnTable,
log: [...(game.log || []), { type: 'rerack', player: currentPlayer.name, ballsAdded: ballsToAdd, ballsOnTable: newBallsOnTable }],
});
};
return (
<div className={styles['game-detail']}>
<div className={styles['game-title']}>
14/1 endlos | Race to {game.raceTo}
</div>
<div className={styles['scores-container']}>
{game.players.map((p, idx) => (
<div
className={`${styles['player-score']} ${idx === game.currentPlayer ? styles['active-player'] : ''}`}
key={p.name}
>
<span className={styles['player-name']}>{p.name}</span>
<span className={styles['score']}>{p.score}</span>
{p.consecutiveFouls > 0 && (
<span className={`${styles['foul-indicator']} ${p.consecutiveFouls === 2 ? styles['foul-warning'] : ''}`}>
Fouls: {p.consecutiveFouls}
</span>
)}
</div>
))}
</div>
<div className={styles['turn-indicator']}>
Aktueller Spieler: <strong>{currentPlayer.name}</strong> ({game.ballsOnTable} Bälle auf dem Tisch)
</div>
<div className={styles['potted-balls-container']}>
<p className={styles['potted-balls-header']}>Bälle am Ende der Aufnahme:</p>
<div className={styles['potted-balls-grid']}>
{Array.from({ length: 16 }, (_, i) => i).map(num => (
<button
key={num}
onClick={() => handleTurnEnd(num)}
disabled={num > game.ballsOnTable}
className={styles['potted-ball-btn']}
>
{num}
</button>
))}
</div>
</div>
<div className={styles['rerack-controls']}>
<button onClick={() => handleReRack(14)} className={styles['rerack-btn']}>+14 Re-Rack</button>
<button onClick={() => handleReRack(15)} className={styles['rerack-btn']}>+15 Re-Rack</button>
</div>
<div className={styles['foul-controls']}>
<button onClick={() => handleFoul('standard')} className={styles['foul-btn']}>Standard Foul (-1)</button>
<button onClick={() => handleFoul('break')} className={styles['foul-btn']}>Break Foul (-2)</button>
</div>
<GameLog log={game.log} />
<div className={styles['game-detail-controls']}>
<button className="btn" onClick={onUndo} disabled={!game.undoStack || game.undoStack.length === 0}>Undo</button>
<button className="btn btn-danger" onClick={onForfeit}>Forfeit</button>
<button className="btn" onClick={onBack}>Zurück zur Liste</button>
</div>
</div>
);
};
export default GameDetail141;

View File

@@ -1,117 +1,648 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks'; import { useState, useEffect, useRef } from 'preact/hooks';
import styles from './NewGame.module.css'; import styles from './NewGame.module.css';
import modalStyles from './PlayerSelectModal.module.css';
const PlayerSelectModal = ({ players, onSelect, onClose }) => (
<div className={modalStyles.modalOverlay} onClick={onClose}>
<div className={modalStyles.modalContent} onClick={e => e.stopPropagation()}>
<div className={modalStyles.modalHeader}>
<h3>Alle Spieler</h3>
<button className={modalStyles.closeButton} onClick={onClose}>×</button>
</div>
<div className={modalStyles.playerList}>
{players.map(player => (
<button key={player} className={modalStyles.playerItem} onClick={() => onSelect(player)}>
{player}
</button>
))}
</div>
</div>
</div>
);
/** /**
* New game creation form. * Player 1 input step for multi-step game creation wizard.
* @param {object} props * @param {object} props
* @param {Function} props.onCreateGame
* @param {string[]} props.playerNameHistory * @param {string[]} props.playerNameHistory
* @param {Function} props.onNext
* @param {Function} props.onCancel * @param {Function} props.onCancel
* @param {Function} props.onGameCreated * @param {string} [props.initialValue]
* @param {object} props.initialValues
* @returns {import('preact').VNode} * @returns {import('preact').VNode}
*/ */
const NewGame = ({ onCreateGame, playerNameHistory, onCancel, onGameCreated, initialValues }) => { const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }) => {
const [player1, setPlayer1] = useState(initialValues?.player1 || ''); const [player1, setPlayer1] = useState(initialValue);
const [player2, setPlayer2] = useState(initialValues?.player2 || '');
const [player3, setPlayer3] = useState(initialValues?.player3 || '');
const [gameType, setGameType] = useState(initialValues?.gameType || '8-Ball');
const [raceTo, setRaceTo] = useState(initialValues?.raceTo ? String(initialValues.raceTo) : '');
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
const [isModalOpen, setIsModalOpen] = useState(false);
const inputRef = useRef(null);
useEffect(() => { useEffect(() => {
setPlayer1(initialValues?.player1 || ''); if (!player1) {
setPlayer2(initialValues?.player2 || ''); setFilteredNames(playerNameHistory);
setPlayer3(initialValues?.player3 || ''); } else {
setGameType(initialValues?.gameType || '8-Ball'); setFilteredNames(
setRaceTo(initialValues?.raceTo ? String(initialValues.raceTo) : ''); playerNameHistory.filter(name =>
setError(null); name.toLowerCase().includes(player1.toLowerCase())
}, [initialValues]); )
);
}
}, [player1, playerNameHistory]);
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
if (!player1.trim() || !player2.trim()) { if (!player1.trim()) {
setError('Bitte Namen für beide Spieler eingeben'); setError('Bitte Namen für Spieler 1 eingeben');
return; return;
} }
const id = onCreateGame({ setError(null);
player1: player1.trim(), onNext(player1.trim());
player2: player2.trim(), };
player3: player3.trim() || null,
gameType, const handleQuickPick = (name) => {
raceTo: raceTo ? parseInt(raceTo) : null setError(null);
}); onNext(name);
if (onGameCreated && id) { };
onGameCreated(id);
} const handleModalSelect = (name) => {
setIsModalOpen(false);
handleQuickPick(name);
}; };
const handleClear = () => { const handleClear = () => {
setPlayer1(''); setPlayer1('');
setPlayer2('');
setPlayer3('');
setGameType('8-Ball');
setRaceTo('');
setError(null); setError(null);
if (inputRef.current) inputRef.current.focus();
}; };
return ( return (
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Neues Spiel Formular"> <form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spieler 1 Eingabe" autoComplete="off">
<div className={styles['screen-title']}>Neues Spiel</div> <div className={styles['screen-title']}>Neues Spiel Schritt 1/5</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}> <div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
<button type="button" className="btn" onClick={handleClear} aria-label="Felder leeren">Felder leeren</button> <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>
<div className={styles['player-inputs']}> <div className={styles['player-input']} style={{ marginBottom: 32, position: 'relative' }}>
<div className={styles['player-input']}> <label htmlFor="player1-input" style={{ fontSize: '1.3rem', fontWeight: 600 }}>Spieler 1</label>
<label htmlFor="player1-input">Spieler 1</label> <div style={{ position: 'relative', width: '100%' }}>
<div className={styles['name-input-container']}> <input
<input id="player1-input" className={styles['name-input']} placeholder="Name Spieler 1" value={player1} onInput={e => setPlayer1(e.target.value)} list="player1-history" aria-label="Name Spieler 1" /> id="player1-input"
<datalist id="player1-history"> className={styles['name-input']}
{playerNameHistory.map((name, idx) => <option value={name} key={name + idx} />)} placeholder="Name Spieler 1"
</datalist> value={player1}
</div> onInput={e => setPlayer1(e.target.value)}
autoFocus
autoComplete="off"
aria-label="Name Spieler 1"
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
ref={inputRef}
/>
{player1 && (
<button
type="button"
className={styles['clear-input-btn']}
aria-label="Feld leeren"
onClick={handleClear}
style={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 24,
color: '#aaa',
padding: 0,
zIndex: 2
}}
tabIndex={0}
>
{/* Unicode heavy multiplication X */}
×
</button>
)}
</div> </div>
<div className={styles['player-input']}> {filteredNames.length > 0 && (
<label htmlFor="player2-input">Spieler 2</label> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 8 }}>
<div className={styles['name-input-container']}> {filteredNames.slice(0, 10).map((name, idx) => (
<input id="player2-input" className={styles['name-input']} placeholder="Name Spieler 2" value={player2} onInput={e => setPlayer2(e.target.value)} list="player2-history" aria-label="Name Spieler 2" /> <button
<datalist id="player2-history"> type="button"
{playerNameHistory.map((name, idx) => <option value={name} key={name + idx} />)} key={name + idx}
</datalist> className={styles['quick-pick-btn']}
style={{ fontSize: '1.1rem', padding: '12px 20px', borderRadius: 8, background: '#333', color: '#fff', border: 'none', cursor: 'pointer' }}
onClick={() => handleQuickPick(name)}
aria-label={`Schnellauswahl: ${name}`}
>
{name}
</button>
))}
{playerNameHistory.length > 10 && (
<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' }}
onClick={() => setIsModalOpen(true)}
aria-label="Weitere Spieler anzeigen"
>
...
</button>
)}
</div> </div>
</div> )}
<div className={styles['player-input']}>
<label htmlFor="player3-input">Spieler 3 (optional)</label>
<div className={styles['name-input-container']}>
<input id="player3-input" className={styles['name-input']} placeholder="Name Spieler 3" value={player3} onInput={e => setPlayer3(e.target.value)} list="player3-history" aria-label="Name Spieler 3" />
<datalist id="player3-history">
{playerNameHistory.map((name, idx) => <option value={name} key={name + idx} />)}
</datalist>
</div>
</div>
</div> </div>
<div className={styles['game-settings']}> {error && <div className={styles['validation-error']} style={{ marginBottom: 16 }}>{error}</div>}
<div className={styles['setting-group']}> {isModalOpen && (
<label htmlFor="game-type-select">Spieltyp</label> <PlayerSelectModal
<select id="game-type-select" value={gameType} onChange={e => setGameType(e.target.value)} aria-label="Spieltyp"> players={playerNameHistory}
<option value="8-Ball">8-Ball</option> onSelect={handleModalSelect}
<option value="9-Ball">9-Ball</option> onClose={() => setIsModalOpen(false)}
<option value="10-Ball">10-Ball</option> />
</select> )}
</div> <div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
<div className={styles['setting-group']}> <button
<label htmlFor="race-to-input">Race to X (optional)</label> type="button"
<input id="race-to-input" type="number" value={raceTo} onInput={e => setRaceTo(e.target.value)} min="1" aria-label="Race to X" /> className={styles['arrow-btn']}
</div> aria-label="Zurück"
</div> onClick={onCancel}
{error && <div className={styles['validation-error']}>{error}</div>} style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
<div className="nav-buttons"> >
<button type="button" className="btn" onClick={onCancel} aria-label="Abbrechen">Abbrechen</button> {/* Unicode left arrow */}
<button type="submit" className="btn" aria-label="Spiel starten">Spiel starten</button> &#8592;
</button>
<button
type="submit"
className={styles['arrow-btn']}
aria-label="Weiter"
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
>
{/* Unicode right arrow */}
&#8594;
</button>
</div> </div>
</form> </form>
); );
}; };
export default NewGame; /**
* 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 [player2, setPlayer2] = useState(initialValue);
const [error, setError] = useState(null);
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
const inputRef = useRef(null);
useEffect(() => {
if (!player2) {
setFilteredNames(playerNameHistory);
} else {
setFilteredNames(
playerNameHistory.filter(name =>
name.toLowerCase().includes(player2.toLowerCase())
)
);
}
}, [player2, playerNameHistory]);
const handleSubmit = (e) => {
e.preventDefault();
if (!player2.trim()) {
setError('Bitte Namen für Spieler 2 eingeben');
return;
}
setError(null);
onNext(player2.trim());
};
const handleQuickPick = (name) => {
setError(null);
onNext(name);
};
const handleClear = () => {
setPlayer2('');
setError(null);
if (inputRef.current) inputRef.current.focus();
};
return (
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spieler 2 Eingabe" autoComplete="off">
<div className={styles['screen-title']}>Neues Spiel Schritt 2/5</div>
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
<span className={styles['progress-dot']} />
<span className={styles['progress-dot'] + ' ' + styles['active']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
</div>
<div className={styles['player-input']} style={{ marginBottom: 32, position: 'relative' }}>
<label htmlFor="player2-input" style={{ fontSize: '1.3rem', fontWeight: 600 }}>Spieler 2</label>
<div style={{ position: 'relative', width: '100%' }}>
<input
id="player2-input"
className={styles['name-input']}
placeholder="Name Spieler 2"
value={player2}
onInput={e => setPlayer2(e.target.value)}
autoFocus
autoComplete="off"
aria-label="Name Spieler 2"
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
ref={inputRef}
/>
{player2 && (
<button
type="button"
className={styles['clear-input-btn']}
aria-label="Feld leeren"
onClick={handleClear}
style={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 24,
color: '#aaa',
padding: 0,
zIndex: 2
}}
tabIndex={0}
>
×
</button>
)}
</div>
{filteredNames.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 8 }}>
{filteredNames.slice(0, 10).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' }}
onClick={() => handleQuickPick(name)}
aria-label={`Schnellauswahl: ${name}`}
>
{name}
</button>
))}
</div>
)}
</div>
{error && <div className={styles['validation-error']} style={{ marginBottom: 16 }}>{error}</div>}
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
<button
type="button"
className={styles['arrow-btn']}
aria-label="Zurück"
onClick={onCancel}
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
>
&#8592;
</button>
<button
type="submit"
className={styles['arrow-btn']}
aria-label="Weiter"
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
>
&#8594;
</button>
</div>
</form>
);
};
/**
* 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 [player3, setPlayer3] = useState(initialValue);
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
const inputRef = useRef(null);
useEffect(() => {
if (!player3) {
setFilteredNames(playerNameHistory);
} else {
setFilteredNames(
playerNameHistory.filter(name =>
name.toLowerCase().includes(player3.toLowerCase())
)
);
}
}, [player3, playerNameHistory]);
const handleSubmit = (e) => {
e.preventDefault();
// Player 3 is optional, so always allow submission
onNext(player3.trim());
};
const handleQuickPick = (name) => {
onNext(name);
};
const handleClear = () => {
setPlayer3('');
if (inputRef.current) inputRef.current.focus();
};
const handleSkip = (e) => {
e.preventDefault();
onNext('');
};
return (
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spieler 3 Eingabe" autoComplete="off">
<div className={styles['screen-title']}>Neues Spiel Schritt 3/5</div>
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot'] + ' ' + styles['active']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
</div>
<div className={styles['player-input']} style={{ marginBottom: 32, position: 'relative' }}>
<label htmlFor="player3-input" style={{ fontSize: '1.3rem', fontWeight: 600 }}>Spieler 3 (optional)</label>
<div style={{ position: 'relative', width: '100%' }}>
<input
id="player3-input"
className={styles['name-input']}
placeholder="Name Spieler 3 (optional)"
value={player3}
onInput={e => setPlayer3(e.target.value)}
autoFocus
autoComplete="off"
aria-label="Name Spieler 3"
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
ref={inputRef}
/>
{player3 && (
<button
type="button"
className={styles['clear-input-btn']}
aria-label="Feld leeren"
onClick={handleClear}
style={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 24,
color: '#aaa',
padding: 0,
zIndex: 2
}}
tabIndex={0}
>
×
</button>
)}
</div>
{filteredNames.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 8 }}>
{filteredNames.slice(0, 10).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' }}
onClick={() => handleQuickPick(name)}
aria-label={`Schnellauswahl: ${name}`}
>
{name}
</button>
))}
</div>
)}
</div>
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
<button
type="button"
className={styles['arrow-btn']}
aria-label="Zurück"
onClick={onCancel}
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
>
&#8592;
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<button
type="button"
onClick={handleSkip}
className={styles['quick-pick-btn']}
style={{ fontSize: '1.1rem', padding: '12px 20px', borderRadius: 8, background: '#333', color: '#fff', border: 'none', cursor: 'pointer' }}
>
Überspringen
</button>
<button
type="submit"
className={styles['arrow-btn']}
aria-label="Weiter"
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
>
&#8594;
</button>
</div>
</div>
</form>
);
};
/**
* 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 [gameType, setGameType] = useState(initialValue);
const gameTypes = ['8-Ball', '9-Ball', '10-Ball', '14/1 endlos'];
const handleSelect = (selectedType) => {
setGameType(selectedType);
};
const handleSubmit = (e) => {
e.preventDefault();
if (gameType) {
onNext(gameType);
}
};
return (
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spielart auswählen">
<div className={styles['screen-title']}>Neues Spiel Schritt 4/5</div>
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot'] + ' ' + styles['active']} />
<span className={styles['progress-dot']} />
</div>
<div className={styles['game-type-selection']}>
{gameTypes.map(type => (
<button
key={type}
type="button"
className={`${styles['game-type-btn']} ${gameType === type ? styles.selected : ''}`}
onClick={() => handleSelect(type)}
>
{type}
</button>
))}
</div>
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
<button
type="button"
className={styles['arrow-btn']}
aria-label="Zurück"
onClick={onCancel}
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
>
{/* Unicode left arrow */}
&#8592;
</button>
<button
type="submit"
className={styles['arrow-btn']}
aria-label="Weiter"
disabled={!gameType}
style={{
fontSize: 48,
width: 80,
height: 80,
borderRadius: '50%',
background: '#222',
color: '#fff',
border: 'none',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
cursor: 'pointer',
opacity: !gameType ? 0.5 : 1,
}}
>
{/* Unicode right arrow */}
&#8594;
</button>
</div>
</form>
);
};
/**
* 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]
* @returns {import('preact').VNode}
*/
const RaceToStep = ({ onNext, onCancel, initialValue = '' }) => {
const [raceTo, setRaceTo] = useState(initialValue);
const quickPicks = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const handleQuickPick = (value) => {
setRaceTo(value);
};
const handleInputChange = (e) => {
setRaceTo(e.target.value);
};
const handleSubmit = (e) => {
e.preventDefault();
onNext(parseInt(raceTo, 10) || 0);
};
return (
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Race To auswählen">
<div className={styles['screen-title']}>Neues Spiel Schritt 5/5</div>
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot'] + ' ' + styles['active']} />
</div>
<div className={styles['endlos-container']}>
<button
type="button"
className={`${styles['race-to-btn']} ${styles['endlos-btn']} ${raceTo === 0 ? styles.selected : ''}`}
onClick={() => handleQuickPick(0)}
>
Endlos
</button>
</div>
<div className={styles['race-to-selection']}>
{quickPicks.map(value => (
<button
key={value}
type="button"
className={`${styles['race-to-btn']} ${parseInt(raceTo, 10) === value ? styles.selected : ''}`}
onClick={() => handleQuickPick(value)}
>
{value}
</button>
))}
</div>
<div className={styles['custom-race-to']}>
<input
type="number"
pattern="[0-9]*"
value={raceTo}
onInput={handleInputChange}
className={styles['name-input']}
placeholder="manuelle Eingabe"
/>
</div>
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
<button
type="button"
className={styles['arrow-btn']}
aria-label="Zurück"
onClick={onCancel}
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
>
{/* Unicode left arrow */}
&#8592;
</button>
<button
type="submit"
className={styles['arrow-btn']}
aria-label="Fertigstellen"
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
>
{/* Unicode checkmark */}
&#10003;
</button>
</div>
</form>
);
};
export { Player1Step, Player2Step, Player3Step, GameTypeStep, RaceToStep };

View File

@@ -110,3 +110,168 @@
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
} }
.progress-indicator {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.progress-dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: #444;
opacity: 0.4;
transition: background 0.2s, opacity 0.2s;
}
.progress-dot.active {
background: #fff;
opacity: 1;
}
.quick-pick-btn {
min-width: 80px;
min-height: 44px;
font-size: 1.1rem;
border-radius: 8px;
background: #333;
color: #fff;
border: none;
cursor: pointer;
margin-bottom: 8px;
transition: background 0.2s;
}
.quick-pick-btn:active, .quick-pick-btn:focus {
background: #555;
outline: none;
}
.arrow-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 48px;
width: 100%;
}
.arrow-btn {
font-size: 48px;
width: 80px;
height: 80px;
border-radius: 50%;
background: #222;
color: #fff;
border: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, color 0.2s;
}
.arrow-btn:active, .arrow-btn:focus {
background: #444;
color: #fff;
outline: none;
}
.clear-input-btn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
font-size: 24px;
color: #aaa;
padding: 0;
z-index: 2;
}
.clear-input-btn:active, .clear-input-btn:focus {
color: #fff;
outline: none;
}
.game-type-selection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
width: 100%;
margin: 16px 0;
}
.game-type-btn {
background: #2a2a2a;
border: 2px solid #333;
color: #fff;
font-size: 1.4rem;
font-weight: 600;
padding: 24px;
border-radius: 8px;
cursor: pointer;
text-align: center;
transition: background 0.2s, border-color 0.2s;
}
.game-type-btn:hover {
background: #333;
border-color: #555;
}
.game-type-btn.selected {
background: #4a4a4a;
border-color: #777;
}
.race-to-selection {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
gap: 12px;
width: 100%;
margin: 16px 0;
}
.race-to-btn {
background: #2a2a2a;
border: 2px solid #333;
color: #fff;
font-size: 1.3rem;
font-weight: 600;
padding: 16px 8px;
border-radius: 8px;
cursor: pointer;
text-align: center;
transition: background 0.2s, border-color 0.2s;
min-height: 70px;
}
.race-to-btn:hover {
background: #333;
border-color: #555;
}
.race-to-btn.selected {
background: #4a4a4a;
border-color: #777;
}
.custom-race-to {
display: flex;
gap: 12px;
margin-top: 24px;
align-items: center;
}
.custom-race-to input {
flex-grow: 1;
}
.custom-race-to .arrow-btn {
width: 60px;
height: 60px;
font-size: 32px;
flex-shrink: 0;
}
.endlos-container {
width: 100%;
margin-bottom: 12px;
}
.endlos-btn {
width: 100%;
}

View File

@@ -0,0 +1,70 @@
.modalOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modalContent {
background: #2c2c2c;
padding: 24px;
border-radius: 12px;
width: 90%;
max-width: 400px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
}
.modalHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.modalHeader h3 {
margin: 0;
font-size: 1.5rem;
color: #fff;
}
.closeButton {
background: none;
border: none;
font-size: 2rem;
color: #aaa;
cursor: pointer;
padding: 0;
line-height: 1;
}
.playerList {
max-height: 60vh;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.playerItem {
background: #444;
color: #fff;
border: none;
padding: 16px;
border-radius: 8px;
text-align: left;
font-size: 1.2rem;
cursor: pointer;
transition: background-color 0.2s;
}
.playerItem:hover {
background: #555;
}

View File

@@ -1 +0,0 @@
/* DEPRECATED: All modal styles are now in Modal.module.css */