refactor: consolidate game components and add toast notifications

- Remove EndlosGame support and GameDetail141.jsx component
- Add Toast notification system with CSS styling
- Refactor GameCompletionModal with enhanced styling
- Improve GameDetail component structure and styling
- Add BaseLayout.astro for consistent page structure
- Update gameService with cleaner logic
- Enhance global styles and remove unused constants
- Streamline navigation components
This commit is contained in:
Frank Schwenk
2025-10-28 16:30:39 +01:00
parent d1e1616faa
commit 8bbe3b9b70
15 changed files with 532 additions and 433 deletions

View File

@@ -6,7 +6,7 @@ import { useNavigation, useNewGameWizard } from '../hooks/useNavigation';
import { useModal, useValidationModal, useCompletionModal } from '../hooks/useModal'; import { useModal, useValidationModal, useCompletionModal } from '../hooks/useModal';
import { GameService } from '../services/gameService'; import { GameService } from '../services/gameService';
import type { StandardGame, EndlosGame } from '../types/game'; import type { StandardGame } from '../types/game';
import { Layout } from './ui/Layout'; import { Layout } from './ui/Layout';
import GameListScreen from './screens/GameListScreen'; import GameListScreen from './screens/GameListScreen';
@@ -36,42 +36,31 @@ export default function App() {
navigation.showGameDetail(gameId); navigation.showGameDetail(gameId);
}; };
const handleUpdateScore = (player: number, change: number) => { const handleUpdateScore = (player: number, change: number) => {
if (!navigation.currentGameId) return; if (!navigation.currentGameId) return;
const game = gameState.getGameById(navigation.currentGameId); const game = gameState.getGameById(navigation.currentGameId);
if (!game || game.status === 'completed' || 'players' in game) return; if (!game || game.status === 'completed') return;
const updatedGame = GameService.updateGameScore(game as StandardGame, player, change); const updatedGame = GameService.updateGameScore(game as StandardGame, player, change);
gameState.updateGame(navigation.currentGameId, updatedGame);
// Check for completion // Add undo state for standard games
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 = { const gameWithHistory = {
...updatedGame, ...updatedGame,
undoStack: [...(originalGame.undoStack || []), originalGame], undoStack: [...(game.undoStack || []), game],
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
gameState.updateGame(navigation.currentGameId, gameWithHistory); gameState.updateGame(navigation.currentGameId, gameWithHistory);
// Check for completion // Check for completion
if (GameService.isGameCompleted(gameWithHistory)) { if (GameService.isGameCompleted(gameWithHistory)) {
completionModal.openCompletionModal(gameWithHistory); completionModal.openCompletionModal(gameWithHistory);
} }
}; };
const handleUndo = () => { const handleUndo = () => {
if (!navigation.currentGameId) return; if (!navigation.currentGameId) return;
@@ -181,10 +170,10 @@ export default function App() {
onFilterChange={gameState.setFilter} onFilterChange={gameState.setFilter}
onShowGameDetail={navigation.showGameDetail} onShowGameDetail={navigation.showGameDetail}
onDeleteGame={handleDeleteGame} onDeleteGame={handleDeleteGame}
onShowNewGame={() => { onShowNewGame={() => {
newGameWizard.startWizard(); newGameWizard.startWizard();
navigation.showNewGame(); navigation.showNewGame();
}} }}
/> />
)} )}
@@ -209,9 +198,7 @@ export default function App() {
game={gameState.getGameById(navigation.currentGameId)} game={gameState.getGameById(navigation.currentGameId)}
onUpdateScore={handleUpdateScore} onUpdateScore={handleUpdateScore}
onFinishGame={handleFinishGame} onFinishGame={handleFinishGame}
onUpdateGame={handleGameAction}
onUndo={handleUndo} onUndo={handleUndo}
onForfeit={handleForfeit}
onBack={navigation.showGameList} onBack={navigation.showGameList}
/> />
)} )}

View File

@@ -2,56 +2,6 @@ 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.
@@ -66,16 +16,9 @@ const calculateStats = (game) => {
const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }) => { const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }) => {
if (!open || !game) return null; if (!open || !game) return null;
let playerNames, scores, maxScore, winners, winnerText, gameStats; const playerNames = [game.player1, game.player2, game.player3].filter(Boolean);
const scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]);
if (game.gameType === '14/1 endlos') { let maxScore, winners, winnerText;
playerNames = game.players.map(p => p.name);
scores = game.players.map(p => p.score);
gameStats = calculateStats(game);
} else {
playerNames = [game.player1, game.player2, game.player3].filter(Boolean);
scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]);
}
if (game.forfeitedBy) { if (game.forfeitedBy) {
winnerText = `${game.winner} hat gewonnen, da ${game.forfeitedBy} aufgegeben hat.`; winnerText = `${game.winner} hat gewonnen, da ${game.forfeitedBy} aufgegeben hat.`;
@@ -105,19 +48,6 @@ const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }) => {
</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>

View File

@@ -26,18 +26,68 @@
.winner-announcement { .winner-announcement {
text-align: center; text-align: center;
margin: 20px 0 0 0; margin: 20px 0 0 0;
padding: 18px 8px; padding: 24px 16px;
background: #43a047; background: linear-gradient(135deg, #ff9800 0%, #ffa726 100%);
border-radius: 8px; border-radius: 16px;
font-size: 1.2rem; font-size: 1.2rem;
color: #fff; color: #222;
font-weight: 700; font-weight: 700;
box-shadow: 0 8px 32px rgba(255, 152, 0, 0.3);
animation: celebrationPulse 2s ease-in-out infinite;
position: relative;
overflow: hidden;
} }
.winner-announcement::before {
content: '🎉';
position: absolute;
top: -10px;
left: 20px;
font-size: 24px;
animation: bounce 1s ease-in-out infinite;
}
.winner-announcement::after {
content: '🏆';
position: absolute;
top: -10px;
right: 20px;
font-size: 24px;
animation: bounce 1s ease-in-out infinite 0.5s;
}
.winner-announcement h3 { .winner-announcement h3 {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.8rem;
color: #2c3e50; color: #222;
text-align: center; text-align: center;
text-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 900;
}
@keyframes celebrationPulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 8px 32px rgba(255, 152, 0, 0.3);
}
50% {
transform: scale(1.02);
box-shadow: 0 12px 40px rgba(255, 152, 0, 0.4);
}
}
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
60% {
transform: translateY(-5px);
}
} }
.btn { .btn {
flex: 1; flex: 1;

View File

@@ -1,6 +1,7 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState } from 'preact/hooks';
import styles from './GameDetail.module.css'; import styles from './GameDetail.module.css';
import GameDetail141 from './GameDetail141.jsx'; import Toast from './Toast.jsx';
/** /**
* Game detail view for a single game. * Game detail view for a single game.
@@ -15,11 +16,21 @@ import GameDetail141 from './GameDetail141.jsx';
* @returns {import('preact').VNode|null} * @returns {import('preact').VNode|null}
*/ */
const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, onForfeit, onBack }) => { const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, onForfeit, onBack }) => {
const [toast, setToast] = useState({ show: false, message: '', type: 'info' });
if (!game) return null; if (!game) return null;
if (game.gameType === '14/1 endlos') { const showToast = (message, type = 'info') => {
return <GameDetail141 game={game} onUpdate={onUpdateGame} onUndo={onUndo} onForfeit={onForfeit} onBack={onBack} />; setToast({ show: true, message, type });
} };
const handleScoreUpdate = (playerIndex, change) => {
onUpdateScore(playerIndex, change);
const playerName = [game.player1, game.player2, game.player3][playerIndex - 1];
const action = change > 0 ? 'Punkt hinzugefügt' : 'Punkt abgezogen';
showToast(`${action} für ${playerName}`, 'success');
};
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);
@@ -31,39 +42,70 @@ const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, o
{game.gameType}{game.raceTo ? ` | Race to ${game.raceTo}` : ''} {game.gameType}{game.raceTo ? ` | Race to ${game.raceTo}` : ''}
</div> </div>
<div className={styles['scores-container']}> <div className={styles['scores-container']}>
{playerNames.map((name, idx) => ( {playerNames.map((name, idx) => {
<div const currentScore = scores[idx];
className={styles['player-score'] + (name === 'Fränky' ? ' ' + styles['franky'] : '')} const progressPercentage = game.raceTo ? Math.min((currentScore / game.raceTo) * 100, 100) : 0;
key={name + idx}
> return (
<span className={styles['player-name']}>{name}</span> <div
<span className={styles['player-score'] + (name === 'Fränky' ? ' ' + styles['franky'] : '')}
className={styles['score']} key={name + idx}
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 className={styles['player-name']}>{name}</span>
</span> <div className={styles['progress-bar']}>
<button <div
className={styles['score-button']} className={styles['progress-fill']}
disabled={isCompleted} style={{ width: `${progressPercentage}%` }}
onClick={() => onUpdateScore(idx+1, -1)} />
aria-label={`Punkt abziehen für ${name}`} </div>
>-</button> <span
<button className={styles['score']}
className={styles['score-button']} id={`score${idx + 1}`}
disabled={isCompleted} onClick={() => !isCompleted && onUpdateScore(idx + 1, 1)}
onClick={() => onUpdateScore(idx+1, 1)} aria-label={`Aktueller Punktestand für ${name}: ${scores[idx]}. Klicken zum Erhöhen.`}
aria-label={`Punkt hinzufügen für ${name}`} >
>+</button> {scores[idx]}
</div> </span>
))} <div className={styles['score-buttons']}>
<button
className={styles['score-button']}
disabled={isCompleted}
onClick={() => handleScoreUpdate(idx+1, -1)}
aria-label={`Punkt abziehen für ${name}`}
>-</button>
<button
className={styles['score-button']}
disabled={isCompleted}
onClick={() => handleScoreUpdate(idx+1, 1)}
aria-label={`Punkt hinzufügen für ${name}`}
>+</button>
</div>
</div>
);
})}
</div> </div>
<div className={styles['game-detail-controls']}> <div className={styles['game-detail-controls']}>
<button className="btn" onClick={onBack} aria-label="Zurück zur Liste">Zurück zur Liste</button> <button className="btn" onClick={onBack} aria-label="Zurück zur Liste">Zurück zur Liste</button>
{onUndo && (
<button
className="btn btn--secondary"
onClick={() => {
onUndo();
showToast('Letzte Aktion rückgängig gemacht', 'info');
}}
aria-label="Rückgängig"
>
Rückgängig
</button>
)}
<button className="btn" disabled={isCompleted} onClick={onFinishGame} aria-label={isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}>{isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}</button> <button className="btn" disabled={isCompleted} onClick={onFinishGame} aria-label={isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}>{isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}</button>
</div> </div>
<Toast
show={toast.show}
message={toast.message}
type={toast.type}
onClose={() => setToast({ show: false, message: '', type: 'info' })}
/>
</div> </div>
); );
}; };

View File

@@ -44,12 +44,20 @@
.player-score { .player-score {
flex: 1; flex: 1;
text-align: center; text-align: center;
padding: 20px; padding: 30px 20px;
border-radius: 16px; border-radius: 20px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
margin: 0 8px; margin: 0 8px;
position: relative;
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
transition: all 0.3s ease;
}
.player-score:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0,0,0,0.3);
} }
.player-score:first-child { .player-score:first-child {
background-color: #43a047; background-color: #43a047;
@@ -61,21 +69,69 @@
background-color: #333; background-color: #333;
} }
.player-name { .player-name {
font-size: 24px; font-size: 28px;
margin-bottom: 10px; font-weight: 700;
margin-bottom: 15px;
color: #fff; color: #fff;
text-shadow: 0 2px 8px rgba(0,0,0,0.4); text-shadow: 0 2px 8px rgba(0,0,0,0.4);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
text-transform: uppercase;
letter-spacing: 1px;
}
.progress-bar {
width: 100%;
height: 8px;
background: rgba(255,255,255,0.2);
border-radius: 4px;
margin: 10px 0;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #fff 0%, #f0f0f0 100%);
border-radius: 4px;
transition: width 0.5s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.game-status {
position: absolute;
top: 10px;
right: 10px;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.game-status.active {
background: #4caf50;
color: #fff;
}
.game-status.completed {
background: #ff9800;
color: #222;
} }
.score { .score {
font-size: 16vh; font-size: 20vh;
font-weight: bold; font-weight: 900;
margin: 10px 0 20px 0; margin: 20px 0 30px 0;
line-height: 1; line-height: 1;
color: #fff; color: #fff;
text-shadow: 0 2px 8px rgba(0,0,0,0.4); text-shadow: 0 4px 16px rgba(0,0,0,0.6);
text-align: center;
display: block;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
} }
.score-buttons { .score-buttons {
display: flex; display: flex;
@@ -84,16 +140,42 @@
margin-top: auto; margin-top: auto;
} }
.score-button { .score-button {
background-color: #333; background: linear-gradient(135deg, #ff9800 0%, #ffa726 100%);
color: white; color: #222;
border: none; border: none;
border-radius: 5px; border-radius: 50%;
padding: 10px 20px; padding: 0;
font-size: 18px; font-size: 2.5rem;
font-weight: 900;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; transition: all 0.3s ease;
min-width: 80px; width: 80px;
margin-bottom: 8px; height: 80px;
margin: 0 8px;
box-shadow: 0 4px 16px rgba(255, 152, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
}
.score-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(255, 152, 0, 0.4);
background: linear-gradient(135deg, #ffa726 0%, #ffb74d 100%);
}
.score-button:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(255, 152, 0, 0.3);
}
.score-button:disabled {
background: #666;
color: #999;
cursor: not-allowed;
transform: none;
box-shadow: none;
} }
.game-controls { .game-controls {
display: flex; display: flex;

View File

@@ -1,241 +0,0 @@
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 GameLogTable = ({ log, players }) => {
if (!log || log.length === 0) return null;
// Only turn and foul entries
const turnEntries = log.filter(e => e.type === 'turn');
const foulEntries = log.filter(e => e.type === 'foul');
// Group into rounds (Aufnahmen): each round = one turn per player, in order
const rounds = [];
for (let i = 0; i < turnEntries.length; i += players.length) {
rounds.push(turnEntries.slice(i, i + players.length));
}
// Helper: for each player/round, sum fouls between previous and current turn
function getFoulSum(playerName, turnIdx, turnEntry) {
// Find previous turn index for this player
let prevTurnIdx = -1;
for (let i = turnIdx - 1; i >= 0; --i) {
if (turnEntries[i].player === playerName) {
prevTurnIdx = i;
break;
}
}
// Find fouls for this player between prevTurnIdx and turnIdx
let fouls = foulEntries.filter(f => {
// Find index of this foul in log
const foulLogIdx = log.indexOf(f);
// Find log index of previous turn and current turn
const prevTurnLogIdx = prevTurnIdx >= 0 ? log.indexOf(turnEntries[prevTurnIdx]) : -1;
const currTurnLogIdx = log.indexOf(turnEntry);
return f.player === playerName && foulLogIdx > prevTurnLogIdx && foulLogIdx < currTurnLogIdx;
});
// Sum totalDeduction for all fouls
return fouls.reduce((sum, f) => sum + (f.totalDeduction || 0), 0);
}
return (
<div className={styles['game-log-table-container']}>
<h4 className={styles['log-title']}>Aufnahmen</h4>
<table className={styles['game-log-table']}>
<thead>
<tr>
<th>Aufnahme</th>
{players.map((p, idx) => (
<th key={p.name} colSpan={3} className={styles['log-player-col']}>{p.name}</th>
))}
</tr>
<tr>
<th></th>
{players.map((p, idx) => [
<th key={p.name + '-balls'}>Bälle</th>,
<th key={p.name + '-foul'}>Foul</th>,
<th key={p.name + '-score'}>Score</th>
])}
</tr>
</thead>
<tbody>
{rounds.map((round, roundIdx) => (
<tr key={roundIdx}>
<td>{roundIdx + 1}</td>
{players.map((p, idx) => {
const entry = round.find(e => e.player === p.name);
if (entry) {
const turnIdx = turnEntries.indexOf(entry);
const foulSum = getFoulSum(p.name, turnIdx, entry);
return [
<td key={p.name + '-balls'}>{entry.ballsPotted}</td>,
<td key={p.name + '-foul'}>{foulSum > 0 ? foulSum : ''}</td>,
<td key={p.name + '-score'}>{entry.newScore}</td>
];
} else {
return [<td key={p.name + '-balls'}></td>,<td key={p.name + '-foul'}></td>,<td key={p.name + '-score'}></td>];
}
})}
</tr>
))}
</tbody>
</table>
</div>
);
};
const GameDetail141 = ({ game, onUpdate, onUndo, onForfeit, onBack }) => {
const [pendingBallsLeft, setPendingBallsLeft] = useState(null); // null means not set
const [pendingFouls, setPendingFouls] = useState(0);
const [pendingReRack, setPendingReRack] = useState(0);
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];
// Handlers now only update local state
const handleBallsLeft = (num) => {
setPendingBallsLeft(num);
};
// Foul: each press adds 1 foul point
const handleFoul = () => {
setPendingFouls(pendingFouls + 1);
};
// Re-rack: accumulate total balls added
const handleReRack = (ballsToAdd) => {
setPendingReRack(pendingReRack + ballsToAdd);
};
// Turn change button handler
const handleTurnChange = () => {
const ballsOnTableBefore = game.ballsOnTable;
const ballsLeft = pendingBallsLeft !== null ? pendingBallsLeft : ballsOnTableBefore;
const ballsPotted = ballsOnTableBefore - ballsLeft;
let newScore = currentPlayer.score + ballsPotted - pendingFouls;
// Re-rack logic: if any re-rack, add correct points and set balls on table to 15
if (pendingReRack > 0) {
const scoreIncrement = ballsLeft + pendingReRack - 15;
newScore += scoreIncrement;
}
const updatedPlayers = game.players.map((p, idx) =>
idx === game.currentPlayer ? { ...p, score: newScore, consecutiveFouls: pendingFouls > 0 ? 0 : (p.consecutiveFouls || 0) } : p
);
let newBallsOnTable = ballsLeft;
if (pendingReRack > 0) newBallsOnTable = 15;
const newLog = [...(game.log || []), {
type: 'turn',
player: currentPlayer.name,
ballsPotted,
foulPoints: pendingFouls,
newScore,
ballsOnTable: newBallsOnTable,
reRack: pendingReRack > 0 ? pendingReRack : undefined
}];
const nextPlayer = (game.currentPlayer + 1) % game.players.length;
onUpdate({
...game,
players: updatedPlayers,
ballsOnTable: newBallsOnTable,
currentPlayer: nextPlayer,
log: newLog,
});
setPendingBallsLeft(null);
setPendingFouls(0);
setPendingReRack(0);
};
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: 15 }, (_, i) => i + 1).map(num => (
<button
key={num}
onClick={() => handleBallsLeft(num)}
disabled={num > game.ballsOnTable}
className={styles['potted-ball-btn'] + (pendingBallsLeft === num ? ' ' + styles['selected'] : '')}
>
{num}
</button>
))}
</div>
</div>
<div className={styles['rerack-controls']}>
<button onClick={() => handleReRack(14)} className={styles['rerack-btn'] + (pendingReRack === 14 ? ' ' + styles['selected'] : '')}>+14 Re-Rack</button>
<button onClick={() => handleReRack(15)} className={styles['rerack-btn'] + (pendingReRack === 15 ? ' ' + styles['selected'] : '')}>+15 Re-Rack</button>
<button onClick={handleFoul} className={styles['rerack-btn'] + (pendingFouls > 0 ? ' ' + styles['selected'] : '')}>Foul -1</button>
{pendingFouls > 0 && <span className={styles['pending-foul-info']}>Foulpunkte: {pendingFouls}</span>}
</div>
<div className={styles['turn-change-controls']}>
<button className={styles['turn-change-btn']} onClick={handleTurnChange}>
</button>
</div>
<GameLogTable log={game.log} players={game.players} />
<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

@@ -479,7 +479,7 @@ const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
*/ */
const GameTypeStep = ({ onNext, onCancel, initialValue = '' }) => { const GameTypeStep = ({ onNext, onCancel, initialValue = '' }) => {
const [gameType, setGameType] = useState(initialValue); const [gameType, setGameType] = useState(initialValue);
const gameTypes = ['8-Ball', '9-Ball', '10-Ball', '14/1 endlos']; const gameTypes = ['8-Ball', '9-Ball', '10-Ball'];
const handleSelect = (selectedType) => { const handleSelect = (selectedType) => {
setGameType(selectedType); setGameType(selectedType);
@@ -561,14 +561,8 @@ const GameTypeStep = ({ onNext, onCancel, initialValue = '' }) => {
* @returns {import('preact').VNode} * @returns {import('preact').VNode}
*/ */
const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }) => { const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }) => {
let quickPicks, defaultValue; const quickPicks = [1, 2, 3, 4, 5, 6, 7, 8, 9];
if (gameType === '14/1 endlos') { const defaultValue = 5;
quickPicks = [60, 70, 80, 90, 100];
defaultValue = 80;
} else {
quickPicks = [1, 2, 3, 4, 5, 6, 7, 8, 9];
defaultValue = 5;
}
const [raceTo, setRaceTo] = useState(initialValue !== '' ? initialValue : defaultValue); const [raceTo, setRaceTo] = useState(initialValue !== '' ? initialValue : defaultValue);
useEffect(() => { useEffect(() => {

56
src/components/Toast.jsx Normal file
View File

@@ -0,0 +1,56 @@
import { h } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import styles from './Toast.module.css';
/**
* Toast notification component for user feedback
* @param {object} props
* @param {boolean} props.show
* @param {string} props.message
* @param {string} props.type - 'success', 'error', 'info'
* @param {Function} props.onClose
* @param {number} [props.duration=3000]
* @returns {import('preact').VNode|null}
*/
const Toast = ({ show, message, type = 'info', onClose, duration = 3000 }) => {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (show) {
setIsVisible(true);
const timer = setTimeout(() => {
setIsVisible(false);
setTimeout(onClose, 300); // Wait for animation to complete
}, duration);
return () => clearTimeout(timer);
}
}, [show, duration, onClose]);
if (!show && !isVisible) return null;
return (
<div className={`${styles.toast} ${styles[type]} ${isVisible ? styles.show : styles.hide}`}>
<div className={styles.toastContent}>
<div className={styles.toastIcon}>
{type === 'success' && '✓'}
{type === 'error' && '✕'}
{type === 'info' && ''}
</div>
<span className={styles.toastMessage}>{message}</span>
<button
className={styles.toastClose}
onClick={() => {
setIsVisible(false);
setTimeout(onClose, 300);
}}
aria-label="Schließen"
>
×
</button>
</div>
</div>
);
};
export default Toast;

View File

@@ -0,0 +1,134 @@
.toast {
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
min-width: 300px;
max-width: 400px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
transform: translateX(100%);
opacity: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.toast.show {
transform: translateX(0);
opacity: 1;
}
.toast.hide {
transform: translateX(100%);
opacity: 0;
}
.toastContent {
display: flex;
align-items: center;
padding: 16px 20px;
gap: 12px;
}
.toastIcon {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 14px;
flex-shrink: 0;
}
.toastMessage {
flex: 1;
font-size: 14px;
font-weight: 500;
color: #333;
line-height: 1.4;
}
.toastClose {
background: none;
border: none;
font-size: 18px;
color: #666;
cursor: pointer;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s ease;
flex-shrink: 0;
}
.toastClose:hover {
background: #f0f0f0;
color: #333;
}
/* Toast types */
.toast.success {
border-left: 4px solid #4caf50;
}
.toast.success .toastIcon {
background: #4caf50;
color: #fff;
}
.toast.error {
border-left: 4px solid #f44336;
}
.toast.error .toastIcon {
background: #f44336;
color: #fff;
}
.toast.info {
border-left: 4px solid #2196f3;
}
.toast.info .toastIcon {
background: #2196f3;
color: #fff;
}
/* Animation keyframes */
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOutRight {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
.toast.show {
animation: slideInRight 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.toast.hide {
animation: slideOutRight 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

View File

@@ -42,7 +42,7 @@ export default function NewGameScreen({
const handleGameTypeNext = (type: string) => { const handleGameTypeNext = (type: string) => {
onDataChange({ onDataChange({
gameType: type as any, // Type assertion for now, could be improved with proper validation gameType: type as any, // Type assertion for now, could be improved with proper validation
raceTo: type === '14/1 endlos' ? '150' : '50' raceTo: '8'
}); });
onStepChange('raceTo'); onStepChange('raceTo');
}; };

View File

@@ -0,0 +1,70 @@
---
---
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="interactive-widget=resizes-content">
<title>BSC Score</title>
<style>
body {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.layout-header {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 10vh;
background: #222;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.layout-footer {
position: fixed;
bottom: 0;
left: 0;
width: 100vw;
height: 10vh;
background: #222;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.layout-main {
min-height: 80vh;
margin-top: 10vh;
margin-bottom: 10vh;
width: 100vw;
box-sizing: border-box;
}
</style>
</head>
<body>
<header class="layout-header">
<!-- BUTTONS PLACEHOLDER -->
<div style="width:100%; display:flex; justify-content:center; gap:1rem;">
<button disabled>Button 1</button>
<button disabled>Button 2</button>
<button disabled>Button 3</button>
</div>
</header>
<main class="layout-main">
<slot />
</main>
<footer class="layout-footer">
<!-- INFO PLACEHOLDER -->
<div style="width:100%; text-align:center;">
<span>Informational text goes here. &copy; {new Date().getFullYear()} BSC Score</span>
</div>
</footer>
</body>
</html>

View File

@@ -42,38 +42,20 @@ export class GameService {
undoStack: [], undoStack: [],
}; };
if (gameData.gameType === '14/1 endlos') { const standardGame: StandardGame = {
const players = [ ...baseGame,
{ name: gameData.player1, score: 0, consecutiveFouls: 0 }, player1: gameData.player1,
{ name: gameData.player2, score: 0, consecutiveFouls: 0 } player2: gameData.player2,
]; score1: 0,
score2: 0,
if (gameData.player3) { };
players.push({ name: gameData.player3, score: 0, consecutiveFouls: 0 });
}
return { if (gameData.player3) {
...baseGame, standardGame.player3 = gameData.player3;
players, standardGame.score3 = 0;
currentPlayer: null,
ballsOnTable: 15,
} as EndlosGame;
} else {
const standardGame: StandardGame = {
...baseGame,
player1: gameData.player1,
player2: gameData.player2,
score1: 0,
score2: 0,
};
if (gameData.player3) {
standardGame.player3 = gameData.player3;
standardGame.score3 = 0;
}
return standardGame;
} }
return standardGame;
} }
/** /**

View File

@@ -149,6 +149,20 @@ input:focus, select:focus {
color: white; color: white;
} }
.btn--secondary {
background: #333;
color: #fff;
border: 2px solid #555;
}
.btn--secondary:hover {
background: #444;
border-color: #666;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
}
.btn-primary:hover { .btn-primary:hover {
background: var(--color-primary-hover); background: var(--color-primary-hover);
} }

View File

@@ -1,6 +1,6 @@
export type GameStatus = 'active' | 'completed'; export type GameStatus = 'active' | 'completed';
export type GameType = '8-Ball' | '9-Ball' | '10-Ball' | '14/1 endlos'; export type GameType = '8-Ball' | '9-Ball' | '10-Ball';
export interface Player { export interface Player {
name: string; name: string;

View File

@@ -4,7 +4,6 @@ export const GAME_TYPES: Array<{ value: GameType; label: string; defaultRaceTo:
{ value: '8-Ball', label: '8-Ball', defaultRaceTo: 5 }, { value: '8-Ball', label: '8-Ball', defaultRaceTo: 5 },
{ value: '9-Ball', label: '9-Ball', defaultRaceTo: 9 }, { value: '9-Ball', label: '9-Ball', defaultRaceTo: 9 },
{ value: '10-Ball', label: '10-Ball', defaultRaceTo: 10 }, { value: '10-Ball', label: '10-Ball', defaultRaceTo: 10 },
{ value: '14/1 endlos', label: '14/1 Endlos', defaultRaceTo: 150 },
]; ];
export const RACE_TO_OPTIONS = [ export const RACE_TO_OPTIONS = [