Refactor BSC Score to Astro, TypeScript, and modular architecture
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user