Compare commits
21 Commits
38916b026d
...
modular-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68434f885d | ||
|
|
aa5ef1c5b2 | ||
|
|
6a25c18153 | ||
|
|
a71c65852d | ||
|
|
875e9c8795 | ||
|
|
429d479f69 | ||
|
|
dbc173f57b | ||
|
|
b466dd2a0a | ||
|
|
14fd711858 | ||
|
|
1c77661dbc | ||
|
|
47554cdd27 | ||
|
|
a2b618ce16 | ||
|
|
76ef005cda | ||
|
|
d1379985f3 | ||
|
|
209df5d9f2 | ||
|
|
7cb79f5ee3 | ||
|
|
d81c375f1e | ||
|
|
c845b0cb51 | ||
|
|
b44b013f58 | ||
|
|
8384d08393 | ||
|
|
de07d6e7a2 |
2
.gitea
2
.gitea
@@ -1 +1 @@
|
|||||||
@https://gitea.schwenk.online/froxxxy/bscscore/issues/1
|
@https://gitea.schwenk.online/froxxxy/bscscore/issues/10
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -24,4 +24,4 @@ pnpm-debug.log*
|
|||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
.gitea
|
.gitea
|
||||||
dev/
|
dev/.gitea
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
|
|
||||||
|
import preact from '@astrojs/preact';
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({});
|
export default defineConfig({
|
||||||
|
integrations: [preact()]
|
||||||
|
});
|
||||||
862
package-lock.json
generated
862
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,12 +3,14 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev --host",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^5.9.0"
|
"@astrojs/preact": "^4.1.0",
|
||||||
|
"astro": "^5.9.0",
|
||||||
|
"preact": "^10.26.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
359
src/components/App.jsx
Normal file
359
src/components/App.jsx
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useEffect, useCallback } from 'preact/hooks';
|
||||||
|
import GameList from './GameList.jsx';
|
||||||
|
import GameDetail from './GameDetail.jsx';
|
||||||
|
import { Player1Step, Player2Step, Player3Step, GameTypeStep, RaceToStep } from './NewGame.jsx';
|
||||||
|
import Modal from './Modal.jsx';
|
||||||
|
import ValidationModal from './ValidationModal.jsx';
|
||||||
|
import GameCompletionModal from './GameCompletionModal.jsx';
|
||||||
|
import FullscreenToggle from './FullscreenToggle.jsx';
|
||||||
|
|
||||||
|
const LOCAL_STORAGE_KEY = 'bscscore_games';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main App component for BSC Score
|
||||||
|
* @returns {import('preact').VNode}
|
||||||
|
*/
|
||||||
|
const App = () => {
|
||||||
|
const [games, setGames] = useState([]);
|
||||||
|
const [currentGameId, setCurrentGameId] = useState(null);
|
||||||
|
const [playerNameHistory, setPlayerNameHistory] = useState([]);
|
||||||
|
const [screen, setScreen] = useState('game-list');
|
||||||
|
const [modal, setModal] = useState({ open: false, gameId: null });
|
||||||
|
const [validation, setValidation] = useState({ open: false, message: '' });
|
||||||
|
const [completionModal, setCompletionModal] = useState({ open: false, game: null });
|
||||||
|
const [filter, setFilter] = useState('all');
|
||||||
|
const [newGameStep, setNewGameStep] = useState(null);
|
||||||
|
const [newGameData, setNewGameData] = useState({ player1: '', player2: '', player3: '', gameType: '', raceTo: '' });
|
||||||
|
|
||||||
|
// Load games from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const savedGames = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||||
|
if (savedGames) {
|
||||||
|
setGames(JSON.parse(savedGames));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save games to localStorage whenever games change
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(games));
|
||||||
|
// Update player name history
|
||||||
|
const nameLastUsed = {};
|
||||||
|
games.forEach(game => {
|
||||||
|
if (game.player1) nameLastUsed[game.player1] = Math.max(nameLastUsed[game.player1] || 0, new Date(game.updatedAt).getTime());
|
||||||
|
if (game.player2) nameLastUsed[game.player2] = Math.max(nameLastUsed[game.player2] || 0, new Date(game.updatedAt).getTime());
|
||||||
|
if (game.player3) nameLastUsed[game.player3] = Math.max(nameLastUsed[game.player3] || 0, new Date(game.updatedAt).getTime());
|
||||||
|
});
|
||||||
|
setPlayerNameHistory(
|
||||||
|
[...new Set(Object.keys(nameLastUsed))].sort((a, b) => nameLastUsed[b] - nameLastUsed[a])
|
||||||
|
);
|
||||||
|
}, [games]);
|
||||||
|
|
||||||
|
// Navigation handlers
|
||||||
|
const showGameList = useCallback(() => {
|
||||||
|
setScreen('game-list');
|
||||||
|
setCurrentGameId(null);
|
||||||
|
}, []);
|
||||||
|
const showNewGame = useCallback(() => {
|
||||||
|
setScreen('new-game');
|
||||||
|
setCurrentGameId(null);
|
||||||
|
setNewGameStep('player1');
|
||||||
|
setNewGameData({ player1: '', player2: '', player3: '', gameType: '', raceTo: '' });
|
||||||
|
}, []);
|
||||||
|
const showGameDetail = useCallback((id) => {
|
||||||
|
setCurrentGameId(id);
|
||||||
|
setScreen('game-detail');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Game creation
|
||||||
|
const handleCreateGame = useCallback(({ player1, player2, player3, gameType, raceTo }) => {
|
||||||
|
const newGame = {
|
||||||
|
id: Date.now(),
|
||||||
|
gameType,
|
||||||
|
raceTo: parseInt(raceTo, 10),
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
log: [],
|
||||||
|
undoStack: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (gameType === '14/1 endlos') {
|
||||||
|
const players = [{ name: player1, score: 0, consecutiveFouls: 0 }, { name: player2, score: 0, consecutiveFouls: 0 }];
|
||||||
|
if (player3) {
|
||||||
|
players.push({ name: player3, score: 0, consecutiveFouls: 0 });
|
||||||
|
}
|
||||||
|
newGame.players = players;
|
||||||
|
newGame.currentPlayer = null; // Set to null, will be chosen in GameDetail141
|
||||||
|
newGame.ballsOnTable = 15;
|
||||||
|
} else {
|
||||||
|
newGame.player1 = player1;
|
||||||
|
newGame.player2 = player2;
|
||||||
|
newGame.score1 = 0;
|
||||||
|
newGame.score2 = 0;
|
||||||
|
if (player3) {
|
||||||
|
newGame.player3 = player3;
|
||||||
|
newGame.score3 = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setGames(g => [newGame, ...g]);
|
||||||
|
return newGame.id;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Game update for 14.1
|
||||||
|
const handleGameAction = useCallback((updatedGame) => {
|
||||||
|
const originalGame = games.find(game => game.id === currentGameId);
|
||||||
|
if (!originalGame) return;
|
||||||
|
|
||||||
|
// Add the original state to the undo stack before updating
|
||||||
|
const newUndoStack = [...(originalGame.undoStack || []), originalGame];
|
||||||
|
|
||||||
|
const gameWithHistory = {
|
||||||
|
...updatedGame,
|
||||||
|
undoStack: newUndoStack,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setGames(games => games.map(game => (game.id === currentGameId ? gameWithHistory : game)));
|
||||||
|
|
||||||
|
// Check for raceTo completion
|
||||||
|
if (gameWithHistory.raceTo) {
|
||||||
|
const winner = gameWithHistory.players.find(p => p.score >= gameWithHistory.raceTo);
|
||||||
|
if (winner) {
|
||||||
|
setCompletionModal({ open: true, game: gameWithHistory });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [games, currentGameId]);
|
||||||
|
|
||||||
|
const handleUndo = useCallback(() => {
|
||||||
|
const game = games.find(g => g.id === currentGameId);
|
||||||
|
if (!game || !game.undoStack || game.undoStack.length === 0) return;
|
||||||
|
|
||||||
|
const lastState = game.undoStack[game.undoStack.length - 1];
|
||||||
|
const newUndoStack = game.undoStack.slice(0, -1);
|
||||||
|
|
||||||
|
setGames(g => g.map(gme => (gme.id === currentGameId ? { ...lastState, undoStack: newUndoStack } : gme)));
|
||||||
|
}, [games, currentGameId]);
|
||||||
|
|
||||||
|
const handleForfeit = useCallback(() => {
|
||||||
|
const game = games.find(g => g.id === currentGameId);
|
||||||
|
if (!game) return;
|
||||||
|
|
||||||
|
const winner = game.players.find((p, idx) => idx !== game.currentPlayer);
|
||||||
|
// In a 2 player game, this is simple. For >2, we need a winner selection.
|
||||||
|
// For now, assume the *other* player wins. This is fine for 2 players.
|
||||||
|
// We'll mark the game as complete with a note about the forfeit.
|
||||||
|
const forfeitedGame = {
|
||||||
|
...game,
|
||||||
|
status: 'completed',
|
||||||
|
winner: winner.name,
|
||||||
|
forfeitedBy: game.players[game.currentPlayer].name,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setGames(g => g.map(gme => (gme.id === currentGameId ? forfeitedGame : gme)));
|
||||||
|
setCompletionModal({ open: true, game: forfeitedGame });
|
||||||
|
}, [games, currentGameId]);
|
||||||
|
|
||||||
|
// Score update
|
||||||
|
const handleUpdateScore = useCallback((player, change) => {
|
||||||
|
setGames(games => games.map(game => {
|
||||||
|
if (game.id !== currentGameId || game.status === 'completed') return game;
|
||||||
|
const updated = { ...game };
|
||||||
|
if (player === 1) updated.score1 = Math.max(0, updated.score1 + change);
|
||||||
|
if (player === 2) updated.score2 = Math.max(0, updated.score2 + change);
|
||||||
|
if (player === 3) updated.score3 = Math.max(0, updated.score3 + change);
|
||||||
|
updated.updatedAt = new Date().toISOString();
|
||||||
|
// Check for raceTo completion
|
||||||
|
if (updated.raceTo && (updated.score1 >= updated.raceTo || updated.score2 >= updated.raceTo || (updated.player3 && updated.score3 >= updated.raceTo))) {
|
||||||
|
setCompletionModal({ open: true, game: updated });
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}));
|
||||||
|
setCompletionModal({ open: false, game: null });
|
||||||
|
setScreen('game-detail');
|
||||||
|
}, [currentGameId]);
|
||||||
|
|
||||||
|
// Finish game
|
||||||
|
const handleFinishGame = useCallback(() => {
|
||||||
|
const game = games.find(g => g.id === currentGameId);
|
||||||
|
if (!game) return;
|
||||||
|
setCompletionModal({ open: true, game });
|
||||||
|
}, [games, currentGameId]);
|
||||||
|
const handleConfirmCompletion = useCallback(() => {
|
||||||
|
setGames(games => games.map(game => {
|
||||||
|
if (game.id !== currentGameId) return game;
|
||||||
|
return { ...game, status: 'completed', updatedAt: new Date().toISOString() };
|
||||||
|
}));
|
||||||
|
setCompletionModal({ open: false, game: null });
|
||||||
|
setScreen('game-detail');
|
||||||
|
}, [currentGameId]);
|
||||||
|
|
||||||
|
const handleRematch = useCallback(() => {
|
||||||
|
const completedGame = games.find(g => g.id === currentGameId);
|
||||||
|
if (!completedGame) return;
|
||||||
|
|
||||||
|
const newId = handleCreateGame({
|
||||||
|
player1: completedGame.player1,
|
||||||
|
player2: completedGame.player2,
|
||||||
|
player3: completedGame.player3,
|
||||||
|
gameType: completedGame.gameType,
|
||||||
|
raceTo: completedGame.raceTo,
|
||||||
|
});
|
||||||
|
|
||||||
|
setCompletionModal({ open: false, game: null });
|
||||||
|
showGameDetail(newId);
|
||||||
|
}, [games, currentGameId, handleCreateGame, showGameDetail]);
|
||||||
|
|
||||||
|
// Delete game
|
||||||
|
const handleDeleteGame = useCallback((id) => {
|
||||||
|
setModal({ open: true, gameId: id });
|
||||||
|
}, []);
|
||||||
|
const handleConfirmDelete = useCallback(() => {
|
||||||
|
setGames(games => games.filter(g => g.id !== modal.gameId));
|
||||||
|
setModal({ open: false, gameId: null });
|
||||||
|
setScreen('game-list');
|
||||||
|
}, [modal.gameId]);
|
||||||
|
const handleCancelDelete = useCallback(() => {
|
||||||
|
setModal({ open: false, gameId: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Validation modal
|
||||||
|
const showValidation = useCallback((message) => {
|
||||||
|
setValidation({ open: true, message });
|
||||||
|
}, []);
|
||||||
|
const closeValidation = useCallback(() => {
|
||||||
|
setValidation({ open: false, message: '' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Step handlers
|
||||||
|
const handlePlayer1Next = (name) => {
|
||||||
|
setNewGameData(data => ({ ...data, player1: name }));
|
||||||
|
setNewGameStep('player2');
|
||||||
|
};
|
||||||
|
const handlePlayer2Next = (name) => {
|
||||||
|
setNewGameData(data => ({ ...data, player2: name }));
|
||||||
|
setNewGameStep('player3');
|
||||||
|
};
|
||||||
|
const handlePlayer3Next = (name) => {
|
||||||
|
setNewGameData(data => ({ ...data, player3: name }));
|
||||||
|
setNewGameStep('gameType');
|
||||||
|
};
|
||||||
|
const handleGameTypeNext = (type) => {
|
||||||
|
setNewGameData(data => ({ ...data, gameType: type, raceTo: type === '14/1 endlos' ? '150' : '50' }));
|
||||||
|
setNewGameStep('raceTo');
|
||||||
|
};
|
||||||
|
const handleRaceToNext = (raceTo) => {
|
||||||
|
const finalData = { ...newGameData, raceTo };
|
||||||
|
const newId = handleCreateGame(finalData);
|
||||||
|
showGameDetail(newId);
|
||||||
|
setNewGameStep(null);
|
||||||
|
setNewGameData({ player1: '', player2: '', player3: '', gameType: '', raceTo: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelNewGame = useCallback(() => {
|
||||||
|
setScreen('game-list');
|
||||||
|
setNewGameStep(null);
|
||||||
|
setNewGameData({ player1: '', player2: '', player3: '', gameType: '', raceTo: '' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="screen-container">
|
||||||
|
{screen === 'game-list' && (
|
||||||
|
<div className="screen active">
|
||||||
|
<div className="screen-content">
|
||||||
|
<button className="nav-button new-game-button" onClick={showNewGame} aria-label="Neues Spiel starten">Neues Spiel</button>
|
||||||
|
<GameList
|
||||||
|
games={games}
|
||||||
|
filter={filter}
|
||||||
|
onShowGameDetail={showGameDetail}
|
||||||
|
onDeleteGame={handleDeleteGame}
|
||||||
|
setFilter={setFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{screen === 'new-game' && (
|
||||||
|
<div className="screen active">
|
||||||
|
<div className="screen-content">
|
||||||
|
{newGameStep === 'player1' && (
|
||||||
|
<Player1Step
|
||||||
|
playerNameHistory={playerNameHistory}
|
||||||
|
onNext={handlePlayer1Next}
|
||||||
|
onCancel={handleCancelNewGame}
|
||||||
|
initialValue={newGameData.player1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{newGameStep === 'player2' && (
|
||||||
|
<Player2Step
|
||||||
|
playerNameHistory={playerNameHistory}
|
||||||
|
onNext={handlePlayer2Next}
|
||||||
|
onCancel={() => setNewGameStep('player1')}
|
||||||
|
initialValue={newGameData.player2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{newGameStep === 'player3' && (
|
||||||
|
<Player3Step
|
||||||
|
playerNameHistory={playerNameHistory}
|
||||||
|
onNext={handlePlayer3Next}
|
||||||
|
onCancel={() => setNewGameStep('player2')}
|
||||||
|
initialValue={newGameData.player3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{newGameStep === 'gameType' && (
|
||||||
|
<GameTypeStep
|
||||||
|
onNext={handleGameTypeNext}
|
||||||
|
onCancel={() => setNewGameStep('player3')}
|
||||||
|
initialValue={newGameData.gameType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{newGameStep === 'raceTo' && (
|
||||||
|
<RaceToStep
|
||||||
|
onNext={handleRaceToNext}
|
||||||
|
onCancel={() => setNewGameStep('gameType')}
|
||||||
|
initialValue={newGameData.raceTo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{screen === 'game-detail' && (
|
||||||
|
<div className="screen active">
|
||||||
|
<div className="screen-content">
|
||||||
|
<GameDetail
|
||||||
|
game={games.find(g => g.id === currentGameId)}
|
||||||
|
onUpdateScore={handleUpdateScore}
|
||||||
|
onFinishGame={handleFinishGame}
|
||||||
|
onUpdateGame={handleGameAction}
|
||||||
|
onUndo={handleUndo}
|
||||||
|
onForfeit={handleForfeit}
|
||||||
|
onBack={showGameList}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Modal
|
||||||
|
open={modal.open}
|
||||||
|
title="Spiel löschen"
|
||||||
|
message="Möchten Sie das Spiel wirklich löschen?"
|
||||||
|
onCancel={handleCancelDelete}
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
/>
|
||||||
|
<ValidationModal
|
||||||
|
open={validation.open}
|
||||||
|
message={validation.message}
|
||||||
|
onClose={closeValidation}
|
||||||
|
/>
|
||||||
|
<GameCompletionModal
|
||||||
|
open={completionModal.open}
|
||||||
|
game={completionModal.game}
|
||||||
|
onConfirm={handleConfirmCompletion}
|
||||||
|
onClose={() => setCompletionModal({ open: false, game: null })}
|
||||||
|
onRematch={handleRematch}
|
||||||
|
/>
|
||||||
|
<FullscreenToggle />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
35
src/components/FullscreenToggle.jsx
Normal file
35
src/components/FullscreenToggle.jsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useCallback } from 'preact/hooks';
|
||||||
|
import styles from './FullscreenToggle.module.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button to toggle fullscreen mode.
|
||||||
|
* @returns {import('preact').VNode}
|
||||||
|
*/
|
||||||
|
const FullscreenToggle = () => {
|
||||||
|
// Toggle fullscreen mode for the document
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
id="fullscreen-toggle"
|
||||||
|
className={styles.fullscreenToggle}
|
||||||
|
onClick={handleToggle}
|
||||||
|
title="Vollbild umschalten"
|
||||||
|
aria-label="Vollbild umschalten"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
|
||||||
|
<path fill="currentColor" d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FullscreenToggle;
|
||||||
38
src/components/FullscreenToggle.module.css
Normal file
38
src/components/FullscreenToggle.module.css
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/* FullscreenToggle-specific styles only. */
|
||||||
|
.fullscreenToggle {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: rgba(52, 152, 219, 0.9);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 9999;
|
||||||
|
transition: background-color 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
.fullscreenToggle:hover {
|
||||||
|
background-color: rgba(52, 152, 219, 1);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
.fullscreenToggle:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
.fullscreenToggle svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
@media screen and (max-width: 480px) {
|
||||||
|
.fullscreenToggle {
|
||||||
|
bottom: 15px;
|
||||||
|
right: 15px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/components/GameCompletionModal.jsx
Normal file
132
src/components/GameCompletionModal.jsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
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.
|
||||||
|
* @param {object} props
|
||||||
|
* @param {boolean} props.open
|
||||||
|
* @param {object} props.game
|
||||||
|
* @param {Function} props.onConfirm
|
||||||
|
* @param {Function} props.onClose
|
||||||
|
* @param {Function} props.onRematch
|
||||||
|
* @returns {import('preact').VNode|null}
|
||||||
|
*/
|
||||||
|
const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }) => {
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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-header']}>
|
||||||
|
<span className={modalStyles['modal-title']} id="completion-modal-title">Spiel beendet</span>
|
||||||
|
<button className={modalStyles['close-button']} onClick={onClose} aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
<div className={modalStyles['modal-body']}>
|
||||||
|
<div className={styles['final-scores']}>
|
||||||
|
{playerNames.map((name, idx) => (
|
||||||
|
<div className={styles['final-score']} key={name + idx}>
|
||||||
|
<span className={styles['player-name']}>{name}</span>
|
||||||
|
<span className={styles['score']}>{scores[idx]}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GameCompletionModal;
|
||||||
110
src/components/GameCompletionModal.module.css
Normal file
110
src/components/GameCompletionModal.module.css
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/* Only GameCompletionModal-specific styles. Shared modal styles are now in Modal.module.css */
|
||||||
|
.final-scores {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.final-score {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 18px 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.final-score .player-name {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.final-score .score {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.winner-announcement {
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0 0 0;
|
||||||
|
padding: 18px 8px;
|
||||||
|
background: #43a047;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.winner-announcement h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #2c3e50;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 18px 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #fff;
|
||||||
|
background: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.btn--warning {
|
||||||
|
background: #f44336;
|
||||||
|
}
|
||||||
|
.btn:not(.btn--warning):hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
.btn--warning:hover {
|
||||||
|
background: #d32f2f;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.btn {
|
||||||
|
font-size: 1rem;
|
||||||
|
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;
|
||||||
|
}
|
||||||
71
src/components/GameDetail.jsx
Normal file
71
src/components/GameDetail.jsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import styles from './GameDetail.module.css';
|
||||||
|
import GameDetail141 from './GameDetail141.jsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game detail view for a single game.
|
||||||
|
* @param {object} props
|
||||||
|
* @param {object} props.game
|
||||||
|
* @param {Function} props.onFinishGame
|
||||||
|
* @param {Function} props.onUpdateScore
|
||||||
|
* @param {Function} props.onUpdateGame
|
||||||
|
* @param {Function} props.onUndo
|
||||||
|
* @param {Function} props.onForfeit
|
||||||
|
* @param {Function} props.onBack
|
||||||
|
* @returns {import('preact').VNode|null}
|
||||||
|
*/
|
||||||
|
const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, onForfeit, onBack }) => {
|
||||||
|
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 playerNames = [game.player1, game.player2, game.player3].filter(Boolean);
|
||||||
|
const scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles['game-detail']}>
|
||||||
|
<div className={styles['game-title']}>
|
||||||
|
{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.`}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<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" disabled={isCompleted} onClick={onFinishGame} aria-label={isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}>{isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GameDetail;
|
||||||
276
src/components/GameDetail.module.css
Normal file
276
src/components/GameDetail.module.css
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
/* GameDetail-specific styles only. Shared utility classes are now in global CSS. */
|
||||||
|
.screen {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.screen.active {
|
||||||
|
display: block;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.screen-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.game-title {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
.game-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.scores-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 32px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.player-score {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
.player-score:first-child {
|
||||||
|
background-color: #43a047;
|
||||||
|
}
|
||||||
|
.player-score:nth-child(2) {
|
||||||
|
background-color: #1565c0;
|
||||||
|
}
|
||||||
|
.player-score:nth-child(3) {
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
.player-name {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.score {
|
||||||
|
font-size: 16vh;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 10px 0 20px 0;
|
||||||
|
line-height: 1;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.score-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
.score-button {
|
||||||
|
background-color: #333;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
min-width: 80px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.game-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.control-button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 30px;
|
||||||
|
background: #333;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
.control-button.delete {
|
||||||
|
background: #f44336;
|
||||||
|
}
|
||||||
|
.game-detail-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 24px;
|
||||||
|
margin: 40px 0 0 0;
|
||||||
|
width: 100%;
|
||||||
|
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;
|
||||||
|
}
|
||||||
208
src/components/GameDetail141.jsx
Normal file
208
src/components/GameDetail141.jsx
Normal 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;
|
||||||
24
src/components/GameHistory.module.css
Normal file
24
src/components/GameHistory.module.css
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/* GameHistory-specific styles only. Shared utility classes are now in global CSS. */
|
||||||
|
.screen {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.screen-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.screen-title {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
62
src/components/GameList.jsx
Normal file
62
src/components/GameList.jsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import styles from './GameList.module.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of games with filter and delete options.
|
||||||
|
* @param {object} props
|
||||||
|
* @param {object[]} props.games
|
||||||
|
* @param {string} props.filter
|
||||||
|
* @param {Function} props.setFilter
|
||||||
|
* @param {Function} props.onShowGameDetail
|
||||||
|
* @param {Function} props.onDeleteGame
|
||||||
|
* @returns {import('preact').VNode}
|
||||||
|
*/
|
||||||
|
const GameList = ({ games, filter = 'all', setFilter, onShowGameDetail, onDeleteGame }) => {
|
||||||
|
// Filter and sort games
|
||||||
|
const filteredGames = games
|
||||||
|
.filter(game => {
|
||||||
|
if (filter === 'active') return game.status === 'active';
|
||||||
|
if (filter === 'completed') return game.status === 'completed';
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles['game-list'] + ' ' + styles['games-container']}>
|
||||||
|
<div className={styles['filter-buttons']}>
|
||||||
|
<button className={styles['filter-button'] + (filter === 'all' ? ' ' + styles['active'] : '')} onClick={() => setFilter('all')} aria-label="Alle Spiele anzeigen">Alle</button>
|
||||||
|
<button className={styles['filter-button'] + (filter === 'active' ? ' ' + styles['active'] : '')} onClick={() => setFilter('active')} aria-label="Nur aktive Spiele anzeigen">Aktiv</button>
|
||||||
|
<button className={styles['filter-button'] + (filter === 'completed' ? ' ' + styles['active'] : '')} onClick={() => setFilter('completed')} aria-label="Nur abgeschlossene Spiele anzeigen">Abgeschlossen</button>
|
||||||
|
</div>
|
||||||
|
{filteredGames.length === 0 ? (
|
||||||
|
<div className={styles['empty-state']}>Keine Spiele vorhanden</div>
|
||||||
|
) : (
|
||||||
|
filteredGames.map(game => {
|
||||||
|
const playerNames = game.player3
|
||||||
|
? `${game.player1} vs ${game.player2} vs ${game.player3}`
|
||||||
|
: `${game.player1} vs ${game.player2}`;
|
||||||
|
const scores = game.player3
|
||||||
|
? `${game.score1} - ${game.score2} - ${game.score3}`
|
||||||
|
: `${game.score1} - ${game.score2}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
styles['game-item'] + ' ' + (game.status === 'completed' ? styles['completed'] : styles['active'])
|
||||||
|
}
|
||||||
|
key={game.id}
|
||||||
|
>
|
||||||
|
<div className={styles['game-info']} onClick={() => onShowGameDetail(game.id)} role="button" tabIndex={0} aria-label={`Details für Spiel ${playerNames}`}>
|
||||||
|
<div className={styles['game-type']}>{game.gameType}{game.raceTo ? ` | ${game.raceTo}` : ''}</div>
|
||||||
|
<div className={styles['player-names']}>{playerNames}</div>
|
||||||
|
<div className={styles['game-scores']}>{scores}</div>
|
||||||
|
</div>
|
||||||
|
<button className={styles['delete-button']} onClick={() => onDeleteGame(game.id)} aria-label={`Spiel löschen: ${playerNames}`}></button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GameList;
|
||||||
135
src/components/GameList.module.css
Normal file
135
src/components/GameList.module.css
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/* GameList-specific styles only. Shared utility classes are now in global CSS. */
|
||||||
|
.screen.active {
|
||||||
|
display: block;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.screen-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.screen-title {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.game-list {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.filter-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 24px 0 16px 0;
|
||||||
|
}
|
||||||
|
.filter-button {
|
||||||
|
flex: 1;
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
padding: 18px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
.filter-button.active {
|
||||||
|
background: #4CAF50;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.games-container {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
.game-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
transition: transform 0.1s ease, background-color 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.game-item.active {
|
||||||
|
background: #1e4620;
|
||||||
|
}
|
||||||
|
.game-item.completed {
|
||||||
|
background: #333;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.game-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
.game-type {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
min-width: 8rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.player-names {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
min-width: 16rem;
|
||||||
|
}
|
||||||
|
.game-scores {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 6rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.delete-button {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border: none;
|
||||||
|
background: #ff4444;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: 1rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.delete-button::before {
|
||||||
|
content: '\1F5D1'; /* 🗑️ */
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.delete-button:hover {
|
||||||
|
background: #cc0000;
|
||||||
|
}
|
||||||
|
.delete-button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #666;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.page-header {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: #181818;
|
||||||
|
padding: 24px 0 16px 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
35
src/components/Modal.jsx
Normal file
35
src/components/Modal.jsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import styles from './Modal.module.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic modal dialog for confirmation.
|
||||||
|
* @param {object} props
|
||||||
|
* @param {boolean} props.open
|
||||||
|
* @param {string} props.title
|
||||||
|
* @param {string} props.message
|
||||||
|
* @param {Function} props.onCancel
|
||||||
|
* @param {Function} props.onConfirm
|
||||||
|
* @returns {import('preact').VNode|null}
|
||||||
|
*/
|
||||||
|
const Modal = ({ open, title, message, onCancel, onConfirm }) => {
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div className={styles['modal'] + ' ' + styles['show']} role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||||
|
<div className={styles['modal-content']}>
|
||||||
|
<div className={styles['modal-header']}>
|
||||||
|
<span className={styles['modal-title']} id="modal-title">{title}</span>
|
||||||
|
<button className={styles['close-button']} onClick={onCancel} aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles['modal-body']}>
|
||||||
|
<div className={styles['modal-message']}>{message}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles['modal-footer']}>
|
||||||
|
<button className={styles['modal-button'] + ' ' + styles['cancel']} onClick={onCancel} aria-label="Abbrechen">Abbrechen</button>
|
||||||
|
<button className={styles['modal-button'] + ' ' + styles['confirm']} onClick={onConfirm} aria-label="Löschen">Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
66
src/components/Modal.module.css
Normal file
66
src/components/Modal.module.css
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/* Consolidated modal styles for all modals */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal.show {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.modal-content {
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.close-button {
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #888;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.close-button:hover {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.modal-body {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.modal-button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.modal-button.cancel {
|
||||||
|
background-color: #444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.modal-button.confirm {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.modal-button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
648
src/components/NewGame.jsx
Normal file
648
src/components/NewGame.jsx
Normal file
@@ -0,0 +1,648 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player 1 input step for multi-step game creation wizard.
|
||||||
|
* @param {object} props
|
||||||
|
* @param {string[]} props.playerNameHistory
|
||||||
|
* @param {Function} props.onNext
|
||||||
|
* @param {Function} props.onCancel
|
||||||
|
* @param {string} [props.initialValue]
|
||||||
|
* @returns {import('preact').VNode}
|
||||||
|
*/
|
||||||
|
const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }) => {
|
||||||
|
const [player1, setPlayer1] = useState(initialValue);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!player1) {
|
||||||
|
setFilteredNames(playerNameHistory);
|
||||||
|
} else {
|
||||||
|
setFilteredNames(
|
||||||
|
playerNameHistory.filter(name =>
|
||||||
|
name.toLowerCase().includes(player1.toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [player1, playerNameHistory]);
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!player1.trim()) {
|
||||||
|
setError('Bitte Namen für Spieler 1 eingeben');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
onNext(player1.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickPick = (name) => {
|
||||||
|
setError(null);
|
||||||
|
onNext(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalSelect = (name) => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
handleQuickPick(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setPlayer1('');
|
||||||
|
setError(null);
|
||||||
|
if (inputRef.current) inputRef.current.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spieler 1 Eingabe" autoComplete="off">
|
||||||
|
<div className={styles['screen-title']}>Neues Spiel – Schritt 1/5</div>
|
||||||
|
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
|
||||||
|
<span className={styles['progress-dot'] + ' ' + styles['active']} />
|
||||||
|
<span className={styles['progress-dot']} />
|
||||||
|
<span className={styles['progress-dot']} />
|
||||||
|
<span className={styles['progress-dot']} />
|
||||||
|
<span className={styles['progress-dot']} />
|
||||||
|
</div>
|
||||||
|
<div className={styles['player-input']} style={{ marginBottom: 32, position: 'relative' }}>
|
||||||
|
<label htmlFor="player1-input" style={{ fontSize: '1.3rem', fontWeight: 600 }}>Spieler 1</label>
|
||||||
|
<div style={{ position: 'relative', width: '100%' }}>
|
||||||
|
<input
|
||||||
|
id="player1-input"
|
||||||
|
className={styles['name-input']}
|
||||||
|
placeholder="Name Spieler 1"
|
||||||
|
value={player1}
|
||||||
|
onInput={e => setPlayer1(e.target.value)}
|
||||||
|
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>
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
{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>
|
||||||
|
{error && <div className={styles['validation-error']} style={{ marginBottom: 16 }}>{error}</div>}
|
||||||
|
{isModalOpen && (
|
||||||
|
<PlayerSelectModal
|
||||||
|
players={playerNameHistory}
|
||||||
|
onSelect={handleModalSelect}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<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 */}
|
||||||
|
←
|
||||||
|
</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 */}
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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' }}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</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' }}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</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' }}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</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' }}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</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 */}
|
||||||
|
←
|
||||||
|
</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 */}
|
||||||
|
→
|
||||||
|
</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 */}
|
||||||
|
←
|
||||||
|
</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 */}
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Player1Step, Player2Step, Player3Step, GameTypeStep, RaceToStep };
|
||||||
277
src/components/NewGame.module.css
Normal file
277
src/components/NewGame.module.css
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
/* NewGame-specific styles only. Shared utility classes are now in global CSS. */
|
||||||
|
.screen {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.screen-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.screen-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.player-inputs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.player-input {
|
||||||
|
background: #222;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px 16px 16px 16px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
.player-input label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.name-input-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.name-input {
|
||||||
|
flex: 2;
|
||||||
|
padding: 14px 12px;
|
||||||
|
border: 2px solid #333;
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
.game-settings {
|
||||||
|
margin-top: 0;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.setting-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.setting-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.setting-group select, .setting-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 12px;
|
||||||
|
border: 2px solid #333;
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.setting-group input:focus, .setting-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #666;
|
||||||
|
}
|
||||||
|
.validation-error {
|
||||||
|
color: #f44336;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.new-game-form {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 32px auto 0 auto;
|
||||||
|
background: #181818;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 16px rgba(0,0,0,0.4);
|
||||||
|
padding: 32px 24px 24px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
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%;
|
||||||
|
}
|
||||||
70
src/components/PlayerSelectModal.module.css
Normal file
70
src/components/PlayerSelectModal.module.css
Normal 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;
|
||||||
|
}
|
||||||
32
src/components/ValidationModal.jsx
Normal file
32
src/components/ValidationModal.jsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import styles from './Modal.module.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal for displaying validation errors.
|
||||||
|
* @param {object} props
|
||||||
|
* @param {boolean} props.open
|
||||||
|
* @param {string} props.message
|
||||||
|
* @param {Function} props.onClose
|
||||||
|
* @returns {import('preact').VNode|null}
|
||||||
|
*/
|
||||||
|
const ValidationModal = ({ open, message, onClose }) => {
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div className={styles['modal'] + ' ' + styles['show']} id="validation-modal" role="alertdialog" aria-modal="true" aria-labelledby="validation-modal-title">
|
||||||
|
<div className={styles['modal-content']}>
|
||||||
|
<div className={styles['modal-header']}>
|
||||||
|
<span className={styles['modal-title']} id="validation-modal-title">Fehler</span>
|
||||||
|
<button className={styles['close-button']} onClick={onClose} aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles['modal-body']}>
|
||||||
|
<div className={styles['modal-message']}>{message}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles['modal-footer']}>
|
||||||
|
<button className={styles['modal-button'] + ' ' + styles['cancel']} onClick={onClose} aria-label="OK">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ValidationModal;
|
||||||
File diff suppressed because it is too large
Load Diff
99
src/styles/index.css
Normal file
99
src/styles/index.css
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/* Global resets and utility styles */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-khtml-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
color: white;
|
||||||
|
min-height: 100vh;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
input, select {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments for fullscreen toggle button */
|
||||||
|
@media screen and (max-width: 480px) {
|
||||||
|
.fullscreenToggle {
|
||||||
|
bottom: 15px;
|
||||||
|
right: 15px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility button for new game (global, not component-specific) */
|
||||||
|
.new-game-button {
|
||||||
|
width: 100%;
|
||||||
|
background: #222;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 20px 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
text-align: center;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-game-button:hover {
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared utility classes for buttons and layout */
|
||||||
|
.btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 100px;
|
||||||
|
padding: 18px;
|
||||||
|
color: white;
|
||||||
|
background: #333;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
.nav-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 16px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal overlay (global, not component-specific) */
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.show {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/strict",
|
"extends": "astro/tsconfigs/strict",
|
||||||
"include": [".astro/types.d.ts", "**/*"],
|
"include": [
|
||||||
"exclude": ["dist"]
|
".astro/types.d.ts",
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user