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:
@@ -6,7 +6,7 @@ import { useNavigation, useNewGameWizard } from '../hooks/useNavigation';
|
||||
import { useModal, useValidationModal, useCompletionModal } from '../hooks/useModal';
|
||||
|
||||
import { GameService } from '../services/gameService';
|
||||
import type { StandardGame, EndlosGame } from '../types/game';
|
||||
import type { StandardGame } from '../types/game';
|
||||
|
||||
import { Layout } from './ui/Layout';
|
||||
import GameListScreen from './screens/GameListScreen';
|
||||
@@ -36,42 +36,31 @@ export default function App() {
|
||||
navigation.showGameDetail(gameId);
|
||||
};
|
||||
|
||||
|
||||
const handleUpdateScore = (player: number, change: number) => {
|
||||
if (!navigation.currentGameId) return;
|
||||
|
||||
const game = gameState.getGameById(navigation.currentGameId);
|
||||
if (!game || game.status === 'completed' || 'players' in game) return;
|
||||
if (!game || game.status === 'completed') return;
|
||||
|
||||
const updatedGame = GameService.updateGameScore(game as StandardGame, player, change);
|
||||
gameState.updateGame(navigation.currentGameId, updatedGame);
|
||||
|
||||
// Check for completion
|
||||
if (GameService.isGameCompleted(updatedGame)) {
|
||||
completionModal.openCompletionModal(updatedGame);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGameAction = (updatedGame: EndlosGame) => {
|
||||
if (!navigation.currentGameId) return;
|
||||
|
||||
const originalGame = gameState.getGameById(navigation.currentGameId);
|
||||
if (!originalGame) return;
|
||||
|
||||
// Add undo state
|
||||
// Add undo state for standard games
|
||||
const gameWithHistory = {
|
||||
...updatedGame,
|
||||
undoStack: [...(originalGame.undoStack || []), originalGame],
|
||||
undoStack: [...(game.undoStack || []), game],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
gameState.updateGame(navigation.currentGameId, gameWithHistory);
|
||||
|
||||
|
||||
// Check for completion
|
||||
if (GameService.isGameCompleted(gameWithHistory)) {
|
||||
completionModal.openCompletionModal(gameWithHistory);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleUndo = () => {
|
||||
if (!navigation.currentGameId) return;
|
||||
|
||||
@@ -181,10 +170,10 @@ export default function App() {
|
||||
onFilterChange={gameState.setFilter}
|
||||
onShowGameDetail={navigation.showGameDetail}
|
||||
onDeleteGame={handleDeleteGame}
|
||||
onShowNewGame={() => {
|
||||
newGameWizard.startWizard();
|
||||
navigation.showNewGame();
|
||||
}}
|
||||
onShowNewGame={() => {
|
||||
newGameWizard.startWizard();
|
||||
navigation.showNewGame();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -209,9 +198,7 @@ export default function App() {
|
||||
game={gameState.getGameById(navigation.currentGameId)}
|
||||
onUpdateScore={handleUpdateScore}
|
||||
onFinishGame={handleFinishGame}
|
||||
onUpdateGame={handleGameAction}
|
||||
onUndo={handleUndo}
|
||||
onForfeit={handleForfeit}
|
||||
onBack={navigation.showGameList}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,56 +2,6 @@ import { h } from 'preact';
|
||||
import modalStyles from './Modal.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.
|
||||
@@ -66,16 +16,9 @@ const calculateStats = (game) => {
|
||||
const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }) => {
|
||||
if (!open || !game) return null;
|
||||
|
||||
let playerNames, scores, maxScore, winners, winnerText, gameStats;
|
||||
|
||||
if (game.gameType === '14/1 endlos') {
|
||||
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]);
|
||||
}
|
||||
const playerNames = [game.player1, game.player2, game.player3].filter(Boolean);
|
||||
const scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]);
|
||||
let maxScore, winners, winnerText;
|
||||
|
||||
if (game.forfeitedBy) {
|
||||
winnerText = `${game.winner} hat gewonnen, da ${game.forfeitedBy} aufgegeben hat.`;
|
||||
@@ -105,19 +48,6 @@ const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }) => {
|
||||
</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 className={modalStyles['modal-footer']}>
|
||||
<button className={styles['btn'] + ' ' + styles['btn--warning']} onClick={onConfirm} aria-label="Bestätigen">Bestätigen</button>
|
||||
|
||||
@@ -26,18 +26,68 @@
|
||||
.winner-announcement {
|
||||
text-align: center;
|
||||
margin: 20px 0 0 0;
|
||||
padding: 18px 8px;
|
||||
background: #43a047;
|
||||
border-radius: 8px;
|
||||
padding: 24px 16px;
|
||||
background: linear-gradient(135deg, #ff9800 0%, #ffa726 100%);
|
||||
border-radius: 16px;
|
||||
font-size: 1.2rem;
|
||||
color: #fff;
|
||||
color: #222;
|
||||
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 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #2c3e50;
|
||||
font-size: 1.8rem;
|
||||
color: #222;
|
||||
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 {
|
||||
flex: 1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import styles from './GameDetail.module.css';
|
||||
import GameDetail141 from './GameDetail141.jsx';
|
||||
import Toast from './Toast.jsx';
|
||||
|
||||
/**
|
||||
* Game detail view for a single game.
|
||||
@@ -15,11 +16,21 @@ import GameDetail141 from './GameDetail141.jsx';
|
||||
* @returns {import('preact').VNode|null}
|
||||
*/
|
||||
const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, onForfeit, onBack }) => {
|
||||
const [toast, setToast] = useState({ show: false, message: '', type: 'info' });
|
||||
|
||||
if (!game) return null;
|
||||
|
||||
if (game.gameType === '14/1 endlos') {
|
||||
return <GameDetail141 game={game} onUpdate={onUpdateGame} onUndo={onUndo} onForfeit={onForfeit} onBack={onBack} />;
|
||||
}
|
||||
const showToast = (message, type = 'info') => {
|
||||
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 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}` : ''}
|
||||
</div>
|
||||
<div className={styles['scores-container']}>
|
||||
{playerNames.map((name, idx) => (
|
||||
<div
|
||||
className={styles['player-score'] + (name === 'Fränky' ? ' ' + styles['franky'] : '')}
|
||||
key={name + idx}
|
||||
>
|
||||
<span className={styles['player-name']}>{name}</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.`}
|
||||
{playerNames.map((name, idx) => {
|
||||
const currentScore = scores[idx];
|
||||
const progressPercentage = game.raceTo ? Math.min((currentScore / game.raceTo) * 100, 100) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles['player-score'] + (name === 'Fränky' ? ' ' + styles['franky'] : '')}
|
||||
key={name + idx}
|
||||
>
|
||||
{scores[idx]}
|
||||
</span>
|
||||
<button
|
||||
className={styles['score-button']}
|
||||
disabled={isCompleted}
|
||||
onClick={() => onUpdateScore(idx+1, -1)}
|
||||
aria-label={`Punkt abziehen für ${name}`}
|
||||
>-</button>
|
||||
<button
|
||||
className={styles['score-button']}
|
||||
disabled={isCompleted}
|
||||
onClick={() => onUpdateScore(idx+1, 1)}
|
||||
aria-label={`Punkt hinzufügen für ${name}`}
|
||||
>+</button>
|
||||
</div>
|
||||
))}
|
||||
<span className={styles['player-name']}>{name}</span>
|
||||
<div className={styles['progress-bar']}>
|
||||
<div
|
||||
className={styles['progress-fill']}
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<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 className={styles['game-detail-controls']}>
|
||||
<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>
|
||||
</div>
|
||||
<Toast
|
||||
show={toast.show}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast({ show: false, message: '', type: 'info' })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -44,12 +44,20 @@
|
||||
.player-score {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
padding: 30px 20px;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
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 {
|
||||
background-color: #43a047;
|
||||
@@ -61,21 +69,69 @@
|
||||
background-color: #333;
|
||||
}
|
||||
.player-name {
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 15px;
|
||||
color: #fff;
|
||||
text-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
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 {
|
||||
font-size: 16vh;
|
||||
font-weight: bold;
|
||||
margin: 10px 0 20px 0;
|
||||
font-size: 20vh;
|
||||
font-weight: 900;
|
||||
margin: 20px 0 30px 0;
|
||||
line-height: 1;
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -84,16 +140,42 @@
|
||||
margin-top: auto;
|
||||
}
|
||||
.score-button {
|
||||
background-color: #333;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #ff9800 0%, #ffa726 100%);
|
||||
color: #222;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 10px 20px;
|
||||
font-size: 18px;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
min-width: 80px;
|
||||
margin-bottom: 8px;
|
||||
transition: all 0.3s ease;
|
||||
width: 80px;
|
||||
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 {
|
||||
display: flex;
|
||||
|
||||
@@ -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;
|
||||
@@ -479,7 +479,7 @@ const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
|
||||
*/
|
||||
const GameTypeStep = ({ onNext, onCancel, 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) => {
|
||||
setGameType(selectedType);
|
||||
@@ -561,14 +561,8 @@ const GameTypeStep = ({ onNext, onCancel, initialValue = '' }) => {
|
||||
* @returns {import('preact').VNode}
|
||||
*/
|
||||
const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }) => {
|
||||
let quickPicks, defaultValue;
|
||||
if (gameType === '14/1 endlos') {
|
||||
quickPicks = [60, 70, 80, 90, 100];
|
||||
defaultValue = 80;
|
||||
} else {
|
||||
quickPicks = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
defaultValue = 5;
|
||||
}
|
||||
const quickPicks = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
const defaultValue = 5;
|
||||
const [raceTo, setRaceTo] = useState(initialValue !== '' ? initialValue : defaultValue);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
56
src/components/Toast.jsx
Normal file
56
src/components/Toast.jsx
Normal 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;
|
||||
|
||||
134
src/components/Toast.module.css
Normal file
134
src/components/Toast.module.css
Normal 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);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function NewGameScreen({
|
||||
const handleGameTypeNext = (type: string) => {
|
||||
onDataChange({
|
||||
gameType: type as any, // Type assertion for now, could be improved with proper validation
|
||||
raceTo: type === '14/1 endlos' ? '150' : '50'
|
||||
raceTo: '8'
|
||||
});
|
||||
onStepChange('raceTo');
|
||||
};
|
||||
|
||||
70
src/layouts/BaseLayout.astro
Normal file
70
src/layouts/BaseLayout.astro
Normal 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. © {new Date().getFullYear()} BSC Score</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -42,38 +42,20 @@ export class GameService {
|
||||
undoStack: [],
|
||||
};
|
||||
|
||||
if (gameData.gameType === '14/1 endlos') {
|
||||
const players = [
|
||||
{ name: gameData.player1, score: 0, consecutiveFouls: 0 },
|
||||
{ name: gameData.player2, score: 0, consecutiveFouls: 0 }
|
||||
];
|
||||
|
||||
if (gameData.player3) {
|
||||
players.push({ name: gameData.player3, score: 0, consecutiveFouls: 0 });
|
||||
}
|
||||
const standardGame: StandardGame = {
|
||||
...baseGame,
|
||||
player1: gameData.player1,
|
||||
player2: gameData.player2,
|
||||
score1: 0,
|
||||
score2: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
...baseGame,
|
||||
players,
|
||||
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;
|
||||
if (gameData.player3) {
|
||||
standardGame.player3 = gameData.player3;
|
||||
standardGame.score3 = 0;
|
||||
}
|
||||
|
||||
return standardGame;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -149,6 +149,20 @@ input:focus, select:focus {
|
||||
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 {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 {
|
||||
name: string;
|
||||
|
||||
@@ -4,7 +4,6 @@ export const GAME_TYPES: Array<{ value: GameType; label: string; defaultRaceTo:
|
||||
{ value: '8-Ball', label: '8-Ball', defaultRaceTo: 5 },
|
||||
{ value: '9-Ball', label: '9-Ball', defaultRaceTo: 9 },
|
||||
{ value: '10-Ball', label: '10-Ball', defaultRaceTo: 10 },
|
||||
{ value: '14/1 endlos', label: '14/1 Endlos', defaultRaceTo: 150 },
|
||||
];
|
||||
|
||||
export const RACE_TO_OPTIONS = [
|
||||
|
||||
Reference in New Issue
Block a user