refactor: extract reusable library
- move reusable domain, data, state, ui code into src/lib - update host screens to consume new library exports - document architecture and configure path aliases - bump astro integration dependencies for compatibility Refs #30
This commit is contained in:
38
src/lib/state/README.md
Normal file
38
src/lib/state/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# State Layer (`@lib/state`)
|
||||
|
||||
Compose domain + data layers into reusable hooks. Hooks are Preact-friendly but React-compatible thanks to the astro-preact compat flag.
|
||||
|
||||
## Hooks
|
||||
|
||||
- `useGameState`
|
||||
- Loads/synchronises game collections.
|
||||
- Exposes CRUD ops (`addGame`, `updateGame`, `deleteGame`), filtering helpers, and cached player history.
|
||||
- Handles persistence errors and loading states.
|
||||
- `useNavigation`
|
||||
- Simple screen router for the 3 major views (list, new game, detail).
|
||||
- Tracks selected game id.
|
||||
- `useNewGameWizard`
|
||||
- Holds transient wizard form state and immutable steps.
|
||||
- Provides `startWizard`, `resetWizard`, `updateGameData`, `nextStep`.
|
||||
- `useModal`, `useValidationModal`, `useCompletionModal`
|
||||
- Encapsulate modal visibility state, ensuring consistent APIs across components.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { useGameState, useNavigation, useModal } from '@lib/state';
|
||||
|
||||
const gameState = useGameState();
|
||||
const navigation = useNavigation();
|
||||
const modal = useModal();
|
||||
|
||||
// gameState.games, navigation.screen, modal.openModal(), ...
|
||||
```
|
||||
|
||||
## Design Notes
|
||||
|
||||
- Hooks avoid direct DOM work—UI components receive ready-to-render props and callbacks.
|
||||
- Side effects (storage, logging) are delegated to `@lib/data`.
|
||||
- All exports are re-exported via `@lib/state/index.ts`.
|
||||
|
||||
|
||||
8
src/lib/state/index.ts
Normal file
8
src/lib/state/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { useGameState } from './useGameState';
|
||||
export {
|
||||
useModal,
|
||||
useValidationModal,
|
||||
useCompletionModal,
|
||||
} from './useModal';
|
||||
export { useNavigation, useNewGameWizard } from './useNavigation';
|
||||
|
||||
121
src/lib/state/useGameState.ts
Normal file
121
src/lib/state/useGameState.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState, useEffect, useCallback } from 'preact/hooks';
|
||||
import type { Game, NewGameData, GameFilter } from '@lib/domain/types';
|
||||
import { GameService } from '@lib/data/gameService';
|
||||
|
||||
export function useGameState() {
|
||||
const [games, setGames] = useState<Game[]>([]);
|
||||
const [filter, setFilter] = useState<GameFilter>('all');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load games from IndexedDB on mount
|
||||
useEffect(() => {
|
||||
const loadGames = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const savedGames = await GameService.loadGames();
|
||||
setGames(savedGames);
|
||||
} catch (err) {
|
||||
console.error('Failed to load games:', err);
|
||||
setError('Failed to load games from storage');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadGames();
|
||||
}, []);
|
||||
|
||||
const addGame = useCallback(async (gameData: NewGameData): Promise<number> => {
|
||||
try {
|
||||
const newGame = GameService.createGame(gameData);
|
||||
await GameService.saveGame(newGame);
|
||||
setGames(prevGames => [newGame, ...prevGames]);
|
||||
return newGame.id;
|
||||
} catch (err) {
|
||||
console.error('Failed to add game:', err);
|
||||
setError('Failed to save new game');
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateGame = useCallback(async (gameId: number, updatedGame: Game) => {
|
||||
try {
|
||||
await GameService.saveGame(updatedGame);
|
||||
setGames(prevGames =>
|
||||
prevGames.map(game => game.id === gameId ? updatedGame : game)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to update game:', err);
|
||||
setError('Failed to save game changes');
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteGame = useCallback(async (gameId: number) => {
|
||||
try {
|
||||
await GameService.deleteGame(gameId);
|
||||
setGames(prevGames => prevGames.filter(game => game.id !== gameId));
|
||||
} catch (err) {
|
||||
console.error('Failed to delete game:', err);
|
||||
setError('Failed to delete game');
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getGameById = useCallback((gameId: number): Game | undefined => {
|
||||
return games.find(game => game.id === gameId);
|
||||
}, [games]);
|
||||
|
||||
const getFilteredGames = useCallback(async (): Promise<Game[]> => {
|
||||
try {
|
||||
return await GameService.getGamesByFilter(filter);
|
||||
} catch (err) {
|
||||
console.error('Failed to get filtered games:', err);
|
||||
setError('Failed to load filtered games');
|
||||
return games.filter(game => {
|
||||
if (filter === 'active') return game.status === 'active';
|
||||
if (filter === 'completed') return game.status === 'completed';
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}, [filter, games]);
|
||||
|
||||
const getPlayerNameHistory = useCallback((): string[] => {
|
||||
// Extract player names from current games for immediate UI use
|
||||
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]);
|
||||
}, [games]);
|
||||
|
||||
return {
|
||||
games,
|
||||
filter,
|
||||
setFilter,
|
||||
loading,
|
||||
error,
|
||||
addGame,
|
||||
updateGame,
|
||||
deleteGame,
|
||||
getGameById,
|
||||
getFilteredGames,
|
||||
getPlayerNameHistory,
|
||||
};
|
||||
}
|
||||
60
src/lib/state/useModal.ts
Normal file
60
src/lib/state/useModal.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useState, useCallback } from 'preact/hooks';
|
||||
import type { ModalState, ValidationState, CompletionModalState } from '@lib/ui/types';
|
||||
import type { Game } from '@lib/domain/types';
|
||||
|
||||
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/lib/state/useNavigation.ts
Normal file
82
src/lib/state/useNavigation.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useState, useCallback } from 'preact/hooks';
|
||||
import type { NewGameStep, NewGameData } from '@lib/domain/types';
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user