Refactor BSC Score to Astro, TypeScript, and modular architecture
This commit is contained in:
259
README.md
259
README.md
@@ -1,80 +1,219 @@
|
|||||||
# BSC Score
|
# BSC Score - Pool Scoring Application
|
||||||
|
|
||||||
A modern, responsive web application for tracking billiards scores. Built with vanilla JavaScript and designed for mobile-first usage.
|
A modern, responsive pool/billiards scoring application built with **Astro** and **Preact**, following best practices for maintainability, performance, and reusability.
|
||||||
|
|
||||||
## Features
|
## ✨ Features
|
||||||
|
|
||||||
- Track scores for different billiards game types (8-Ball, 9-Ball, 10-Ball, 14/1)
|
- **Multi-game Support**: 8-Ball, 9-Ball, 10-Ball, and 14/1 Endlos
|
||||||
- Support for "Race to X" games
|
- **Real-time Scoring**: Live score tracking with undo functionality
|
||||||
- Real-time score tracking
|
- **Player Management**: Automatic player name history and suggestions
|
||||||
- Game history with active and completed games
|
- **Game Management**: Create, track, and manage multiple games
|
||||||
- Player name history and quick selection
|
- **Responsive Design**: Optimized for mobile and desktop
|
||||||
- Mobile-optimized touch interface
|
- **Progressive Web App**: Offline support and app-like experience
|
||||||
- Offline support with local storage
|
- **TypeScript**: Full type safety for better development experience
|
||||||
- Dark theme design
|
|
||||||
|
|
||||||
## Usage
|
## 🏗️ Architecture
|
||||||
|
|
||||||
1. Open `index.html` in your web browser
|
This project has been refactored following modern software development best practices:
|
||||||
2. Create a new game by clicking "Neues Spiel"
|
|
||||||
3. Select or enter player names
|
|
||||||
4. Choose game type and optional "Race to X" setting
|
|
||||||
5. Use the score buttons to track points during the game
|
|
||||||
6. Complete the game when finished
|
|
||||||
7. View game history and filter by status
|
|
||||||
|
|
||||||
## Development
|
### **Separation of Concerns**
|
||||||
|
- **Services Layer**: Game data management and localStorage operations
|
||||||
|
- **Custom Hooks**: Reusable state management logic
|
||||||
|
- **Components**: UI components with single responsibilities
|
||||||
|
- **Utils**: Pure utility functions for common operations
|
||||||
|
|
||||||
The application is built using:
|
### **Type Safety**
|
||||||
- Vanilla JavaScript (ES6+)
|
- Full TypeScript implementation
|
||||||
- HTML5
|
- Comprehensive type definitions for game domain
|
||||||
- CSS3
|
- Type-safe component props and state management
|
||||||
- LocalStorage for data persistence
|
|
||||||
|
|
||||||
No build process or dependencies required. Simply clone the repository and open `index.html` in a web browser.
|
### **Component Architecture**
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/ # Reusable UI components (Button, Card, Layout)
|
||||||
|
│ ├── screens/ # Screen-level components
|
||||||
|
│ └── ... # Feature-specific components
|
||||||
|
├── hooks/ # Custom React/Preact hooks
|
||||||
|
├── services/ # Business logic and data management
|
||||||
|
├── types/ # TypeScript type definitions
|
||||||
|
├── utils/ # Pure utility functions
|
||||||
|
└── styles/ # Global styles and CSS modules
|
||||||
|
```
|
||||||
|
|
||||||
## Project Structure
|
### **State Management**
|
||||||
|
- **useGameState**: Centralized game data management
|
||||||
|
- **useNavigation**: Screen and routing state
|
||||||
|
- **useModal**: Modal state management
|
||||||
|
- **Custom hooks**: Encapsulated, reusable state logic
|
||||||
|
|
||||||
The project consists of the following key files:
|
### **Design System**
|
||||||
- `index.html`: Main application file containing HTML, CSS, and JavaScript
|
- Consistent design tokens and CSS custom properties
|
||||||
- `README.md`: Project documentation and setup instructions
|
- Reusable UI components with variant support
|
||||||
- `LICENSE`: GNU GPLv3 license text
|
- Responsive design patterns
|
||||||
- `TODO.md`: Roadmap and planned features
|
- Accessibility-first approach
|
||||||
|
|
||||||
## Features in Detail
|
## 🚀 Getting Started
|
||||||
|
|
||||||
### Core Features
|
### Prerequisites
|
||||||
- Score tracking for multiple billiards game types
|
- Node.js 18+
|
||||||
- Player name history with quick selection
|
- npm or yarn
|
||||||
- Game status management (active/completed)
|
|
||||||
- Local storage for offline functionality
|
|
||||||
- Mobile-optimized interface
|
|
||||||
|
|
||||||
### User Interface
|
### Installation
|
||||||
- Dark theme design
|
```bash
|
||||||
- Touch-friendly controls
|
# Clone the repository
|
||||||
- Responsive layout
|
git clone <repository-url>
|
||||||
- Game type selection
|
cd bscscore
|
||||||
- Player name management
|
|
||||||
|
|
||||||
### Data Management
|
# Install dependencies
|
||||||
- Local storage persistence
|
npm install
|
||||||
- Game history tracking
|
|
||||||
- Player name history
|
|
||||||
- Status filtering
|
|
||||||
|
|
||||||
## Contributing
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
1. Fork the repository
|
### Available Scripts
|
||||||
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
|
```bash
|
||||||
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
|
npm run dev # Start development server
|
||||||
4. Push to the branch (`git push origin feature/AmazingFeature`)
|
npm run build # Build for production
|
||||||
5. Open a Pull Request
|
npm run preview # Preview production build
|
||||||
|
```
|
||||||
|
|
||||||
## Roadmap
|
## 📁 Project Structure
|
||||||
|
|
||||||
See [TODO.md](TODO.md) for a list of proposed features and known issues.
|
### **Core Components**
|
||||||
|
- `App.tsx` - Main application component with orchestrated state management
|
||||||
|
- `screens/` - Screen-level components (GameList, NewGame, GameDetail)
|
||||||
|
- `ui/` - Reusable UI components following design system
|
||||||
|
|
||||||
## License
|
### **State Management**
|
||||||
|
- `hooks/useGameState.ts` - Game CRUD operations and persistence
|
||||||
|
- `hooks/useNavigation.ts` - Application routing and screen state
|
||||||
|
- `hooks/useModal.ts` - Modal state management
|
||||||
|
|
||||||
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
|
### **Business Logic**
|
||||||
|
- `services/gameService.ts` - Game creation, updates, and business rules
|
||||||
|
- `utils/gameUtils.ts` - Game-related utility functions
|
||||||
|
- `utils/validation.ts` - Input validation and sanitization
|
||||||
|
|
||||||
|
### **Type Definitions**
|
||||||
|
- `types/game.ts` - Game domain types
|
||||||
|
- `types/ui.ts` - UI component types
|
||||||
|
- `types/css-modules.d.ts` - CSS modules type support
|
||||||
|
|
||||||
|
## 🎯 Key Improvements
|
||||||
|
|
||||||
|
### **From Monolithic to Modular**
|
||||||
|
- **Before**: 360-line App component handling everything
|
||||||
|
- **After**: Separated concerns with focused, single-responsibility components
|
||||||
|
|
||||||
|
### **Type Safety**
|
||||||
|
- **Before**: JavaScript with PropTypes comments
|
||||||
|
- **After**: Full TypeScript with comprehensive type definitions
|
||||||
|
|
||||||
|
### **State Management**
|
||||||
|
- **Before**: All state in one component with prop drilling
|
||||||
|
- **After**: Custom hooks with proper encapsulation and reusability
|
||||||
|
|
||||||
|
### **Reusability**
|
||||||
|
- **Before**: Tightly coupled, single-use components
|
||||||
|
- **After**: Reusable UI components with variant support
|
||||||
|
|
||||||
|
### **Performance**
|
||||||
|
- **Before**: Client-side only rendering
|
||||||
|
- **After**: Astro's islands architecture with optimal hydration
|
||||||
|
|
||||||
|
### **Developer Experience**
|
||||||
|
- **Before**: No structure, mixed concerns
|
||||||
|
- **After**: Clear architecture, proper tooling, and documentation
|
||||||
|
|
||||||
|
## 🛠️ Technology Stack
|
||||||
|
|
||||||
|
- **Framework**: [Astro](https://astro.build/) - Islands architecture for optimal performance
|
||||||
|
- **UI Library**: [Preact](https://preactjs.com/) - Lightweight React alternative
|
||||||
|
- **Language**: [TypeScript](https://www.typescriptlang.org/) - Type safety and better DX
|
||||||
|
- **Styling**: CSS Modules with design tokens
|
||||||
|
- **Build Tool**: Vite (integrated with Astro)
|
||||||
|
|
||||||
|
## 📱 Progressive Web App
|
||||||
|
|
||||||
|
The application includes PWA features:
|
||||||
|
- Offline support with service worker
|
||||||
|
- App manifest for "Add to Home Screen"
|
||||||
|
- Optimized for mobile devices
|
||||||
|
- Fast loading with proper caching strategies
|
||||||
|
|
||||||
|
## 🎨 Design System
|
||||||
|
|
||||||
|
### **Design Tokens**
|
||||||
|
- Consistent color palette
|
||||||
|
- Standardized spacing and sizing
|
||||||
|
- Responsive breakpoints
|
||||||
|
- Accessibility-compliant contrast ratios
|
||||||
|
|
||||||
|
### **Component Variants**
|
||||||
|
- Button: `primary`, `secondary`, `danger`
|
||||||
|
- Card: `default`, `elevated`, `outlined`
|
||||||
|
- Sizes: `small`, `medium`, `large`
|
||||||
|
|
||||||
|
## 🧪 Best Practices Implemented
|
||||||
|
|
||||||
|
### **Code Quality**
|
||||||
|
- ✅ SOLID principles
|
||||||
|
- ✅ DRY (Don't Repeat Yourself)
|
||||||
|
- ✅ Single Responsibility Principle
|
||||||
|
- ✅ Proper separation of concerns
|
||||||
|
- ✅ TypeScript strict mode
|
||||||
|
|
||||||
|
### **Performance**
|
||||||
|
- ✅ Code splitting with Astro islands
|
||||||
|
- ✅ Optimized bundle size
|
||||||
|
- ✅ Efficient re-rendering with proper hooks
|
||||||
|
- ✅ CSS Modules for optimized styling
|
||||||
|
|
||||||
|
### **Accessibility**
|
||||||
|
- ✅ ARIA labels and roles
|
||||||
|
- ✅ Keyboard navigation support
|
||||||
|
- ✅ Screen reader compatibility
|
||||||
|
- ✅ High contrast support
|
||||||
|
|
||||||
|
### **Maintainability**
|
||||||
|
- ✅ Clear file structure
|
||||||
|
- ✅ Comprehensive documentation
|
||||||
|
- ✅ Type safety throughout
|
||||||
|
- ✅ Modular, testable components
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
### **Astro Configuration**
|
||||||
|
- Preact integration with React compatibility
|
||||||
|
- TypeScript strict mode
|
||||||
|
- Optimized build settings
|
||||||
|
- Development server configuration
|
||||||
|
|
||||||
|
### **TypeScript Configuration**
|
||||||
|
- Strict type checking
|
||||||
|
- Modern ES2020+ features
|
||||||
|
- CSS Modules support
|
||||||
|
- Astro-specific types
|
||||||
|
|
||||||
|
## 📈 Future Improvements
|
||||||
|
|
||||||
|
- Unit and integration testing with Vitest
|
||||||
|
- E2E testing with Playwright
|
||||||
|
- Internationalization (i18n) support
|
||||||
|
- Advanced game statistics and analytics
|
||||||
|
- Real-time multiplayer support
|
||||||
|
- Game export/import functionality
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
This codebase follows strict development principles:
|
||||||
|
1. Every feature must be type-safe
|
||||||
|
2. Components must be reusable and well-documented
|
||||||
|
3. Business logic must be separated from UI logic
|
||||||
|
4. All changes must follow the established architecture patterns
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
[Include your license information here]
|
||||||
@@ -1,9 +1,41 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
|
|
||||||
import preact from '@astrojs/preact';
|
import preact from '@astrojs/preact';
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
integrations: [preact()]
|
integrations: [
|
||||||
|
preact({
|
||||||
|
compat: true, // Enable React compatibility for better ecosystem support
|
||||||
|
})
|
||||||
|
],
|
||||||
|
|
||||||
|
// Build optimizations
|
||||||
|
build: {
|
||||||
|
inlineStylesheets: 'auto',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Vite configuration for development
|
||||||
|
vite: {
|
||||||
|
define: {
|
||||||
|
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
|
||||||
|
},
|
||||||
|
css: {
|
||||||
|
modules: {
|
||||||
|
localsConvention: 'camelCase',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['preact/hooks'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Development server configuration
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
host: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Performance and SEO optimizations
|
||||||
|
compressHTML: true,
|
||||||
});
|
});
|
||||||
17
package-lock.json
generated
17
package-lock.json
generated
@@ -11,6 +11,9 @@
|
|||||||
"@astrojs/preact": "^4.1.0",
|
"@astrojs/preact": "^4.1.0",
|
||||||
"astro": "^5.9.0",
|
"astro": "^5.9.0",
|
||||||
"preact": "^10.26.8"
|
"preact": "^10.26.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ampproject/remapping": {
|
"node_modules/@ampproject/remapping": {
|
||||||
@@ -1831,12 +1834,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.15.29",
|
"version": "24.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.29.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.3.tgz",
|
||||||
"integrity": "sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ==",
|
"integrity": "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~7.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/unist": {
|
"node_modules/@types/unist": {
|
||||||
@@ -5098,9 +5101,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "7.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unicode-properties": {
|
"node_modules/unicode-properties": {
|
||||||
|
|||||||
@@ -12,5 +12,8 @@
|
|||||||
"@astrojs/preact": "^4.1.0",
|
"@astrojs/preact": "^4.1.0",
|
||||||
"astro": "^5.9.0",
|
"astro": "^5.9.0",
|
||||||
"preact": "^10.26.8"
|
"preact": "^10.26.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,360 +0,0 @@
|
|||||||
import { h } from 'preact';
|
|
||||||
import { useState, useEffect, useCallback } from 'preact/hooks';
|
|
||||||
import GameList from './GameList.jsx';
|
|
||||||
import GameDetail from './GameDetail.jsx';
|
|
||||||
import { Player1Step, Player2Step, Player3Step, GameTypeStep, RaceToStep } from './NewGame.jsx';
|
|
||||||
import Modal from './Modal.jsx';
|
|
||||||
import ValidationModal from './ValidationModal.jsx';
|
|
||||||
import GameCompletionModal from './GameCompletionModal.jsx';
|
|
||||||
import FullscreenToggle from './FullscreenToggle.jsx';
|
|
||||||
|
|
||||||
const LOCAL_STORAGE_KEY = 'bscscore_games';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main App component for BSC Score
|
|
||||||
* @returns {import('preact').VNode}
|
|
||||||
*/
|
|
||||||
const App = () => {
|
|
||||||
const [games, setGames] = useState([]);
|
|
||||||
const [currentGameId, setCurrentGameId] = useState(null);
|
|
||||||
const [playerNameHistory, setPlayerNameHistory] = useState([]);
|
|
||||||
const [screen, setScreen] = useState('game-list');
|
|
||||||
const [modal, setModal] = useState({ open: false, gameId: null });
|
|
||||||
const [validation, setValidation] = useState({ open: false, message: '' });
|
|
||||||
const [completionModal, setCompletionModal] = useState({ open: false, game: null });
|
|
||||||
const [filter, setFilter] = useState('all');
|
|
||||||
const [newGameStep, setNewGameStep] = useState(null);
|
|
||||||
const [newGameData, setNewGameData] = useState({ player1: '', player2: '', player3: '', gameType: '', raceTo: '' });
|
|
||||||
|
|
||||||
// Load games from localStorage on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const savedGames = localStorage.getItem(LOCAL_STORAGE_KEY);
|
|
||||||
if (savedGames) {
|
|
||||||
setGames(JSON.parse(savedGames));
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Save games to localStorage whenever games change
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(games));
|
|
||||||
// Update player name history
|
|
||||||
const nameLastUsed = {};
|
|
||||||
games.forEach(game => {
|
|
||||||
if (game.player1) nameLastUsed[game.player1] = Math.max(nameLastUsed[game.player1] || 0, new Date(game.updatedAt).getTime());
|
|
||||||
if (game.player2) nameLastUsed[game.player2] = Math.max(nameLastUsed[game.player2] || 0, new Date(game.updatedAt).getTime());
|
|
||||||
if (game.player3) nameLastUsed[game.player3] = Math.max(nameLastUsed[game.player3] || 0, new Date(game.updatedAt).getTime());
|
|
||||||
});
|
|
||||||
setPlayerNameHistory(
|
|
||||||
[...new Set(Object.keys(nameLastUsed))].sort((a, b) => nameLastUsed[b] - nameLastUsed[a])
|
|
||||||
);
|
|
||||||
}, [games]);
|
|
||||||
|
|
||||||
// Navigation handlers
|
|
||||||
const showGameList = useCallback(() => {
|
|
||||||
setScreen('game-list');
|
|
||||||
setCurrentGameId(null);
|
|
||||||
}, []);
|
|
||||||
const showNewGame = useCallback(() => {
|
|
||||||
setScreen('new-game');
|
|
||||||
setCurrentGameId(null);
|
|
||||||
setNewGameStep('player1');
|
|
||||||
setNewGameData({ player1: '', player2: '', player3: '', gameType: '', raceTo: '' });
|
|
||||||
}, []);
|
|
||||||
const showGameDetail = useCallback((id) => {
|
|
||||||
setCurrentGameId(id);
|
|
||||||
setScreen('game-detail');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Game creation
|
|
||||||
const handleCreateGame = useCallback(({ player1, player2, player3, gameType, raceTo }) => {
|
|
||||||
const newGame = {
|
|
||||||
id: Date.now(),
|
|
||||||
gameType,
|
|
||||||
raceTo: parseInt(raceTo, 10),
|
|
||||||
status: 'active',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
log: [],
|
|
||||||
undoStack: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (gameType === '14/1 endlos') {
|
|
||||||
const players = [{ name: player1, score: 0, consecutiveFouls: 0 }, { name: player2, score: 0, consecutiveFouls: 0 }];
|
|
||||||
if (player3) {
|
|
||||||
players.push({ name: player3, score: 0, consecutiveFouls: 0 });
|
|
||||||
}
|
|
||||||
newGame.players = players;
|
|
||||||
newGame.currentPlayer = null; // Set to null, will be chosen in GameDetail141
|
|
||||||
newGame.ballsOnTable = 15;
|
|
||||||
} else {
|
|
||||||
newGame.player1 = player1;
|
|
||||||
newGame.player2 = player2;
|
|
||||||
newGame.score1 = 0;
|
|
||||||
newGame.score2 = 0;
|
|
||||||
if (player3) {
|
|
||||||
newGame.player3 = player3;
|
|
||||||
newGame.score3 = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setGames(g => [newGame, ...g]);
|
|
||||||
return newGame.id;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Game update for 14.1
|
|
||||||
const handleGameAction = useCallback((updatedGame) => {
|
|
||||||
const originalGame = games.find(game => game.id === currentGameId);
|
|
||||||
if (!originalGame) return;
|
|
||||||
|
|
||||||
// Add the original state to the undo stack before updating
|
|
||||||
const newUndoStack = [...(originalGame.undoStack || []), originalGame];
|
|
||||||
|
|
||||||
const gameWithHistory = {
|
|
||||||
...updatedGame,
|
|
||||||
undoStack: newUndoStack,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
setGames(games => games.map(game => (game.id === currentGameId ? gameWithHistory : game)));
|
|
||||||
|
|
||||||
// Check for raceTo completion
|
|
||||||
if (gameWithHistory.raceTo) {
|
|
||||||
const winner = gameWithHistory.players.find(p => p.score >= gameWithHistory.raceTo);
|
|
||||||
if (winner) {
|
|
||||||
setCompletionModal({ open: true, game: gameWithHistory });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [games, currentGameId]);
|
|
||||||
|
|
||||||
const handleUndo = useCallback(() => {
|
|
||||||
const game = games.find(g => g.id === currentGameId);
|
|
||||||
if (!game || !game.undoStack || game.undoStack.length === 0) return;
|
|
||||||
|
|
||||||
const lastState = game.undoStack[game.undoStack.length - 1];
|
|
||||||
const newUndoStack = game.undoStack.slice(0, -1);
|
|
||||||
|
|
||||||
setGames(g => g.map(gme => (gme.id === currentGameId ? { ...lastState, undoStack: newUndoStack } : gme)));
|
|
||||||
}, [games, currentGameId]);
|
|
||||||
|
|
||||||
const handleForfeit = useCallback(() => {
|
|
||||||
const game = games.find(g => g.id === currentGameId);
|
|
||||||
if (!game) return;
|
|
||||||
|
|
||||||
const winner = game.players.find((p, idx) => idx !== game.currentPlayer);
|
|
||||||
// In a 2 player game, this is simple. For >2, we need a winner selection.
|
|
||||||
// For now, assume the *other* player wins. This is fine for 2 players.
|
|
||||||
// We'll mark the game as complete with a note about the forfeit.
|
|
||||||
const forfeitedGame = {
|
|
||||||
...game,
|
|
||||||
status: 'completed',
|
|
||||||
winner: winner.name,
|
|
||||||
forfeitedBy: game.players[game.currentPlayer].name,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
setGames(g => g.map(gme => (gme.id === currentGameId ? forfeitedGame : gme)));
|
|
||||||
setCompletionModal({ open: true, game: forfeitedGame });
|
|
||||||
}, [games, currentGameId]);
|
|
||||||
|
|
||||||
// Score update
|
|
||||||
const handleUpdateScore = useCallback((player, change) => {
|
|
||||||
setGames(games => games.map(game => {
|
|
||||||
if (game.id !== currentGameId || game.status === 'completed') return game;
|
|
||||||
const updated = { ...game };
|
|
||||||
if (player === 1) updated.score1 = Math.max(0, updated.score1 + change);
|
|
||||||
if (player === 2) updated.score2 = Math.max(0, updated.score2 + change);
|
|
||||||
if (player === 3) updated.score3 = Math.max(0, updated.score3 + change);
|
|
||||||
updated.updatedAt = new Date().toISOString();
|
|
||||||
// Check for raceTo completion
|
|
||||||
if (updated.raceTo && (updated.score1 >= updated.raceTo || updated.score2 >= updated.raceTo || (updated.player3 && updated.score3 >= updated.raceTo))) {
|
|
||||||
setCompletionModal({ open: true, game: updated });
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
}));
|
|
||||||
setCompletionModal({ open: false, game: null });
|
|
||||||
setScreen('game-detail');
|
|
||||||
}, [currentGameId]);
|
|
||||||
|
|
||||||
// Finish game
|
|
||||||
const handleFinishGame = useCallback(() => {
|
|
||||||
const game = games.find(g => g.id === currentGameId);
|
|
||||||
if (!game) return;
|
|
||||||
setCompletionModal({ open: true, game });
|
|
||||||
}, [games, currentGameId]);
|
|
||||||
const handleConfirmCompletion = useCallback(() => {
|
|
||||||
setGames(games => games.map(game => {
|
|
||||||
if (game.id !== currentGameId) return game;
|
|
||||||
return { ...game, status: 'completed', updatedAt: new Date().toISOString() };
|
|
||||||
}));
|
|
||||||
setCompletionModal({ open: false, game: null });
|
|
||||||
setScreen('game-detail');
|
|
||||||
}, [currentGameId]);
|
|
||||||
|
|
||||||
const handleRematch = useCallback(() => {
|
|
||||||
const completedGame = games.find(g => g.id === currentGameId);
|
|
||||||
if (!completedGame) return;
|
|
||||||
|
|
||||||
const newId = handleCreateGame({
|
|
||||||
player1: completedGame.player1,
|
|
||||||
player2: completedGame.player2,
|
|
||||||
player3: completedGame.player3,
|
|
||||||
gameType: completedGame.gameType,
|
|
||||||
raceTo: completedGame.raceTo,
|
|
||||||
});
|
|
||||||
|
|
||||||
setCompletionModal({ open: false, game: null });
|
|
||||||
showGameDetail(newId);
|
|
||||||
}, [games, currentGameId, handleCreateGame, showGameDetail]);
|
|
||||||
|
|
||||||
// Delete game
|
|
||||||
const handleDeleteGame = useCallback((id) => {
|
|
||||||
setModal({ open: true, gameId: id });
|
|
||||||
}, []);
|
|
||||||
const handleConfirmDelete = useCallback(() => {
|
|
||||||
setGames(games => games.filter(g => g.id !== modal.gameId));
|
|
||||||
setModal({ open: false, gameId: null });
|
|
||||||
setScreen('game-list');
|
|
||||||
}, [modal.gameId]);
|
|
||||||
const handleCancelDelete = useCallback(() => {
|
|
||||||
setModal({ open: false, gameId: null });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Validation modal
|
|
||||||
const showValidation = useCallback((message) => {
|
|
||||||
setValidation({ open: true, message });
|
|
||||||
}, []);
|
|
||||||
const closeValidation = useCallback(() => {
|
|
||||||
setValidation({ open: false, message: '' });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Step handlers
|
|
||||||
const handlePlayer1Next = (name) => {
|
|
||||||
setNewGameData(data => ({ ...data, player1: name }));
|
|
||||||
setNewGameStep('player2');
|
|
||||||
};
|
|
||||||
const handlePlayer2Next = (name) => {
|
|
||||||
setNewGameData(data => ({ ...data, player2: name }));
|
|
||||||
setNewGameStep('player3');
|
|
||||||
};
|
|
||||||
const handlePlayer3Next = (name) => {
|
|
||||||
setNewGameData(data => ({ ...data, player3: name }));
|
|
||||||
setNewGameStep('gameType');
|
|
||||||
};
|
|
||||||
const handleGameTypeNext = (type) => {
|
|
||||||
setNewGameData(data => ({ ...data, gameType: type, raceTo: type === '14/1 endlos' ? '150' : '50' }));
|
|
||||||
setNewGameStep('raceTo');
|
|
||||||
};
|
|
||||||
const handleRaceToNext = (raceTo) => {
|
|
||||||
const finalData = { ...newGameData, raceTo };
|
|
||||||
const newId = handleCreateGame(finalData);
|
|
||||||
showGameDetail(newId);
|
|
||||||
setNewGameStep(null);
|
|
||||||
setNewGameData({ player1: '', player2: '', player3: '', gameType: '', raceTo: '' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelNewGame = useCallback(() => {
|
|
||||||
setScreen('game-list');
|
|
||||||
setNewGameStep(null);
|
|
||||||
setNewGameData({ player1: '', player2: '', player3: '', gameType: '', raceTo: '' });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="screen-container">
|
|
||||||
{screen === 'game-list' && (
|
|
||||||
<div className="screen active">
|
|
||||||
<div className="screen-content">
|
|
||||||
<button className="nav-button new-game-button" onClick={showNewGame} aria-label="Neues Spiel starten">Neues Spiel</button>
|
|
||||||
<GameList
|
|
||||||
games={games}
|
|
||||||
filter={filter}
|
|
||||||
onShowGameDetail={showGameDetail}
|
|
||||||
onDeleteGame={handleDeleteGame}
|
|
||||||
setFilter={setFilter}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{screen === 'new-game' && (
|
|
||||||
<div className="screen active">
|
|
||||||
<div className="screen-content">
|
|
||||||
{newGameStep === 'player1' && (
|
|
||||||
<Player1Step
|
|
||||||
playerNameHistory={playerNameHistory}
|
|
||||||
onNext={handlePlayer1Next}
|
|
||||||
onCancel={handleCancelNewGame}
|
|
||||||
initialValue={newGameData.player1}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{newGameStep === 'player2' && (
|
|
||||||
<Player2Step
|
|
||||||
playerNameHistory={playerNameHistory}
|
|
||||||
onNext={handlePlayer2Next}
|
|
||||||
onCancel={() => setNewGameStep('player1')}
|
|
||||||
initialValue={newGameData.player2}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{newGameStep === 'player3' && (
|
|
||||||
<Player3Step
|
|
||||||
playerNameHistory={playerNameHistory}
|
|
||||||
onNext={handlePlayer3Next}
|
|
||||||
onCancel={() => setNewGameStep('player2')}
|
|
||||||
initialValue={newGameData.player3}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{newGameStep === 'gameType' && (
|
|
||||||
<GameTypeStep
|
|
||||||
onNext={handleGameTypeNext}
|
|
||||||
onCancel={() => setNewGameStep('player3')}
|
|
||||||
initialValue={newGameData.gameType}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{newGameStep === 'raceTo' && (
|
|
||||||
<RaceToStep
|
|
||||||
onNext={handleRaceToNext}
|
|
||||||
onCancel={() => setNewGameStep('gameType')}
|
|
||||||
initialValue={newGameData.raceTo}
|
|
||||||
gameType={newGameData.gameType}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{screen === 'game-detail' && (
|
|
||||||
<div className="screen active">
|
|
||||||
<div className="screen-content">
|
|
||||||
<GameDetail
|
|
||||||
game={games.find(g => g.id === currentGameId)}
|
|
||||||
onUpdateScore={handleUpdateScore}
|
|
||||||
onFinishGame={handleFinishGame}
|
|
||||||
onUpdateGame={handleGameAction}
|
|
||||||
onUndo={handleUndo}
|
|
||||||
onForfeit={handleForfeit}
|
|
||||||
onBack={showGameList}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Modal
|
|
||||||
open={modal.open}
|
|
||||||
title="Spiel löschen"
|
|
||||||
message="Möchten Sie das Spiel wirklich löschen?"
|
|
||||||
onCancel={handleCancelDelete}
|
|
||||||
onConfirm={handleConfirmDelete}
|
|
||||||
/>
|
|
||||||
<ValidationModal
|
|
||||||
open={validation.open}
|
|
||||||
message={validation.message}
|
|
||||||
onClose={closeValidation}
|
|
||||||
/>
|
|
||||||
<GameCompletionModal
|
|
||||||
open={completionModal.open}
|
|
||||||
game={completionModal.game}
|
|
||||||
onConfirm={handleConfirmCompletion}
|
|
||||||
onClose={() => setCompletionModal({ open: false, game: null })}
|
|
||||||
onRematch={handleRematch}
|
|
||||||
/>
|
|
||||||
<FullscreenToggle />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
244
src/components/App.tsx
Normal file
244
src/components/App.tsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
|
import { useGameState } from '../hooks/useGameState';
|
||||||
|
import { useNavigation, useNewGameWizard } from '../hooks/useNavigation';
|
||||||
|
import { useModal, useValidationModal, useCompletionModal } from '../hooks/useModal';
|
||||||
|
|
||||||
|
import { GameService } from '../services/gameService';
|
||||||
|
import type { StandardGame, EndlosGame } from '../types/game';
|
||||||
|
|
||||||
|
import { Layout } from './ui/Layout';
|
||||||
|
import GameListScreen from './screens/GameListScreen';
|
||||||
|
import NewGameScreen from './screens/NewGameScreen';
|
||||||
|
import GameDetailScreen from './screens/GameDetailScreen';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import ValidationModal from './ValidationModal';
|
||||||
|
import GameCompletionModal from './GameCompletionModal';
|
||||||
|
import FullscreenToggle from './FullscreenToggle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main App component for BSC Score
|
||||||
|
*/
|
||||||
|
export default function App() {
|
||||||
|
// State management hooks
|
||||||
|
const gameState = useGameState();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const newGameWizard = useNewGameWizard();
|
||||||
|
const modal = useModal();
|
||||||
|
const validationModal = useValidationModal();
|
||||||
|
const completionModal = useCompletionModal();
|
||||||
|
|
||||||
|
// Game lifecycle handlers
|
||||||
|
const handleCreateGame = (gameData: any) => {
|
||||||
|
const gameId = gameState.addGame(gameData);
|
||||||
|
newGameWizard.resetWizard();
|
||||||
|
navigation.showGameDetail(gameId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateScore = (player: number, change: number) => {
|
||||||
|
if (!navigation.currentGameId) return;
|
||||||
|
|
||||||
|
const game = gameState.getGameById(navigation.currentGameId);
|
||||||
|
if (!game || game.status === 'completed' || 'players' in game) return;
|
||||||
|
|
||||||
|
const updatedGame = GameService.updateGameScore(game as StandardGame, player, change);
|
||||||
|
gameState.updateGame(navigation.currentGameId, updatedGame);
|
||||||
|
|
||||||
|
// Check for completion
|
||||||
|
if (GameService.isGameCompleted(updatedGame)) {
|
||||||
|
completionModal.openCompletionModal(updatedGame);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGameAction = (updatedGame: EndlosGame) => {
|
||||||
|
if (!navigation.currentGameId) return;
|
||||||
|
|
||||||
|
const originalGame = gameState.getGameById(navigation.currentGameId);
|
||||||
|
if (!originalGame) return;
|
||||||
|
|
||||||
|
// Add undo state
|
||||||
|
const gameWithHistory = {
|
||||||
|
...updatedGame,
|
||||||
|
undoStack: [...(originalGame.undoStack || []), originalGame],
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
gameState.updateGame(navigation.currentGameId, gameWithHistory);
|
||||||
|
|
||||||
|
// Check for completion
|
||||||
|
if (GameService.isGameCompleted(gameWithHistory)) {
|
||||||
|
completionModal.openCompletionModal(gameWithHistory);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUndo = () => {
|
||||||
|
if (!navigation.currentGameId) return;
|
||||||
|
|
||||||
|
const game = gameState.getGameById(navigation.currentGameId);
|
||||||
|
if (!game?.undoStack?.length) return;
|
||||||
|
|
||||||
|
const lastState = game.undoStack[game.undoStack.length - 1];
|
||||||
|
const newUndoStack = game.undoStack.slice(0, -1);
|
||||||
|
|
||||||
|
gameState.updateGame(navigation.currentGameId, {
|
||||||
|
...lastState,
|
||||||
|
undoStack: newUndoStack,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleForfeit = () => {
|
||||||
|
if (!navigation.currentGameId) return;
|
||||||
|
|
||||||
|
const game = gameState.getGameById(navigation.currentGameId);
|
||||||
|
if (!game || !('players' in game)) return;
|
||||||
|
|
||||||
|
const currentPlayerIndex = game.currentPlayer;
|
||||||
|
if (currentPlayerIndex === null) return;
|
||||||
|
|
||||||
|
const winner = game.players.find((_, idx) => idx !== currentPlayerIndex);
|
||||||
|
const forfeitedGame = {
|
||||||
|
...game,
|
||||||
|
status: 'completed' as const,
|
||||||
|
winner: winner?.name,
|
||||||
|
forfeitedBy: game.players[currentPlayerIndex].name,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
gameState.updateGame(navigation.currentGameId, forfeitedGame);
|
||||||
|
completionModal.openCompletionModal(forfeitedGame);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinishGame = () => {
|
||||||
|
if (!navigation.currentGameId) return;
|
||||||
|
|
||||||
|
const game = gameState.getGameById(navigation.currentGameId);
|
||||||
|
if (!game) return;
|
||||||
|
|
||||||
|
completionModal.openCompletionModal(game);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmCompletion = () => {
|
||||||
|
if (!navigation.currentGameId) return;
|
||||||
|
|
||||||
|
gameState.updateGame(navigation.currentGameId, {
|
||||||
|
...gameState.getGameById(navigation.currentGameId)!,
|
||||||
|
status: 'completed',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
completionModal.closeCompletionModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRematch = () => {
|
||||||
|
if (!navigation.currentGameId) return;
|
||||||
|
|
||||||
|
const completedGame = gameState.getGameById(navigation.currentGameId);
|
||||||
|
if (!completedGame) return;
|
||||||
|
|
||||||
|
let gameData;
|
||||||
|
if ('players' in completedGame) {
|
||||||
|
gameData = {
|
||||||
|
player1: completedGame.players[0]?.name || '',
|
||||||
|
player2: completedGame.players[1]?.name || '',
|
||||||
|
player3: completedGame.players[2]?.name || '',
|
||||||
|
gameType: completedGame.gameType,
|
||||||
|
raceTo: completedGame.raceTo.toString(),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
gameData = {
|
||||||
|
player1: completedGame.player1,
|
||||||
|
player2: completedGame.player2,
|
||||||
|
player3: completedGame.player3 || '',
|
||||||
|
gameType: completedGame.gameType,
|
||||||
|
raceTo: completedGame.raceTo.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newGameId = gameState.addGame(gameData);
|
||||||
|
completionModal.closeCompletionModal();
|
||||||
|
navigation.showGameDetail(newGameId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteGame = (gameId: number) => {
|
||||||
|
modal.openModal(gameId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = () => {
|
||||||
|
if (modal.modal.gameId) {
|
||||||
|
gameState.deleteGame(modal.modal.gameId);
|
||||||
|
}
|
||||||
|
modal.closeModal();
|
||||||
|
navigation.showGameList();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
{navigation.screen === 'game-list' && (
|
||||||
|
<GameListScreen
|
||||||
|
games={gameState.getFilteredGames()}
|
||||||
|
filter={gameState.filter}
|
||||||
|
onFilterChange={gameState.setFilter}
|
||||||
|
onShowGameDetail={navigation.showGameDetail}
|
||||||
|
onDeleteGame={handleDeleteGame}
|
||||||
|
onShowNewGame={() => {
|
||||||
|
newGameWizard.startWizard();
|
||||||
|
navigation.showNewGame();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{navigation.screen === 'new-game' && (
|
||||||
|
<NewGameScreen
|
||||||
|
step={newGameWizard.newGameStep}
|
||||||
|
data={newGameWizard.newGameData}
|
||||||
|
playerHistory={gameState.getPlayerNameHistory()}
|
||||||
|
onStepChange={newGameWizard.nextStep}
|
||||||
|
onDataChange={newGameWizard.updateGameData}
|
||||||
|
onCreateGame={handleCreateGame}
|
||||||
|
onCancel={() => {
|
||||||
|
newGameWizard.resetWizard();
|
||||||
|
navigation.showGameList();
|
||||||
|
}}
|
||||||
|
onShowValidation={validationModal.showValidation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{navigation.screen === 'game-detail' && navigation.currentGameId && (
|
||||||
|
<GameDetailScreen
|
||||||
|
game={gameState.getGameById(navigation.currentGameId)}
|
||||||
|
onUpdateScore={handleUpdateScore}
|
||||||
|
onFinishGame={handleFinishGame}
|
||||||
|
onUpdateGame={handleGameAction}
|
||||||
|
onUndo={handleUndo}
|
||||||
|
onForfeit={handleForfeit}
|
||||||
|
onBack={navigation.showGameList}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={modal.modal.open}
|
||||||
|
title="Spiel löschen"
|
||||||
|
message="Möchten Sie das Spiel wirklich löschen?"
|
||||||
|
onCancel={modal.closeModal}
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ValidationModal
|
||||||
|
open={validationModal.validation.open}
|
||||||
|
message={validationModal.validation.message}
|
||||||
|
onClose={validationModal.closeValidation}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GameCompletionModal
|
||||||
|
open={completionModal.completionModal.open}
|
||||||
|
game={completionModal.completionModal.game}
|
||||||
|
onConfirm={handleConfirmCompletion}
|
||||||
|
onClose={completionModal.closeCompletionModal}
|
||||||
|
onRematch={handleRematch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FullscreenToggle />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/BscScoreApp.astro
Normal file
33
src/components/BscScoreApp.astro
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
// This is an Astro component that properly leverages SSR and islands
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Use Astro's islands architecture for better performance -->
|
||||||
|
<!-- Only hydrate the interactive app component when needed -->
|
||||||
|
<div id="app-root">
|
||||||
|
<slot name="app-content" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#app-root {
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progressive enhancement styles */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Client-side progressive enhancement
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Add any progressive enhancement here
|
||||||
|
console.log('BSC Score App initialized');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { h } from 'preact';
|
|
||||||
import styles from './GameList.module.css';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List of games with filter and delete options.
|
|
||||||
* @param {object} props
|
|
||||||
* @param {object[]} props.games
|
|
||||||
* @param {string} props.filter
|
|
||||||
* @param {Function} props.setFilter
|
|
||||||
* @param {Function} props.onShowGameDetail
|
|
||||||
* @param {Function} props.onDeleteGame
|
|
||||||
* @returns {import('preact').VNode}
|
|
||||||
*/
|
|
||||||
const GameList = ({ games, filter = 'all', setFilter, onShowGameDetail, onDeleteGame }) => {
|
|
||||||
// Filter and sort games
|
|
||||||
const filteredGames = games
|
|
||||||
.filter(game => {
|
|
||||||
if (filter === 'active') return game.status === 'active';
|
|
||||||
if (filter === 'completed') return game.status === 'completed';
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles['game-list'] + ' ' + styles['games-container']}>
|
|
||||||
<div className={styles['filter-buttons']}>
|
|
||||||
<button className={styles['filter-button'] + (filter === 'all' ? ' ' + styles['active'] : '')} onClick={() => setFilter('all')} aria-label="Alle Spiele anzeigen">Alle</button>
|
|
||||||
<button className={styles['filter-button'] + (filter === 'active' ? ' ' + styles['active'] : '')} onClick={() => setFilter('active')} aria-label="Nur aktive Spiele anzeigen">Aktiv</button>
|
|
||||||
<button className={styles['filter-button'] + (filter === 'completed' ? ' ' + styles['active'] : '')} onClick={() => setFilter('completed')} aria-label="Nur abgeschlossene Spiele anzeigen">Abgeschlossen</button>
|
|
||||||
</div>
|
|
||||||
{filteredGames.length === 0 ? (
|
|
||||||
<div className={styles['empty-state']}>Keine Spiele vorhanden</div>
|
|
||||||
) : (
|
|
||||||
filteredGames.map(game => {
|
|
||||||
const playerNames = game.player3
|
|
||||||
? `${game.player1} vs ${game.player2} vs ${game.player3}`
|
|
||||||
: `${game.player1} vs ${game.player2}`;
|
|
||||||
const scores = game.player3
|
|
||||||
? `${game.score1} - ${game.score2} - ${game.score3}`
|
|
||||||
: `${game.score1} - ${game.score2}`;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
styles['game-item'] + ' ' + (game.status === 'completed' ? styles['completed'] : styles['active'])
|
|
||||||
}
|
|
||||||
key={game.id}
|
|
||||||
>
|
|
||||||
<div className={styles['game-info']} onClick={() => onShowGameDetail(game.id)} role="button" tabIndex={0} aria-label={`Details für Spiel ${playerNames}`}>
|
|
||||||
<div className={styles['game-type']}>{game.gameType}{game.raceTo ? ` | ${game.raceTo}` : ''}</div>
|
|
||||||
<div className={styles['player-names']}>{playerNames}</div>
|
|
||||||
<div className={styles['game-scores']}>{scores}</div>
|
|
||||||
</div>
|
|
||||||
<button className={styles['delete-button']} onClick={() => onDeleteGame(game.id)} aria-label={`Spiel löschen: ${playerNames}`}></button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default GameList;
|
|
||||||
114
src/components/GameList.tsx
Normal file
114
src/components/GameList.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { Card } from './ui/Card';
|
||||||
|
import { Button } from './ui/Button';
|
||||||
|
import styles from './GameList.module.css';
|
||||||
|
import type { Game, GameFilter, StandardGame } from '../types/game';
|
||||||
|
|
||||||
|
interface GameListProps {
|
||||||
|
games: Game[];
|
||||||
|
filter: GameFilter;
|
||||||
|
setFilter: (filter: GameFilter) => void;
|
||||||
|
onShowGameDetail: (gameId: number) => void;
|
||||||
|
onDeleteGame: (gameId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GameList({
|
||||||
|
games,
|
||||||
|
filter = 'all',
|
||||||
|
setFilter,
|
||||||
|
onShowGameDetail,
|
||||||
|
onDeleteGame
|
||||||
|
}: GameListProps) {
|
||||||
|
const filteredGames = games
|
||||||
|
.filter(game => {
|
||||||
|
if (filter === 'active') return game.status === 'active';
|
||||||
|
if (filter === 'completed') return game.status === 'completed';
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
|
||||||
|
const getPlayerNames = (game: Game): string => {
|
||||||
|
if ('players' in game) {
|
||||||
|
return game.players.map(p => p.name).join(' vs ');
|
||||||
|
} else {
|
||||||
|
const standardGame = game as StandardGame;
|
||||||
|
return standardGame.player3
|
||||||
|
? `${standardGame.player1} vs ${standardGame.player2} vs ${standardGame.player3}`
|
||||||
|
: `${standardGame.player1} vs ${standardGame.player2}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScores = (game: Game): string => {
|
||||||
|
if ('players' in game) {
|
||||||
|
return game.players.map(p => p.score).join(' - ');
|
||||||
|
} else {
|
||||||
|
const standardGame = game as StandardGame;
|
||||||
|
return standardGame.player3
|
||||||
|
? `${standardGame.score1} - ${standardGame.score2} - ${standardGame.score3}`
|
||||||
|
: `${standardGame.score1} - ${standardGame.score2}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterButtons: Array<{ key: GameFilter; label: string; ariaLabel: string }> = [
|
||||||
|
{ key: 'all', label: 'Alle', ariaLabel: 'Alle Spiele anzeigen' },
|
||||||
|
{ key: 'active', label: 'Aktiv', ariaLabel: 'Nur aktive Spiele anzeigen' },
|
||||||
|
{ key: 'completed', label: 'Abgeschlossen', ariaLabel: 'Nur abgeschlossene Spiele anzeigen' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles['game-list'] + ' ' + styles['games-container']}>
|
||||||
|
<div className={styles['filter-buttons']}>
|
||||||
|
{filterButtons.map(({ key, label, ariaLabel }) => (
|
||||||
|
<Button
|
||||||
|
key={key}
|
||||||
|
variant={filter === key ? 'primary' : 'secondary'}
|
||||||
|
size="small"
|
||||||
|
onClick={() => setFilter(key)}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredGames.length === 0 ? (
|
||||||
|
<div className={styles['empty-state']}>Keine Spiele vorhanden</div>
|
||||||
|
) : (
|
||||||
|
filteredGames.map(game => {
|
||||||
|
const playerNames = getPlayerNames(game);
|
||||||
|
const scores = getScores(game);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={game.id}
|
||||||
|
variant="elevated"
|
||||||
|
className={game.status === 'completed' ? styles['completed'] : styles['active']}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={styles['game-info']}
|
||||||
|
onClick={() => onShowGameDetail(game.id)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Details für Spiel ${playerNames}`}
|
||||||
|
>
|
||||||
|
<div className={styles['game-type']}>
|
||||||
|
{game.gameType}{game.raceTo ? ` | ${game.raceTo}` : ''}
|
||||||
|
</div>
|
||||||
|
<div className={styles['player-names']}>{playerNames}</div>
|
||||||
|
<div className={styles['game-scores']}>{scores}</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="small"
|
||||||
|
onClick={() => onDeleteGame(game.id)}
|
||||||
|
aria-label={`Spiel löschen: ${playerNames}`}
|
||||||
|
>
|
||||||
|
🗑
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/components/screens/GameDetailScreen.tsx
Normal file
46
src/components/screens/GameDetailScreen.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { Screen } from '../ui/Layout';
|
||||||
|
import GameDetail from '../GameDetail';
|
||||||
|
import type { Game, EndlosGame } from '../../types/game';
|
||||||
|
|
||||||
|
interface GameDetailScreenProps {
|
||||||
|
game?: Game;
|
||||||
|
onUpdateScore: (player: number, change: number) => void;
|
||||||
|
onFinishGame: () => void;
|
||||||
|
onUpdateGame: (game: EndlosGame) => void;
|
||||||
|
onUndo: () => void;
|
||||||
|
onForfeit: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GameDetailScreen({
|
||||||
|
game,
|
||||||
|
onUpdateScore,
|
||||||
|
onFinishGame,
|
||||||
|
onUpdateGame,
|
||||||
|
onUndo,
|
||||||
|
onForfeit,
|
||||||
|
onBack,
|
||||||
|
}: GameDetailScreenProps) {
|
||||||
|
if (!game) {
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<div>Game not found</div>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<GameDetail
|
||||||
|
game={game}
|
||||||
|
onUpdateScore={onUpdateScore}
|
||||||
|
onFinishGame={onFinishGame}
|
||||||
|
onUpdateGame={onUpdateGame}
|
||||||
|
onUndo={onUndo}
|
||||||
|
onForfeit={onForfeit}
|
||||||
|
onBack={onBack}
|
||||||
|
/>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/components/screens/GameListScreen.tsx
Normal file
45
src/components/screens/GameListScreen.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { Button } from '../ui/Button';
|
||||||
|
import { Screen } from '../ui/Layout';
|
||||||
|
import GameList from '../GameList';
|
||||||
|
import type { Game, GameFilter } from '../../types/game';
|
||||||
|
|
||||||
|
interface GameListScreenProps {
|
||||||
|
games: Game[];
|
||||||
|
filter: GameFilter;
|
||||||
|
onFilterChange: (filter: GameFilter) => void;
|
||||||
|
onShowGameDetail: (gameId: number) => void;
|
||||||
|
onDeleteGame: (gameId: number) => void;
|
||||||
|
onShowNewGame: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GameListScreen({
|
||||||
|
games,
|
||||||
|
filter,
|
||||||
|
onFilterChange,
|
||||||
|
onShowGameDetail,
|
||||||
|
onDeleteGame,
|
||||||
|
onShowNewGame,
|
||||||
|
}: GameListScreenProps) {
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="large"
|
||||||
|
onClick={onShowNewGame}
|
||||||
|
aria-label="Neues Spiel starten"
|
||||||
|
style={{ width: '100%', marginBottom: '24px' }}
|
||||||
|
>
|
||||||
|
+ Neues Spiel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<GameList
|
||||||
|
games={games}
|
||||||
|
filter={filter}
|
||||||
|
onShowGameDetail={onShowGameDetail}
|
||||||
|
onDeleteGame={onDeleteGame}
|
||||||
|
setFilter={onFilterChange}
|
||||||
|
/>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/components/screens/NewGameScreen.tsx
Normal file
121
src/components/screens/NewGameScreen.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { Screen } from '../ui/Layout';
|
||||||
|
import { Player1Step, Player2Step, Player3Step, GameTypeStep, RaceToStep } from '../NewGame';
|
||||||
|
import type { NewGameStep, NewGameData } from '../../types/game';
|
||||||
|
|
||||||
|
interface NewGameScreenProps {
|
||||||
|
step: NewGameStep;
|
||||||
|
data: NewGameData;
|
||||||
|
playerHistory: string[];
|
||||||
|
onStepChange: (step: NewGameStep) => void;
|
||||||
|
onDataChange: (data: Partial<NewGameData>) => void;
|
||||||
|
onCreateGame: (data: NewGameData) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onShowValidation: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewGameScreen({
|
||||||
|
step,
|
||||||
|
data,
|
||||||
|
playerHistory,
|
||||||
|
onStepChange,
|
||||||
|
onDataChange,
|
||||||
|
onCreateGame,
|
||||||
|
onCancel,
|
||||||
|
onShowValidation,
|
||||||
|
}: NewGameScreenProps) {
|
||||||
|
const handlePlayer1Next = (name: string) => {
|
||||||
|
onDataChange({ player1: name });
|
||||||
|
onStepChange('player2');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlayer2Next = (name: string) => {
|
||||||
|
onDataChange({ player2: name });
|
||||||
|
onStepChange('player3');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlayer3Next = (name: string) => {
|
||||||
|
onDataChange({ player3: name });
|
||||||
|
onStepChange('gameType');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGameTypeNext = (type: string) => {
|
||||||
|
onDataChange({
|
||||||
|
gameType: type as any, // Type assertion for now, could be improved with proper validation
|
||||||
|
raceTo: type === '14/1 endlos' ? '150' : '50'
|
||||||
|
});
|
||||||
|
onStepChange('raceTo');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRaceToNext = (raceTo: string) => {
|
||||||
|
const finalData = { ...data, raceTo };
|
||||||
|
onCreateGame(finalData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStepBack = () => {
|
||||||
|
switch (step) {
|
||||||
|
case 'player2':
|
||||||
|
onStepChange('player1');
|
||||||
|
break;
|
||||||
|
case 'player3':
|
||||||
|
onStepChange('player2');
|
||||||
|
break;
|
||||||
|
case 'gameType':
|
||||||
|
onStepChange('player3');
|
||||||
|
break;
|
||||||
|
case 'raceTo':
|
||||||
|
onStepChange('gameType');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
{step === 'player1' && (
|
||||||
|
<Player1Step
|
||||||
|
playerNameHistory={playerHistory}
|
||||||
|
onNext={handlePlayer1Next}
|
||||||
|
onCancel={onCancel}
|
||||||
|
initialValue={data.player1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'player2' && (
|
||||||
|
<Player2Step
|
||||||
|
playerNameHistory={playerHistory}
|
||||||
|
onNext={handlePlayer2Next}
|
||||||
|
onCancel={handleStepBack}
|
||||||
|
initialValue={data.player2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'player3' && (
|
||||||
|
<Player3Step
|
||||||
|
playerNameHistory={playerHistory}
|
||||||
|
onNext={handlePlayer3Next}
|
||||||
|
onCancel={handleStepBack}
|
||||||
|
initialValue={data.player3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'gameType' && (
|
||||||
|
<GameTypeStep
|
||||||
|
onNext={handleGameTypeNext}
|
||||||
|
onCancel={handleStepBack}
|
||||||
|
initialValue={data.gameType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'raceTo' && (
|
||||||
|
<RaceToStep
|
||||||
|
onNext={handleRaceToNext}
|
||||||
|
onCancel={handleStepBack}
|
||||||
|
initialValue={data.raceTo}
|
||||||
|
gameType={data.gameType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/components/ui/Button.module.css
Normal file
82
src/components/ui/Button.module.css
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/* Design tokens */
|
||||||
|
:root {
|
||||||
|
--color-primary: #ff9800;
|
||||||
|
--color-primary-hover: #ffa726;
|
||||||
|
--color-secondary: #333;
|
||||||
|
--color-secondary-hover: #444;
|
||||||
|
--color-danger: #f44336;
|
||||||
|
--color-danger-hover: #ef5350;
|
||||||
|
--color-white: #fff;
|
||||||
|
--border-radius: 6px;
|
||||||
|
--transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: var(--transition);
|
||||||
|
touch-action: manipulation;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Variants */
|
||||||
|
.primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary:hover:not(.disabled) {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
background: var(--color-secondary);
|
||||||
|
color: var(--color-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary:hover:not(.disabled) {
|
||||||
|
background: var(--color-secondary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
background: var(--color-danger);
|
||||||
|
color: var(--color-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger:hover:not(.disabled) {
|
||||||
|
background: var(--color-danger-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sizes */
|
||||||
|
.small {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medium {
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large {
|
||||||
|
padding: 18px 32px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* States */
|
||||||
|
.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
32
src/components/ui/Button.tsx
Normal file
32
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import type { ButtonProps } from '../../types/ui';
|
||||||
|
import styles from './Button.module.css';
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = 'secondary',
|
||||||
|
size = 'medium',
|
||||||
|
disabled = false,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
...rest
|
||||||
|
}: ButtonProps) {
|
||||||
|
const classNames = [
|
||||||
|
styles.button,
|
||||||
|
styles[variant],
|
||||||
|
styles[size],
|
||||||
|
disabled && styles.disabled,
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={classNames}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/components/ui/Card.module.css
Normal file
53
src/components/ui/Card.module.css
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
.card {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.default {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevated {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outlined {
|
||||||
|
background: transparent;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Padding variants */
|
||||||
|
.padding-none {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.padding-small {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.padding-medium {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.padding-large {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
34
src/components/ui/Card.tsx
Normal file
34
src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import styles from './Card.module.css';
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
children: any;
|
||||||
|
variant?: 'default' | 'elevated' | 'outlined';
|
||||||
|
padding?: 'none' | 'small' | 'medium' | 'large';
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({
|
||||||
|
children,
|
||||||
|
variant = 'default',
|
||||||
|
padding = 'medium',
|
||||||
|
className = '',
|
||||||
|
onClick
|
||||||
|
}: CardProps) {
|
||||||
|
const classNames = [
|
||||||
|
styles.card,
|
||||||
|
styles[variant],
|
||||||
|
styles[`padding-${padding}`],
|
||||||
|
onClick && styles.clickable,
|
||||||
|
className,
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
const Component = onClick ? 'button' : 'div';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Component className={classNames} onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/ui/Layout.module.css
Normal file
24
src/components/ui/Layout.module.css
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
.layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.content {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/components/ui/Layout.tsx
Normal file
30
src/components/ui/Layout.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import styles from './Layout.module.css';
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: any;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Layout({ children, className = '' }: LayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className={`${styles.layout} ${className}`}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScreenProps {
|
||||||
|
children: any;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Screen({ children, className = '' }: ScreenProps) {
|
||||||
|
return (
|
||||||
|
<div className={`${styles.screen} ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
src/hooks/useGameState.ts
Normal file
65
src/hooks/useGameState.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'preact/hooks';
|
||||||
|
import type { Game, NewGameData, GameFilter } from '../types/game';
|
||||||
|
import { GameService } from '../services/gameService';
|
||||||
|
|
||||||
|
export function useGameState() {
|
||||||
|
const [games, setGames] = useState<Game[]>([]);
|
||||||
|
const [filter, setFilter] = useState<GameFilter>('all');
|
||||||
|
|
||||||
|
// Load games from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const savedGames = GameService.loadGames();
|
||||||
|
setGames(savedGames);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save games to localStorage whenever games change
|
||||||
|
useEffect(() => {
|
||||||
|
GameService.saveGames(games);
|
||||||
|
}, [games]);
|
||||||
|
|
||||||
|
const addGame = useCallback((gameData: NewGameData): number => {
|
||||||
|
const newGame = GameService.createGame(gameData);
|
||||||
|
setGames(prevGames => [newGame, ...prevGames]);
|
||||||
|
return newGame.id;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateGame = useCallback((gameId: number, updatedGame: Game) => {
|
||||||
|
setGames(prevGames =>
|
||||||
|
prevGames.map(game => game.id === gameId ? updatedGame : game)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteGame = useCallback((gameId: number) => {
|
||||||
|
setGames(prevGames => prevGames.filter(game => game.id !== gameId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getGameById = useCallback((gameId: number): Game | undefined => {
|
||||||
|
return games.find(game => game.id === gameId);
|
||||||
|
}, [games]);
|
||||||
|
|
||||||
|
const getFilteredGames = useCallback((): Game[] => {
|
||||||
|
return games
|
||||||
|
.filter(game => {
|
||||||
|
if (filter === 'active') return game.status === 'active';
|
||||||
|
if (filter === 'completed') return game.status === 'completed';
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
}, [games, filter]);
|
||||||
|
|
||||||
|
const getPlayerNameHistory = useCallback((): string[] => {
|
||||||
|
return GameService.getPlayerNameHistory(games);
|
||||||
|
}, [games]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
games,
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
addGame,
|
||||||
|
updateGame,
|
||||||
|
deleteGame,
|
||||||
|
getGameById,
|
||||||
|
getFilteredGames,
|
||||||
|
getPlayerNameHistory,
|
||||||
|
};
|
||||||
|
}
|
||||||
60
src/hooks/useModal.ts
Normal file
60
src/hooks/useModal.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useState, useCallback } from 'preact/hooks';
|
||||||
|
import type { ModalState, ValidationState, CompletionModalState } from '../types/ui';
|
||||||
|
import type { Game } from '../types/game';
|
||||||
|
|
||||||
|
export function useModal() {
|
||||||
|
const [modal, setModal] = useState<ModalState>({ open: false, gameId: null });
|
||||||
|
|
||||||
|
const openModal = useCallback((gameId?: number) => {
|
||||||
|
setModal({ open: true, gameId });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeModal = useCallback(() => {
|
||||||
|
setModal({ open: false, gameId: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
modal,
|
||||||
|
openModal,
|
||||||
|
closeModal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useValidationModal() {
|
||||||
|
const [validation, setValidation] = useState<ValidationState>({ open: false, message: '' });
|
||||||
|
|
||||||
|
const showValidation = useCallback((message: string) => {
|
||||||
|
setValidation({ open: true, message });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeValidation = useCallback(() => {
|
||||||
|
setValidation({ open: false, message: '' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
validation,
|
||||||
|
showValidation,
|
||||||
|
closeValidation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCompletionModal() {
|
||||||
|
const [completionModal, setCompletionModal] = useState<CompletionModalState>({
|
||||||
|
open: false,
|
||||||
|
game: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const openCompletionModal = useCallback((game: Game) => {
|
||||||
|
setCompletionModal({ open: true, game });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeCompletionModal = useCallback(() => {
|
||||||
|
setCompletionModal({ open: false, game: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
completionModal,
|
||||||
|
openCompletionModal,
|
||||||
|
closeCompletionModal,
|
||||||
|
};
|
||||||
|
}
|
||||||
82
src/hooks/useNavigation.ts
Normal file
82
src/hooks/useNavigation.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useState, useCallback } from 'preact/hooks';
|
||||||
|
import type { NewGameStep, NewGameData } from '../types/game';
|
||||||
|
|
||||||
|
type Screen = 'game-list' | 'new-game' | 'game-detail';
|
||||||
|
|
||||||
|
export function useNavigation() {
|
||||||
|
const [screen, setScreen] = useState<Screen>('game-list');
|
||||||
|
const [currentGameId, setCurrentGameId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const showGameList = useCallback(() => {
|
||||||
|
setScreen('game-list');
|
||||||
|
setCurrentGameId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showNewGame = useCallback(() => {
|
||||||
|
setScreen('new-game');
|
||||||
|
setCurrentGameId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showGameDetail = useCallback((gameId: number) => {
|
||||||
|
setCurrentGameId(gameId);
|
||||||
|
setScreen('game-detail');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
screen,
|
||||||
|
currentGameId,
|
||||||
|
showGameList,
|
||||||
|
showNewGame,
|
||||||
|
showGameDetail,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNewGameWizard() {
|
||||||
|
const [newGameStep, setNewGameStep] = useState<NewGameStep>(null);
|
||||||
|
const [newGameData, setNewGameData] = useState<NewGameData>({
|
||||||
|
player1: '',
|
||||||
|
player2: '',
|
||||||
|
player3: '',
|
||||||
|
gameType: '',
|
||||||
|
raceTo: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const startWizard = useCallback(() => {
|
||||||
|
setNewGameStep('player1');
|
||||||
|
setNewGameData({
|
||||||
|
player1: '',
|
||||||
|
player2: '',
|
||||||
|
player3: '',
|
||||||
|
gameType: '',
|
||||||
|
raceTo: '',
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetWizard = useCallback(() => {
|
||||||
|
setNewGameStep(null);
|
||||||
|
setNewGameData({
|
||||||
|
player1: '',
|
||||||
|
player2: '',
|
||||||
|
player3: '',
|
||||||
|
gameType: '',
|
||||||
|
raceTo: '',
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateGameData = useCallback((data: Partial<NewGameData>) => {
|
||||||
|
setNewGameData(prev => ({ ...prev, ...data }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const nextStep = useCallback((step: NewGameStep) => {
|
||||||
|
setNewGameStep(step);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
newGameStep,
|
||||||
|
newGameData,
|
||||||
|
startWizard,
|
||||||
|
resetWizard,
|
||||||
|
updateGameData,
|
||||||
|
nextStep,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,9 +1,38 @@
|
|||||||
---
|
---
|
||||||
import "../styles/index.css";
|
import "../styles/index.css";
|
||||||
import App from "../components/App.jsx";
|
import BscScoreApp from "../components/BscScoreApp.astro";
|
||||||
|
import App from "../components/App";
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- Main entry point for the Pool Scoring App -->
|
<!DOCTYPE html>
|
||||||
<main class="screen-container">
|
<html lang="de">
|
||||||
<App client:only="preact" />
|
<head>
|
||||||
</main>
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>BSC Score - Pool Scoring App</title>
|
||||||
|
<meta name="description" content="Professional pool/billiards scoring application for tournaments and casual games">
|
||||||
|
|
||||||
|
<!-- Performance optimizations -->
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
|
||||||
|
<!-- PWA Meta tags -->
|
||||||
|
<meta name="theme-color" content="#1a1a1a">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<BscScoreApp>
|
||||||
|
<!--
|
||||||
|
Using client:only for the main app since it's highly interactive
|
||||||
|
and benefits from full client-side rendering
|
||||||
|
-->
|
||||||
|
<App client:only="preact" slot="app-content" />
|
||||||
|
</BscScoreApp>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
155
src/services/gameService.ts
Normal file
155
src/services/gameService.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import type { Game, GameType, StandardGame, EndlosGame, NewGameData } from '../types/game';
|
||||||
|
|
||||||
|
const LOCAL_STORAGE_KEY = 'bscscore_games';
|
||||||
|
|
||||||
|
export class GameService {
|
||||||
|
/**
|
||||||
|
* Load games from localStorage
|
||||||
|
*/
|
||||||
|
static loadGames(): Game[] {
|
||||||
|
try {
|
||||||
|
const savedGames = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||||
|
return savedGames ? JSON.parse(savedGames) : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading games from localStorage:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save games to localStorage
|
||||||
|
*/
|
||||||
|
static saveGames(games: Game[]): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(games));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving games to localStorage:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new game
|
||||||
|
*/
|
||||||
|
static createGame(gameData: NewGameData): Game {
|
||||||
|
const baseGame = {
|
||||||
|
id: Date.now(),
|
||||||
|
gameType: gameData.gameType as GameType,
|
||||||
|
raceTo: parseInt(gameData.raceTo, 10),
|
||||||
|
status: 'active' as const,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
log: [],
|
||||||
|
undoStack: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (gameData.gameType === '14/1 endlos') {
|
||||||
|
const players = [
|
||||||
|
{ name: gameData.player1, score: 0, consecutiveFouls: 0 },
|
||||||
|
{ name: gameData.player2, score: 0, consecutiveFouls: 0 }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (gameData.player3) {
|
||||||
|
players.push({ name: gameData.player3, score: 0, consecutiveFouls: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseGame,
|
||||||
|
players,
|
||||||
|
currentPlayer: null,
|
||||||
|
ballsOnTable: 15,
|
||||||
|
} as EndlosGame;
|
||||||
|
} else {
|
||||||
|
const standardGame: StandardGame = {
|
||||||
|
...baseGame,
|
||||||
|
player1: gameData.player1,
|
||||||
|
player2: gameData.player2,
|
||||||
|
score1: 0,
|
||||||
|
score2: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (gameData.player3) {
|
||||||
|
standardGame.player3 = gameData.player3;
|
||||||
|
standardGame.score3 = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return standardGame;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a game's score (for standard games)
|
||||||
|
*/
|
||||||
|
static updateGameScore(game: StandardGame, player: number, change: number): StandardGame {
|
||||||
|
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 !== undefined) {
|
||||||
|
updated.score3 = Math.max(0, updated.score3 + change);
|
||||||
|
}
|
||||||
|
|
||||||
|
updated.updatedAt = new Date().toISOString();
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a game is completed based on raceTo
|
||||||
|
*/
|
||||||
|
static isGameCompleted(game: Game): boolean {
|
||||||
|
if (game.status === 'completed') return true;
|
||||||
|
|
||||||
|
if ('players' in game) {
|
||||||
|
// EndlosGame
|
||||||
|
return game.players.some(player => player.score >= game.raceTo);
|
||||||
|
} else {
|
||||||
|
// StandardGame
|
||||||
|
const scores = [game.score1, game.score2, game.score3].filter(score => score !== undefined);
|
||||||
|
return scores.some(score => score >= game.raceTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the winner of a completed game
|
||||||
|
*/
|
||||||
|
static getGameWinner(game: Game): string | null {
|
||||||
|
if (!this.isGameCompleted(game)) return null;
|
||||||
|
|
||||||
|
if ('players' in game) {
|
||||||
|
// EndlosGame
|
||||||
|
const winner = game.players.find(player => player.score >= game.raceTo);
|
||||||
|
return winner?.name || null;
|
||||||
|
} else {
|
||||||
|
// StandardGame
|
||||||
|
if (game.score1 >= game.raceTo) return game.player1;
|
||||||
|
if (game.score2 >= game.raceTo) return game.player2;
|
||||||
|
if (game.player3 && game.score3 && game.score3 >= game.raceTo) return game.player3;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract player name history from games
|
||||||
|
*/
|
||||||
|
static getPlayerNameHistory(games: Game[]): string[] {
|
||||||
|
const nameLastUsed: Record<string, number> = {};
|
||||||
|
|
||||||
|
games.forEach(game => {
|
||||||
|
const timestamp = new Date(game.updatedAt).getTime();
|
||||||
|
|
||||||
|
if ('players' in game) {
|
||||||
|
// EndlosGame
|
||||||
|
game.players.forEach(player => {
|
||||||
|
nameLastUsed[player.name] = Math.max(nameLastUsed[player.name] || 0, timestamp);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// StandardGame
|
||||||
|
if (game.player1) nameLastUsed[game.player1] = Math.max(nameLastUsed[game.player1] || 0, timestamp);
|
||||||
|
if (game.player2) nameLastUsed[game.player2] = Math.max(nameLastUsed[game.player2] || 0, timestamp);
|
||||||
|
if (game.player3) nameLastUsed[game.player3] = Math.max(nameLastUsed[game.player3] || 0, timestamp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...new Set(Object.keys(nameLastUsed))].sort((a, b) => nameLastUsed[b] - nameLastUsed[a]);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/types/css-modules.d.ts
vendored
Normal file
9
src/types/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
declare module '*.module.css' {
|
||||||
|
const classes: { [key: string]: string };
|
||||||
|
export default classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.css' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
51
src/types/game.ts
Normal file
51
src/types/game.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
export type GameStatus = 'active' | 'completed';
|
||||||
|
|
||||||
|
export type GameType = '8-Ball' | '9-Ball' | '10-Ball' | '14/1 endlos';
|
||||||
|
|
||||||
|
export interface Player {
|
||||||
|
name: string;
|
||||||
|
score: number;
|
||||||
|
consecutiveFouls?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseGame {
|
||||||
|
id: number;
|
||||||
|
gameType: GameType;
|
||||||
|
raceTo: number;
|
||||||
|
status: GameStatus;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
log: Array<any>;
|
||||||
|
undoStack: Array<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StandardGame extends BaseGame {
|
||||||
|
player1: string;
|
||||||
|
player2: string;
|
||||||
|
player3?: string;
|
||||||
|
score1: number;
|
||||||
|
score2: number;
|
||||||
|
score3?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EndlosGame extends BaseGame {
|
||||||
|
players: Player[];
|
||||||
|
currentPlayer: number | null;
|
||||||
|
ballsOnTable: number;
|
||||||
|
winner?: string;
|
||||||
|
forfeitedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Game = StandardGame | EndlosGame;
|
||||||
|
|
||||||
|
export interface NewGameData {
|
||||||
|
player1: string;
|
||||||
|
player2: string;
|
||||||
|
player3: string;
|
||||||
|
gameType: GameType | '';
|
||||||
|
raceTo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NewGameStep = 'player1' | 'player2' | 'player3' | 'gameType' | 'raceTo' | null;
|
||||||
|
|
||||||
|
export type GameFilter = 'all' | 'active' | 'completed';
|
||||||
29
src/types/ui.ts
Normal file
29
src/types/ui.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export interface ModalState {
|
||||||
|
open: boolean;
|
||||||
|
gameId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationState {
|
||||||
|
open: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompletionModalState {
|
||||||
|
open: boolean;
|
||||||
|
game: any | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppScreen {
|
||||||
|
current: 'game-list' | 'new-game' | 'game-detail';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ButtonProps {
|
||||||
|
variant?: 'primary' | 'secondary' | 'danger';
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
disabled?: boolean;
|
||||||
|
children?: any;
|
||||||
|
onClick?: () => void;
|
||||||
|
'aria-label'?: string;
|
||||||
|
style?: any;
|
||||||
|
[key: string]: any; // Allow additional props
|
||||||
|
}
|
||||||
49
src/utils/constants.ts
Normal file
49
src/utils/constants.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { GameType } from '../types/game';
|
||||||
|
|
||||||
|
export const GAME_TYPES: Array<{ value: GameType; label: string; defaultRaceTo: number }> = [
|
||||||
|
{ value: '8-Ball', label: '8-Ball', defaultRaceTo: 5 },
|
||||||
|
{ value: '9-Ball', label: '9-Ball', defaultRaceTo: 9 },
|
||||||
|
{ value: '10-Ball', label: '10-Ball', defaultRaceTo: 10 },
|
||||||
|
{ value: '14/1 endlos', label: '14/1 Endlos', defaultRaceTo: 150 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const RACE_TO_OPTIONS = [
|
||||||
|
{ value: '5', label: 'Race to 5' },
|
||||||
|
{ value: '7', label: 'Race to 7' },
|
||||||
|
{ value: '9', label: 'Race to 9' },
|
||||||
|
{ value: '11', label: 'Race to 11' },
|
||||||
|
{ value: '15', label: 'Race to 15' },
|
||||||
|
{ value: '25', label: 'Race to 25' },
|
||||||
|
{ value: '50', label: 'Race to 50' },
|
||||||
|
{ value: '100', label: 'Race to 100' },
|
||||||
|
{ value: '150', label: 'Race to 150' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LOCAL_STORAGE_KEYS = {
|
||||||
|
GAMES: 'bscscore_games',
|
||||||
|
SETTINGS: 'bscscore_settings',
|
||||||
|
PLAYER_HISTORY: 'bscscore_player_history',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const APP_CONFIG = {
|
||||||
|
MAX_PLAYER_NAME_LENGTH: 20,
|
||||||
|
MAX_GAME_HISTORY: 100,
|
||||||
|
MAX_PLAYER_HISTORY: 50,
|
||||||
|
UNDO_STACK_SIZE: 10,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const VALIDATION_MESSAGES = {
|
||||||
|
PLAYER_NAME_REQUIRED: 'Spielername ist erforderlich',
|
||||||
|
PLAYER_NAME_TOO_LONG: `Spielername darf maximal ${APP_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen lang sein`,
|
||||||
|
GAME_TYPE_REQUIRED: 'Spieltyp muss ausgewählt werden',
|
||||||
|
RACE_TO_REQUIRED: 'Race-to Wert ist erforderlich',
|
||||||
|
RACE_TO_INVALID: 'Race-to Wert muss eine positive Zahl sein',
|
||||||
|
DUPLICATE_PLAYER_NAMES: 'Spielernamen müssen eindeutig sein',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const BREAKPOINTS = {
|
||||||
|
MOBILE: '480px',
|
||||||
|
TABLET: '768px',
|
||||||
|
DESKTOP: '1024px',
|
||||||
|
LARGE_DESKTOP: '1200px',
|
||||||
|
} as const;
|
||||||
102
src/utils/gameUtils.ts
Normal file
102
src/utils/gameUtils.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import type { Game, StandardGame, EndlosGame } from '../types/game';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game utility functions for common operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function isEndlosGame(game: Game): game is EndlosGame {
|
||||||
|
return 'players' in game;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStandardGame(game: Game): game is StandardGame {
|
||||||
|
return !('players' in game);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatGameTime(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
if (diffInHours < 1) {
|
||||||
|
return 'vor wenigen Minuten';
|
||||||
|
} else if (diffInHours < 24) {
|
||||||
|
return `vor ${Math.floor(diffInHours)} Stunde${Math.floor(diffInHours) !== 1 ? 'n' : ''}`;
|
||||||
|
} else if (diffInHours < 168) { // 7 days
|
||||||
|
const days = Math.floor(diffInHours / 24);
|
||||||
|
return `vor ${days} Tag${days !== 1 ? 'en' : ''}`;
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString('de-DE');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGameDuration(game: Game): string {
|
||||||
|
const start = new Date(game.createdAt);
|
||||||
|
const end = new Date(game.updatedAt);
|
||||||
|
const diffInMinutes = (end.getTime() - start.getTime()) / (1000 * 60);
|
||||||
|
|
||||||
|
if (diffInMinutes < 60) {
|
||||||
|
return `${Math.floor(diffInMinutes)} Min`;
|
||||||
|
} else {
|
||||||
|
const hours = Math.floor(diffInMinutes / 60);
|
||||||
|
const minutes = Math.floor(diffInMinutes % 60);
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateGameProgress(game: Game): number {
|
||||||
|
if (isEndlosGame(game)) {
|
||||||
|
const maxScore = Math.max(...game.players.map(p => p.score));
|
||||||
|
return Math.min((maxScore / game.raceTo) * 100, 100);
|
||||||
|
} else {
|
||||||
|
const scores = [game.score1, game.score2, game.score3 || 0];
|
||||||
|
const maxScore = Math.max(...scores);
|
||||||
|
return Math.min((maxScore / game.raceTo) * 100, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGameWinner(game: Game): string | null {
|
||||||
|
if (game.status !== 'completed') return null;
|
||||||
|
|
||||||
|
if ('winner' in game && game.winner) {
|
||||||
|
return game.winner;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEndlosGame(game)) {
|
||||||
|
const winner = game.players.find(player => player.score >= game.raceTo);
|
||||||
|
return winner?.name || null;
|
||||||
|
} else {
|
||||||
|
if (game.score1 >= game.raceTo) return game.player1;
|
||||||
|
if (game.score2 >= game.raceTo) return game.player2;
|
||||||
|
if (game.player3 && game.score3 && game.score3 >= game.raceTo) return game.player3;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGamePlayers(game: Game): Array<{ name: string; score: number }> {
|
||||||
|
if (isEndlosGame(game)) {
|
||||||
|
return game.players.map(player => ({
|
||||||
|
name: player.name,
|
||||||
|
score: player.score,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const players = [
|
||||||
|
{ name: game.player1, score: game.score1 },
|
||||||
|
{ name: game.player2, score: game.score2 },
|
||||||
|
];
|
||||||
|
if (game.player3) {
|
||||||
|
players.push({ name: game.player3, score: game.score3 || 0 });
|
||||||
|
}
|
||||||
|
return players;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateGameData(data: any): boolean {
|
||||||
|
return !!(
|
||||||
|
data.player1?.trim() &&
|
||||||
|
data.player2?.trim() &&
|
||||||
|
data.gameType &&
|
||||||
|
data.raceTo &&
|
||||||
|
parseInt(data.raceTo) > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/utils/validation.ts
Normal file
94
src/utils/validation.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { APP_CONFIG, VALIDATION_MESSAGES } from './constants';
|
||||||
|
import type { NewGameData } from '../types/game';
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePlayerName(name: string): ValidationResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
|
||||||
|
if (!trimmedName) {
|
||||||
|
errors.push(VALIDATION_MESSAGES.PLAYER_NAME_REQUIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedName.length > APP_CONFIG.MAX_PLAYER_NAME_LENGTH) {
|
||||||
|
errors.push(VALIDATION_MESSAGES.PLAYER_NAME_TOO_LONG);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateGameData(data: NewGameData): ValidationResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Validate player names
|
||||||
|
const player1Validation = validatePlayerName(data.player1);
|
||||||
|
const player2Validation = validatePlayerName(data.player2);
|
||||||
|
|
||||||
|
errors.push(...player1Validation.errors);
|
||||||
|
errors.push(...player2Validation.errors);
|
||||||
|
|
||||||
|
// Check for duplicate player names
|
||||||
|
const playerNames = [data.player1.trim(), data.player2.trim()];
|
||||||
|
if (data.player3?.trim()) {
|
||||||
|
const player3Validation = validatePlayerName(data.player3);
|
||||||
|
errors.push(...player3Validation.errors);
|
||||||
|
playerNames.push(data.player3.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueNames = new Set(playerNames.filter(name => name.length > 0));
|
||||||
|
if (uniqueNames.size !== playerNames.filter(name => name.length > 0).length) {
|
||||||
|
errors.push(VALIDATION_MESSAGES.DUPLICATE_PLAYER_NAMES);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate game type
|
||||||
|
if (!data.gameType?.trim()) {
|
||||||
|
errors.push(VALIDATION_MESSAGES.GAME_TYPE_REQUIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate race to
|
||||||
|
if (!data.raceTo?.trim()) {
|
||||||
|
errors.push(VALIDATION_MESSAGES.RACE_TO_REQUIRED);
|
||||||
|
} else {
|
||||||
|
const raceToNumber = parseInt(data.raceTo, 10);
|
||||||
|
if (isNaN(raceToNumber) || raceToNumber <= 0) {
|
||||||
|
errors.push(VALIDATION_MESSAGES.RACE_TO_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizePlayerName(name: string): string {
|
||||||
|
return name
|
||||||
|
.trim()
|
||||||
|
.slice(0, APP_CONFIG.MAX_PLAYER_NAME_LENGTH)
|
||||||
|
.replace(/[^\w\s-]/g, ''); // Remove special characters except spaces and hyphens
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRaceTo(value: string): ValidationResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (!value?.trim()) {
|
||||||
|
errors.push(VALIDATION_MESSAGES.RACE_TO_REQUIRED);
|
||||||
|
} else {
|
||||||
|
const numValue = parseInt(value, 10);
|
||||||
|
if (isNaN(numValue) || numValue <= 0) {
|
||||||
|
errors.push(VALIDATION_MESSAGES.RACE_TO_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
],
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "preact"
|
"jsxImportSource": "preact",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user