This commit is contained in:
parent
3e537bc05d
commit
154646a124
19 changed files with 2989 additions and 147 deletions
32
.forgejo/workflows/docker-build.yml
Normal file
32
.forgejo/workflows/docker-build.yml
Normal file
|
@ -0,0 +1,32 @@
|
|||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main # Ou la branche sur laquelle vous pushez habituellement
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: alpine # Utilisation de votre runner
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build -t git.wilmoredynamics.com/ab/ab01:${GITHUB_SHA::8} .
|
||||
# Tag the image with 'latest'
|
||||
docker tag git.wilmoredynamics.com/ab/ab01:${GITHUB_SHA::8} git.wilmoredynamics.com/ab/ab01:latest
|
||||
|
||||
- name: Log in to Forgejo Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: git.wilmoredynamics.com
|
||||
username: AB # Votre nom d'utilisateur ou organisation Forgejo
|
||||
password: ${{ secrets.FORGEJO_TOKEN }} # Votre jeton d'accès personnel Forgejo
|
||||
|
||||
- name: Push Docker image to Forgejo Container Registry
|
||||
run: |
|
||||
docker push git.wilmoredynamics.com/ab/ab01:${GITHUB_SHA::8}
|
||||
docker push git.wilmoredynamics.com/ab/ab01:latest
|
66
dockerfile
Normal file
66
dockerfile
Normal file
|
@ -0,0 +1,66 @@
|
|||
# syntax=docker.io/docker/dockerfile:1
|
||||
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn run build; \
|
||||
elif [ -f package-lock.json ]; then npm run build; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
# ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
|
||||
# server.js is created by next build from the standalone output
|
||||
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
CMD ["node", "server.js"]
|
54
fdr.md
Normal file
54
fdr.md
Normal file
|
@ -0,0 +1,54 @@
|
|||
Absolument ! Voici la feuille de route condensée, sans la configuration du projet, la phase de tests ni le déploiement :
|
||||
|
||||
**Phase 1 : Conception et Planification**
|
||||
|
||||
1. **Définir la portée de l'application :**
|
||||
* **Jeux :** Mots croisés, Sudoku, Binero.
|
||||
* **Niveaux de difficulté :** Facile, Moyen, Difficile (et potentiellement Expert). Définir les critères pour chaque niveau de chaque jeu.
|
||||
* **Fonctionnalités :** Score par partie, progression locale (sauvegarde de l'état du jeu et des meilleurs scores).
|
||||
* **Public cible :** Tout public, avec une interface intuitive et accessible.
|
||||
* **Design :** Inspiration Dieter Rams (minimalisme, clarté, fonctionnalité) et Apple (simplicité, attention aux détails, typographie soignée).
|
||||
|
||||
2. **Choisir les technologies :**
|
||||
* Next.js, TypeScript, Material UI.
|
||||
|
||||
3. **Conception de l'interface utilisateur (UI) et de l'expérience utilisateur (UX) :**
|
||||
* **Principes de design :** Minimalisme, clarté, fonctionnalité, cohérence, attention aux détails.
|
||||
* **Wireframes :** Schémas simples des écrans clés (accueil, sélection du jeu, interface de chaque jeu).
|
||||
* **Maquettes (Mockups) :** Designs détaillés basés sur Material UI, personnalisés pour un style épuré (couleurs neutres, typographie élégante, espaces blancs).
|
||||
|
||||
4. **Planification de la structure du projet :**
|
||||
* Organisation des dossiers (`pages`, `components`, `styles`, `utils`, `types`).
|
||||
|
||||
**Phase 2 : Développement des Jeux**
|
||||
|
||||
1. **Développement du jeu de Mots Croisés :**
|
||||
* **Logique du jeu :** Gestion des niveaux de difficulté (taille de la grille, nombre de mots, complexité des définitions), génération (ou utilisation de grilles prédéfinies), vérification de la solution, calcul du score.
|
||||
* **Interface utilisateur (avec Material UI) :** Affichage de la grille, des définitions, interaction pour remplir les cases.
|
||||
|
||||
2. **Développement du jeu de Sudoku :**
|
||||
* **Logique du jeu :** Gestion des niveaux de difficulté (nombre de cases pré-remplies), génération de grilles valides, vérification de la solution, calcul du score.
|
||||
* **Interface utilisateur (avec Material UI) :** Affichage de la grille, interaction pour entrer les chiffres.
|
||||
|
||||
3. **Développement du jeu de Binero :**
|
||||
* **Logique du jeu :** Gestion des niveaux de difficulté (taille de la grille, nombre de cases initialement remplies), génération de grilles valides, vérification de la solution, calcul du score.
|
||||
* **Interface utilisateur (avec Material UI) :** Affichage de la grille, interaction pour placer des 0 ou des 1.
|
||||
|
||||
**Phase 3 : Développement des Fonctionnalités Additionnelles**
|
||||
|
||||
* **Système de score :** Implémenter la logique de calcul du score pour chaque jeu.
|
||||
* **Progression locale :** Utiliser `localStorage` pour sauvegarder l'état des parties en cours et les meilleurs scores.
|
||||
|
||||
**Phase 4 : Conception et Développement de l'Interface Utilisateur (UI)**
|
||||
|
||||
* **Thème Material UI :** Personnalisation pour refléter le style Dieter Rams et Apple (couleurs, typographie, espacements).
|
||||
* **Composants Material UI :** Utilisation judicieuse et minimaliste des composants.
|
||||
* **Mise en page :** Création d'interfaces claires et organisées avec les outils de Material UI.
|
||||
* **Typographie :** Choix d'une police élégante et lisible.
|
||||
* **Icônes :** Utilisation parcimonieuse et significative des icônes Material UI.
|
||||
|
||||
**Phase 5 : Maintenance et Améliorations**
|
||||
|
||||
* Surveillance de l'application, correction des bugs, ajout de nouvelles fonctionnalités.
|
||||
|
||||
Cette feuille de route se concentre sur les étapes essentielles de conception et de développement de votre application.
|
851
package-lock.json
generated
851
package-lock.json
generated
File diff suppressed because it is too large
Load diff
16
package.json
16
package.json
|
@ -9,19 +9,23 @@
|
|||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^7.0.2",
|
||||
"@mui/material": "^7.0.2",
|
||||
"next": "15.3.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"next": "15.3.0"
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.0",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
|
775
src/app/binero/page.tsx
Normal file
775
src/app/binero/page.tsx
Normal file
|
@ -0,0 +1,775 @@
|
|||
'use client';
|
||||
|
||||
import { Box, Container, Typography, Button, useTheme, ButtonGroup, Alert, Snackbar, IconButton, Tooltip, Paper, Divider, Dialog, DialogTitle, DialogContent, LinearProgress, Badge } from '@mui/material';
|
||||
import { BineroGrid } from '@/components/games/binero/BineroGrid';
|
||||
import { Timer } from '@/components/games/sudoku/Timer';
|
||||
import { generateBinero, validateGrid, isGridComplete, calculateScore } from '@/components/games/binero/bineroLogic';
|
||||
import { BineroRules } from '@/components/games/binero/BineroRules';
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { Help, Refresh, CheckCircle, Timer as TimerIcon, EmojiEvents, Undo, Save, Settings } from '@mui/icons-material';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
|
||||
type CellValue = 0 | 1 | null;
|
||||
type Grid = CellValue[][];
|
||||
|
||||
interface GameState {
|
||||
grid: Grid;
|
||||
difficulty: 'easy' | 'medium' | 'hard';
|
||||
isPlaying: boolean;
|
||||
score: number;
|
||||
size: number;
|
||||
mistakes: number;
|
||||
gridSize: { rows: number; cols: number };
|
||||
}
|
||||
|
||||
const DIFFICULTY_MULTIPLIER = {
|
||||
easy: 1,
|
||||
medium: 1.5,
|
||||
hard: 2,
|
||||
};
|
||||
|
||||
const GRID_SIZES = [
|
||||
{ rows: 6, cols: 6, label: '6×6' },
|
||||
{ rows: 8, cols: 8, label: '8×8' },
|
||||
{ rows: 10, cols: 10, label: '10×10' },
|
||||
{ rows: 12, cols: 12, label: '12×12' },
|
||||
{ rows: 14, cols: 14, label: '14×14' },
|
||||
];
|
||||
|
||||
export default function BineroPage() {
|
||||
const theme = useTheme();
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
const [showHints, setShowHints] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [saveCounter, setSaveCounter] = useState(0);
|
||||
const [bestScores, setBestScores] = useLocalStorage<Record<string, number>>('bineroBestScores', {
|
||||
easy: 0,
|
||||
medium: 0,
|
||||
hard: 0,
|
||||
});
|
||||
const [savedGames, setSavedGames] = useLocalStorage<Record<string, GameState>>('bineroSavedGames', {});
|
||||
const [history, setHistory] = useState<Grid[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
|
||||
const [gameState, setGameState] = useState<GameState>(() => ({
|
||||
grid: generateBinero(8, 'easy'),
|
||||
difficulty: 'easy',
|
||||
isPlaying: false,
|
||||
score: 0,
|
||||
size: 8,
|
||||
mistakes: 0,
|
||||
gridSize: { rows: 8, cols: 8 },
|
||||
}));
|
||||
|
||||
const [message, setMessage] = useState<{ text: string; severity: 'success' | 'error' | 'info' }>({
|
||||
text: '',
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
const memoizedGrid = useMemo(() => gameState.grid, [gameState.grid]);
|
||||
|
||||
const gridCompletion = useMemo(() => {
|
||||
const totalCells = gameState.grid.length * gameState.grid.length;
|
||||
const filledCells = gameState.grid.flat().filter(cell => cell !== null).length;
|
||||
return Math.round((filledCells / totalCells) * 100);
|
||||
}, [gameState.grid]);
|
||||
|
||||
const updateBestScore = useCallback((score: number) => {
|
||||
setBestScores(prev => ({
|
||||
...prev,
|
||||
[gameState.difficulty]: Math.max(score, prev[gameState.difficulty] || 0),
|
||||
}));
|
||||
}, [gameState.difficulty, setBestScores]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameState.isPlaying && gameState.score > 0) {
|
||||
updateBestScore(gameState.score);
|
||||
}
|
||||
}, [gameState.isPlaying, gameState.score, updateBestScore]);
|
||||
|
||||
const handleCellChange = useCallback((row: number, col: number, value: 0 | 1 | null) => {
|
||||
setGameState(prev => {
|
||||
const newGrid = prev.grid.map((r, i) =>
|
||||
r.map((c, j) => (i === row && j === col ? value : c))
|
||||
);
|
||||
|
||||
setHistory(prevHistory => {
|
||||
const newHistory = prevHistory.slice(0, historyIndex + 1);
|
||||
newHistory.push(JSON.parse(JSON.stringify(newGrid)));
|
||||
return newHistory;
|
||||
});
|
||||
setHistoryIndex(prev => prev + 1);
|
||||
|
||||
return { ...prev, grid: newGrid };
|
||||
});
|
||||
}, [historyIndex]);
|
||||
|
||||
const handleGameComplete = useCallback(() => {
|
||||
setGameState(prev => ({ ...prev, isPlaying: false }));
|
||||
setMessage({ text: 'Félicitations ! Vous avez terminé le Binero !', severity: 'success' });
|
||||
}, []);
|
||||
|
||||
const handleNewGame = useCallback((newDifficulty: GameState['difficulty'], newSize?: { rows: number; cols: number }) => {
|
||||
const size = newSize || gameState.gridSize;
|
||||
|
||||
setTimeout(() => {
|
||||
const newGrid = generateBinero(size.rows, newDifficulty);
|
||||
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
grid: newGrid,
|
||||
difficulty: newDifficulty,
|
||||
isPlaying: true,
|
||||
score: 0,
|
||||
mistakes: 0,
|
||||
gridSize: size,
|
||||
}));
|
||||
|
||||
setMessage({ text: `Nouvelle partie en difficulté ${newDifficulty} (${size.rows}×${size.cols})`, severity: 'info' });
|
||||
}, 0);
|
||||
}, [gameState.gridSize]);
|
||||
|
||||
const handleCheck = useCallback(() => {
|
||||
const isComplete = isGridComplete(gameState.grid);
|
||||
|
||||
if (!isComplete) {
|
||||
setMessage({
|
||||
text: 'La grille n\'est pas complète. Continuez à remplir les cases vides.',
|
||||
severity: 'info'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier les règles une par une pour donner des messages plus précis
|
||||
let errorMessage = '';
|
||||
|
||||
// Vérifier le nombre égal de 0 et 1 dans chaque ligne et colonne
|
||||
for (let i = 0; i < gameState.grid.length; i++) {
|
||||
const row = gameState.grid[i];
|
||||
const col = gameState.grid.map(r => r[i]);
|
||||
|
||||
const rowZeros = row.filter(cell => cell === 0).length;
|
||||
const rowOnes = row.filter(cell => cell === 1).length;
|
||||
const colZeros = col.filter(cell => cell === 0).length;
|
||||
const colOnes = col.filter(cell => cell === 1).length;
|
||||
|
||||
if (rowZeros !== rowOnes) {
|
||||
errorMessage = `La ligne ${i+1} n'a pas un nombre égal de 0 et de 1.`;
|
||||
break;
|
||||
}
|
||||
|
||||
if (colZeros !== colOnes) {
|
||||
errorMessage = `La colonne ${i+1} n'a pas un nombre égal de 0 et de 1.`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier les chiffres consécutifs
|
||||
if (!errorMessage) {
|
||||
for (let i = 0; i < gameState.grid.length; i++) {
|
||||
const row = gameState.grid[i];
|
||||
const col = gameState.grid.map(r => r[i]);
|
||||
|
||||
for (let j = 0; j < gameState.grid.length - 2; j++) {
|
||||
if (row[j] !== null && row[j] === row[j+1] && row[j] === row[j+2]) {
|
||||
errorMessage = `La ligne ${i+1} contient trois ${row[j]} consécutifs.`;
|
||||
break;
|
||||
}
|
||||
|
||||
if (col[j] !== null && col[j] === col[j+1] && col[j] === col[j+2]) {
|
||||
errorMessage = `La colonne ${i+1} contient trois ${col[j]} consécutifs.`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMessage) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier les lignes/colonnes identiques uniquement en mode difficile
|
||||
if (!errorMessage && gameState.difficulty === 'hard') {
|
||||
const rows = gameState.grid.map(row => row.join(','));
|
||||
const cols = gameState.grid[0].map((_, colIndex) =>
|
||||
gameState.grid.map(row => row[colIndex]).join(',')
|
||||
);
|
||||
|
||||
// Vérifier les lignes identiques
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
for (let j = i + 1; j < rows.length; j++) {
|
||||
if (rows[i] === rows[j]) {
|
||||
errorMessage = `Les lignes ${i+1} et ${j+1} sont identiques.`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (errorMessage) break;
|
||||
}
|
||||
|
||||
// Vérifier les colonnes identiques
|
||||
if (!errorMessage) {
|
||||
for (let i = 0; i < cols.length; i++) {
|
||||
for (let j = i + 1; j < cols.length; j++) {
|
||||
if (cols[i] === cols[j]) {
|
||||
errorMessage = `Les colonnes ${i+1} et ${j+1} sont identiques.`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (errorMessage) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
setGameState(prev => ({ ...prev, mistakes: prev.mistakes + 1 }));
|
||||
setMessage({
|
||||
text: `Erreur : ${errorMessage}`,
|
||||
severity: 'error'
|
||||
});
|
||||
} else {
|
||||
setMessage({
|
||||
text: 'Félicitations ! Votre grille est valide et complète.',
|
||||
severity: 'success'
|
||||
});
|
||||
handleGameComplete();
|
||||
}
|
||||
}, [gameState.grid, gameState.difficulty, handleGameComplete]);
|
||||
|
||||
const handleTimeUpdate = useCallback((seconds: number) => {
|
||||
if (!gameState.isPlaying) return;
|
||||
|
||||
const currentScore = calculateScore(
|
||||
gameState.difficulty,
|
||||
seconds,
|
||||
gameState.mistakes,
|
||||
gameState.gridSize
|
||||
);
|
||||
|
||||
setGameState(prev => ({ ...prev, score: currentScore }));
|
||||
}, [gameState.difficulty, gameState.mistakes, gameState.gridSize, gameState.isPlaying]);
|
||||
|
||||
const handleCellClick = useCallback((row: number, col: number) => {
|
||||
const currentValue = gameState.grid[row][col];
|
||||
let newValue: 0 | 1 | null = null;
|
||||
|
||||
if (currentValue === null) {
|
||||
newValue = 0;
|
||||
} else if (currentValue === 0) {
|
||||
newValue = 1;
|
||||
}
|
||||
|
||||
handleCellChange(row, col, newValue);
|
||||
}, [gameState.grid, handleCellChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isGridComplete(gameState.grid) && validateGrid(gameState.grid, gameState.difficulty)) {
|
||||
handleGameComplete();
|
||||
}
|
||||
}, [gameState.grid, gameState.difficulty, handleGameComplete]);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
if (historyIndex > 0) {
|
||||
setHistoryIndex(prev => prev - 1);
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
grid: JSON.parse(JSON.stringify(history[historyIndex - 1]))
|
||||
}));
|
||||
}
|
||||
}, [history, historyIndex]);
|
||||
|
||||
const handleSaveGame = useCallback(() => {
|
||||
const saveKey = `binero_${gameState.difficulty}_${gameState.gridSize.rows}x${gameState.gridSize.cols}_${saveCounter}`;
|
||||
setSaveCounter(prev => prev + 1);
|
||||
setSavedGames(prev => ({
|
||||
...prev,
|
||||
[saveKey]: gameState
|
||||
}));
|
||||
setMessage({ text: 'Partie sauvegardée avec succès !', severity: 'success' });
|
||||
}, [gameState, setSavedGames, saveCounter]);
|
||||
|
||||
const handleLoadGame = useCallback((savedGame: GameState) => {
|
||||
setGameState(savedGame);
|
||||
setHistory([JSON.parse(JSON.stringify(savedGame.grid))]);
|
||||
setHistoryIndex(0);
|
||||
setMessage({ text: 'Partie chargée avec succès !', severity: 'success' });
|
||||
setShowSettings(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg">
|
||||
<Box sx={{ py: { xs: 2, sm: 4 } }}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
maxWidth: { xs: '100%', sm: 800 },
|
||||
mx: 'auto',
|
||||
px: { xs: 1, sm: 0 },
|
||||
}}>
|
||||
<Box sx={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
justifyContent: 'space-between',
|
||||
alignItems: { xs: 'center', sm: 'center' },
|
||||
mb: 3,
|
||||
gap: 2,
|
||||
}}>
|
||||
<Typography
|
||||
component="h1"
|
||||
variant="h3"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.secondary.main})`,
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
textAlign: 'center',
|
||||
fontSize: { xs: '2rem', sm: '2.5rem' },
|
||||
}}
|
||||
>
|
||||
Binero
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Tooltip title="Règles du jeu">
|
||||
<IconButton onClick={() => setShowRules(!showRules)} color="primary">
|
||||
<Help />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Paramètres">
|
||||
<IconButton onClick={() => setShowSettings(!showSettings)} color="primary">
|
||||
<Settings />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Nouvelle partie">
|
||||
<IconButton onClick={() => handleNewGame(gameState.difficulty)} color="primary">
|
||||
<Refresh />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Paper elevation={0} sx={{
|
||||
p: { xs: 2, sm: 3 },
|
||||
mb: 3,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
width: '100%',
|
||||
borderRadius: '12px',
|
||||
}}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
justifyContent: 'space-between',
|
||||
alignItems: { xs: 'center', sm: 'center' },
|
||||
mb: 2,
|
||||
gap: 2,
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TimerIcon color="primary" />
|
||||
<Timer isRunning={gameState.isPlaying} onTimeUpdate={handleTimeUpdate} />
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<EmojiEvents color="primary" />
|
||||
<Typography variant="h6">
|
||||
Score: {gameState.score}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Progression: {gridCompletion}%
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Meilleur score ({gameState.difficulty}): {bestScores[gameState.difficulty] || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={gridCompletion}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.palette.grey[200],
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 4,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Box sx={{
|
||||
mb: 3,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
}}>
|
||||
<Box sx={{ width: '100%', overflowX: 'auto' }}>
|
||||
<ButtonGroup
|
||||
variant="outlined"
|
||||
size="large"
|
||||
sx={{
|
||||
width: '100%',
|
||||
'& .MuiButton-root': {
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
},
|
||||
flex: 1,
|
||||
borderWidth: '2px',
|
||||
'&.MuiButton-contained': {
|
||||
background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
},
|
||||
'&.MuiButton-outlined': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
color: theme.palette.primary.main,
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.primary.dark,
|
||||
backgroundColor: theme.palette.primary.light + '15',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={() => handleNewGame('easy')}
|
||||
variant={gameState.difficulty === 'easy' ? 'contained' : 'outlined'}
|
||||
sx={{
|
||||
borderRadius: '8px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
Facile
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleNewGame('medium')}
|
||||
variant={gameState.difficulty === 'medium' ? 'contained' : 'outlined'}
|
||||
sx={{
|
||||
borderRadius: '8px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
Moyen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleNewGame('hard')}
|
||||
variant={gameState.difficulty === 'hard' ? 'contained' : 'outlined'}
|
||||
sx={{
|
||||
borderRadius: '8px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
Difficile
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ width: '100%', overflowX: 'auto' }}>
|
||||
<ButtonGroup
|
||||
variant="outlined"
|
||||
size="large"
|
||||
sx={{
|
||||
width: '100%',
|
||||
'& .MuiButton-root': {
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
},
|
||||
flex: 1,
|
||||
borderWidth: '2px',
|
||||
'&.MuiButton-contained': {
|
||||
background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
},
|
||||
'&.MuiButton-outlined': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
color: theme.palette.primary.main,
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.primary.dark,
|
||||
backgroundColor: theme.palette.primary.light + '15',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{GRID_SIZES.map((size) => (
|
||||
<Button
|
||||
key={`${size.rows}×${size.cols}`}
|
||||
onClick={() => handleNewGame(gameState.difficulty, { rows: size.rows, cols: size.cols })}
|
||||
variant={
|
||||
gameState.gridSize.rows === size.rows && gameState.gridSize.cols === size.cols
|
||||
? 'contained'
|
||||
: 'outlined'
|
||||
}
|
||||
sx={{
|
||||
borderRadius: '8px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
{size.label}
|
||||
</Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
width: '100%',
|
||||
overflowX: 'auto',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
mb: 3,
|
||||
}}>
|
||||
<Box sx={{
|
||||
minWidth: { xs: '280px', sm: '400px' },
|
||||
maxWidth: '100%',
|
||||
}}>
|
||||
<BineroGrid
|
||||
grid={memoizedGrid}
|
||||
onCellClick={handleCellClick}
|
||||
showHints={showHints}
|
||||
difficulty={gameState.difficulty}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr 1fr', sm: 'repeat(3, 1fr)', md: 'repeat(5, 1fr)' },
|
||||
gap: 2,
|
||||
width: '100%',
|
||||
}}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => handleNewGame(gameState.difficulty)}
|
||||
startIcon={<Refresh />}
|
||||
size="large"
|
||||
sx={{
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
},
|
||||
borderRadius: '8px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.5px',
|
||||
background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
Nouvelle Partie
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={handleCheck}
|
||||
startIcon={<CheckCircle />}
|
||||
size="large"
|
||||
sx={{
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
backgroundColor: theme.palette.secondary.light + '15',
|
||||
},
|
||||
borderRadius: '8px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.5px',
|
||||
borderWidth: '2px',
|
||||
}}
|
||||
>
|
||||
Vérifier
|
||||
</Button>
|
||||
<Button
|
||||
variant={showHints ? "contained" : "outlined"}
|
||||
color="info"
|
||||
onClick={() => setShowHints(!showHints)}
|
||||
startIcon={<Help />}
|
||||
size="large"
|
||||
sx={{
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
},
|
||||
borderRadius: '8px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.5px',
|
||||
...(showHints ? {
|
||||
background: `linear-gradient(45deg, ${theme.palette.info.main}, ${theme.palette.info.dark})`,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
} : {
|
||||
borderWidth: '2px',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.info.light + '15',
|
||||
},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{showHints ? "Masquer" : "Indices"}
|
||||
</Button>
|
||||
<Tooltip title="Annuler le dernier coup">
|
||||
<span>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={handleUndo}
|
||||
startIcon={<Undo />}
|
||||
size="large"
|
||||
disabled={historyIndex <= 0}
|
||||
sx={{
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover:not(:disabled)': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
backgroundColor: theme.palette.mode === 'dark'
|
||||
? theme.palette.grey[800]
|
||||
: theme.palette.grey[200],
|
||||
},
|
||||
borderRadius: '8px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.5px',
|
||||
borderWidth: '2px',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minHeight: '48px',
|
||||
opacity: historyIndex <= 0 ? 0.5 : 1,
|
||||
'&:disabled': {
|
||||
backgroundColor: theme.palette.mode === 'dark'
|
||||
? theme.palette.grey[900]
|
||||
: theme.palette.grey[100],
|
||||
borderColor: theme.palette.mode === 'dark'
|
||||
? theme.palette.grey[700]
|
||||
: theme.palette.grey[300],
|
||||
},
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Sauvegarder la partie">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={handleSaveGame}
|
||||
size="large"
|
||||
sx={{
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
backgroundColor: theme.palette.mode === 'dark'
|
||||
? `${theme.palette.primary.main}33`
|
||||
: theme.palette.primary.light + '15',
|
||||
borderColor: theme.palette.mode === 'dark'
|
||||
? theme.palette.primary.light
|
||||
: theme.palette.primary.main,
|
||||
},
|
||||
borderRadius: '8px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.5px',
|
||||
borderWidth: '1px',
|
||||
borderColor: theme.palette.mode === 'dark'
|
||||
? theme.palette.primary.main + '80'
|
||||
: theme.palette.primary.main,
|
||||
color: theme.palette.mode === 'dark'
|
||||
? theme.palette.primary.light
|
||||
: theme.palette.primary.main,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minHeight: '48px',
|
||||
backgroundColor: 'transparent',
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
>
|
||||
Sauvegarder
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Dialog
|
||||
open={showRules}
|
||||
onClose={() => setShowRules(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Règles du Binero</DialogTitle>
|
||||
<DialogContent>
|
||||
<BineroRules />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={showSettings}
|
||||
onClose={() => setShowSettings(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Paramètres et parties sauvegardées</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="h6" sx={{ mt: 2, mb: 1 }}>Parties sauvegardées</Typography>
|
||||
{Object.keys(savedGames).length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Aucune partie sauvegardée.
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{Object.entries(savedGames).map(([key, game]) => (
|
||||
<Paper
|
||||
key={key}
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle1">
|
||||
{game.difficulty.charAt(0).toUpperCase() + game.difficulty.slice(1)} ({game.gridSize.rows}×{game.gridSize.cols})
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Score: {game.score} | Progression: {Math.round((game.grid.flat().filter(cell => cell !== null).length / (game.gridSize.rows * game.gridSize.cols)) * 100)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => handleLoadGame(game)}
|
||||
>
|
||||
Charger
|
||||
</Button>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Snackbar
|
||||
open={!!message.text}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setMessage({ text: '', severity: 'info' })}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity={message.severity} sx={{ width: '100%' }}>
|
||||
{message.text}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
|
@ -1,6 +1,9 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ColorModeProvider } from '@/context/ColorModeContext';
|
||||
import { Navbar } from '@/components/Navbar';
|
||||
import { CssBaseline } from '@mui/material';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
|
@ -13,21 +16,23 @@ const geistMono = Geist_Mono({
|
|||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Logica - Jeux de Logique",
|
||||
description: "Collection de jeux de logique et de réflexion",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<html lang="fr">
|
||||
<body>
|
||||
<ColorModeProvider>
|
||||
<CssBaseline />
|
||||
<Navbar />
|
||||
{children}
|
||||
</ColorModeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
201
src/app/page.tsx
201
src/app/page.tsx
|
@ -1,103 +1,112 @@
|
|||
import Image from "next/image";
|
||||
'use client';
|
||||
|
||||
import { Box, Container, Typography, Grid, Card, CardContent, CardActions, Button, useTheme } from '@mui/material';
|
||||
import { Extension, Grid3x3, GridOn } from '@mui/icons-material';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
const theme = useTheme();
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
const games = [
|
||||
{
|
||||
title: 'Mots Croisés',
|
||||
description: 'Testez votre vocabulaire et votre esprit de déduction avec nos grilles de mots croisés personnalisées.',
|
||||
icon: <Extension sx={{ fontSize: 40 }} />,
|
||||
href: '/mots-croises',
|
||||
},
|
||||
{
|
||||
title: 'Sudoku',
|
||||
description: 'Relevez le défi des chiffres avec nos grilles de Sudoku de différents niveaux de difficulté.',
|
||||
icon: <Grid3x3 sx={{ fontSize: 40 }} />,
|
||||
href: '/sudoku',
|
||||
},
|
||||
{
|
||||
title: 'Binero',
|
||||
description: 'Explorez la logique binaire avec ce puzzle captivant de 0 et de 1.',
|
||||
icon: <GridOn sx={{ fontSize: 40 }} />,
|
||||
href: '/binero',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: 'calc(100vh - 64px)',
|
||||
pt: 8,
|
||||
pb: 6,
|
||||
background: theme.palette.background.default,
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<Box textAlign="center" mb={8}>
|
||||
<Typography
|
||||
component="h1"
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
mb: 2,
|
||||
background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.secondary.main})`,
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
Bienvenue sur Logica
|
||||
</Typography>
|
||||
<Typography variant="h5" color="text.secondary" paragraph>
|
||||
Exercez votre esprit avec notre collection de jeux de logique
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(2, 1fr)',
|
||||
md: 'repeat(3, 1fr)',
|
||||
},
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
{games.map((game) => (
|
||||
<Box key={game.title}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ flexGrow: 1, textAlign: 'center' }}>
|
||||
<Box sx={{ mb: 2, color: 'primary.main' }}>{game.icon}</Box>
|
||||
<Typography gutterBottom variant="h5" component="h2">
|
||||
{game.title}
|
||||
</Typography>
|
||||
<Typography color="text.secondary">{game.description}</Typography>
|
||||
</CardContent>
|
||||
<CardActions sx={{ justifyContent: 'center', pb: 2 }}>
|
||||
<Button
|
||||
component={Link}
|
||||
href={game.href}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{
|
||||
borderRadius: '20px',
|
||||
px: 4,
|
||||
}}
|
||||
>
|
||||
Jouer
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
197
src/app/sudoku/page.tsx
Normal file
197
src/app/sudoku/page.tsx
Normal file
|
@ -0,0 +1,197 @@
|
|||
'use client';
|
||||
|
||||
import { Box, Container, Typography, Button, useTheme, ButtonGroup, Alert, Snackbar } from '@mui/material';
|
||||
import { SudokuGrid } from '@/components/games/sudoku/SudokuGrid';
|
||||
import { Timer } from '@/components/games/sudoku/Timer';
|
||||
import { generateSudoku, validateGrid, isGridComplete } from '@/components/games/sudoku/sudokuLogic';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
interface GameState {
|
||||
grid: (number | null)[][];
|
||||
difficulty: 'easy' | 'medium' | 'hard';
|
||||
isPlaying: boolean;
|
||||
score: number;
|
||||
bestScores: Record<string, number>;
|
||||
}
|
||||
|
||||
const DIFFICULTY_MULTIPLIER = {
|
||||
easy: 1,
|
||||
medium: 2,
|
||||
hard: 3,
|
||||
};
|
||||
|
||||
const calculateScore = (difficulty: 'easy' | 'medium' | 'hard', timeInSeconds: number): number => {
|
||||
const baseScore = 1000;
|
||||
const timeMultiplier = Math.max(0, 1 - timeInSeconds / 3600); // 1 heure max
|
||||
return Math.round(baseScore * DIFFICULTY_MULTIPLIER[difficulty] * timeMultiplier);
|
||||
};
|
||||
|
||||
export default function SudokuPage() {
|
||||
const theme = useTheme();
|
||||
const [gameState, setGameState] = useState<GameState>(() => {
|
||||
const savedState = localStorage.getItem('sudokuGameState');
|
||||
const savedBestScores = localStorage.getItem('sudokuBestScores');
|
||||
|
||||
return {
|
||||
grid: generateSudoku('easy'),
|
||||
difficulty: 'easy',
|
||||
isPlaying: false,
|
||||
score: 0,
|
||||
bestScores: savedBestScores ? JSON.parse(savedBestScores) : { easy: 0, medium: 0, hard: 0 },
|
||||
};
|
||||
});
|
||||
|
||||
const [message, setMessage] = useState<{ text: string; severity: 'success' | 'error' | 'info' }>({ text: '', severity: 'info' });
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sudokuBestScores', JSON.stringify(gameState.bestScores));
|
||||
}, [gameState.bestScores]);
|
||||
|
||||
const handleCellChange = (row: number, col: number, value: number | null) => {
|
||||
const newGrid = gameState.grid.map((r, i) =>
|
||||
r.map((c, j) => (i === row && j === col ? value : c))
|
||||
);
|
||||
|
||||
setGameState(prev => ({ ...prev, grid: newGrid }));
|
||||
|
||||
if (isGridComplete(newGrid)) {
|
||||
handleGameComplete();
|
||||
}
|
||||
};
|
||||
|
||||
const handleGameComplete = () => {
|
||||
setGameState(prev => ({ ...prev, isPlaying: false }));
|
||||
setMessage({ text: 'Félicitations ! Vous avez terminé le Sudoku !', severity: 'success' });
|
||||
};
|
||||
|
||||
const handleNewGame = useCallback((newDifficulty: GameState['difficulty']) => {
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
grid: generateSudoku(newDifficulty),
|
||||
difficulty: newDifficulty,
|
||||
isPlaying: true,
|
||||
score: 0,
|
||||
}));
|
||||
setMessage({ text: `Nouvelle partie en difficulté ${newDifficulty}`, severity: 'info' });
|
||||
}, []);
|
||||
|
||||
const handleCheck = () => {
|
||||
if (validateGrid(gameState.grid)) {
|
||||
setMessage({ text: 'La grille est valide, continuez !', severity: 'success' });
|
||||
} else {
|
||||
setMessage({ text: 'Il y a des erreurs dans la grille', severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeUpdate = (seconds: number) => {
|
||||
const currentScore = calculateScore(gameState.difficulty, seconds);
|
||||
setGameState(prev => ({ ...prev, score: currentScore }));
|
||||
};
|
||||
|
||||
const updateBestScore = useCallback((score: number) => {
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
bestScores: {
|
||||
...prev.bestScores,
|
||||
[prev.difficulty]: Math.max(score, prev.bestScores[prev.difficulty] || 0),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameState.isPlaying && gameState.score > 0) {
|
||||
updateBestScore(gameState.score);
|
||||
}
|
||||
}, [gameState.isPlaying, gameState.score, updateBestScore]);
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg">
|
||||
<Box sx={{ py: 4 }}>
|
||||
<Typography
|
||||
component="h1"
|
||||
variant="h3"
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
mb: 4,
|
||||
fontWeight: 600,
|
||||
background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.secondary.main})`,
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
Sudoku
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ maxWidth: 600, mx: 'auto' }}>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Timer isRunning={gameState.isPlaying} onTimeUpdate={handleTimeUpdate} />
|
||||
<Typography variant="h6">
|
||||
Score: {gameState.score}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="subtitle1" textAlign="center" color="text.secondary">
|
||||
Meilleur score ({gameState.difficulty}): {gameState.bestScores[gameState.difficulty] || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'center' }}>
|
||||
<ButtonGroup variant="outlined">
|
||||
<Button
|
||||
onClick={() => handleNewGame('easy')}
|
||||
variant={gameState.difficulty === 'easy' ? 'contained' : 'outlined'}
|
||||
>
|
||||
Facile
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleNewGame('medium')}
|
||||
variant={gameState.difficulty === 'medium' ? 'contained' : 'outlined'}
|
||||
>
|
||||
Moyen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleNewGame('hard')}
|
||||
variant={gameState.difficulty === 'hard' ? 'contained' : 'outlined'}
|
||||
>
|
||||
Difficile
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
|
||||
<SudokuGrid
|
||||
initialGrid={gameState.grid}
|
||||
onCellChange={handleCellChange}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center', mt: 4 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => handleNewGame(gameState.difficulty)}
|
||||
>
|
||||
Nouvelle Partie
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={handleCheck}
|
||||
>
|
||||
Vérifier
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Snackbar
|
||||
open={!!message.text}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setMessage({ text: '', severity: 'info' })}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity={message.severity} sx={{ width: '100%' }}>
|
||||
{message.text}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
);
|
||||
}
|
93
src/components/Navbar.tsx
Normal file
93
src/components/Navbar.tsx
Normal file
|
@ -0,0 +1,93 @@
|
|||
'use client';
|
||||
|
||||
import { AppBar, Toolbar, Typography, IconButton, useTheme, Box, Drawer, List, ListItem, ListItemIcon, ListItemText } from '@mui/material';
|
||||
import { Brightness4, Brightness7, Menu, Extension, Grid3x3, GridOn } from '@mui/icons-material';
|
||||
import { useContext, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ColorModeContext } from '@/context/ColorModeContext';
|
||||
|
||||
export const Navbar = () => {
|
||||
const theme = useTheme();
|
||||
const colorMode = useContext(ColorModeContext);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const menuItems = [
|
||||
{ title: 'Mots Croisés', icon: <Extension />, href: '/mots-croises' },
|
||||
{ title: 'Sudoku', icon: <Grid3x3 />, href: '/sudoku' },
|
||||
{ title: 'Binero', icon: <GridOn />, href: '/binero' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppBar position="sticky">
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
size="large"
|
||||
edge="start"
|
||||
color="inherit"
|
||||
aria-label="menu"
|
||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
>
|
||||
<Menu />
|
||||
</IconButton>
|
||||
|
||||
<Typography
|
||||
variant="h6"
|
||||
component={Link}
|
||||
href="/"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
fontWeight: 600,
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
LOGICA
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: { xs: 'none', sm: 'flex' }, gap: 2, alignItems: 'center' }}>
|
||||
{menuItems.map((item) => (
|
||||
<Typography
|
||||
key={item.title}
|
||||
component={Link}
|
||||
href={item.href}
|
||||
sx={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
{item.title}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<IconButton onClick={colorMode.toggleColorMode} color="inherit">
|
||||
{theme.palette.mode === 'dark' ? <Brightness7 /> : <Brightness4 />}
|
||||
</IconButton>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Drawer
|
||||
anchor="left"
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
>
|
||||
<List sx={{ width: 250 }}>
|
||||
{menuItems.map((item) => (
|
||||
<ListItem
|
||||
key={item.title}
|
||||
component={Link}
|
||||
href={item.href}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
sx={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<ListItemIcon sx={{ color: 'primary.main' }}>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={item.title} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
161
src/components/games/binero/BineroGrid.tsx
Normal file
161
src/components/games/binero/BineroGrid.tsx
Normal file
|
@ -0,0 +1,161 @@
|
|||
'use client';
|
||||
|
||||
import { Box, Paper, Typography, useTheme } from '@mui/material';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
|
||||
type CellValue = 0 | 1 | null;
|
||||
type Grid = CellValue[][];
|
||||
|
||||
interface BineroGridProps {
|
||||
grid: Grid;
|
||||
onCellClick: (row: number, col: number) => void;
|
||||
showHints?: boolean;
|
||||
difficulty: 'easy' | 'medium' | 'hard';
|
||||
}
|
||||
|
||||
// Composant de cellule mémorisé pour éviter les re-rendus inutiles
|
||||
const BineroCell = memo(({
|
||||
value,
|
||||
isConflict,
|
||||
showHints,
|
||||
onClick,
|
||||
theme
|
||||
}: {
|
||||
value: CellValue;
|
||||
isConflict: boolean;
|
||||
showHints: boolean;
|
||||
onClick: () => void;
|
||||
theme: any;
|
||||
}) => (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
cursor: 'pointer',
|
||||
backgroundColor: value === null
|
||||
? theme.palette.background.default
|
||||
: isConflict && showHints
|
||||
? theme.palette.error.light
|
||||
: theme.palette.background.paper,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: value === null
|
||||
? 'transparent'
|
||||
: isConflict && showHints
|
||||
? theme.palette.error.main
|
||||
: theme.palette.text.primary,
|
||||
}}
|
||||
>
|
||||
{value === null ? '·' : value}
|
||||
</Typography>
|
||||
</Box>
|
||||
));
|
||||
|
||||
BineroCell.displayName = 'BineroCell';
|
||||
|
||||
export const BineroGrid = memo(({ grid, onCellClick, showHints = false, difficulty }: BineroGridProps) => {
|
||||
const theme = useTheme();
|
||||
const size = grid.length;
|
||||
|
||||
// Vérifie si une cellule est en conflit avec les règles
|
||||
const isCellInConflict = useCallback((row: number, col: number): boolean => {
|
||||
if (grid[row][col] === null) return false;
|
||||
|
||||
const value = grid[row][col];
|
||||
const rowValues = grid[row];
|
||||
const colValues = grid.map((r: CellValue[]) => r[col]);
|
||||
|
||||
// Vérifier les chiffres consécutifs horizontalement
|
||||
if (col >= 2 && rowValues[col - 1] === value && rowValues[col - 2] === value) return true;
|
||||
if (col <= size - 3 && rowValues[col + 1] === value && rowValues[col + 2] === value) return true;
|
||||
if (col > 0 && col < size - 1 && rowValues[col - 1] === value && rowValues[col + 1] === value) return true;
|
||||
|
||||
// Vérifier les chiffres consécutifs verticalement
|
||||
if (row >= 2 && colValues[row - 1] === value && colValues[row - 2] === value) return true;
|
||||
if (row <= size - 3 && colValues[row + 1] === value && colValues[row + 2] === value) return true;
|
||||
if (row > 0 && row < size - 1 && colValues[row - 1] === value && colValues[row + 1] === value) return true;
|
||||
|
||||
// Vérifier le nombre de 0 et 1 dans la ligne
|
||||
const rowZeros = rowValues.filter((cell: CellValue) => cell === 0).length;
|
||||
const rowOnes = rowValues.filter((cell: CellValue) => cell === 1).length;
|
||||
const maxAllowed = Math.ceil(size / 2);
|
||||
if (rowZeros > maxAllowed || rowOnes > maxAllowed) return true;
|
||||
|
||||
// Vérifier le nombre de 0 et 1 dans la colonne
|
||||
const colZeros = colValues.filter((cell: CellValue) => cell === 0).length;
|
||||
const colOnes = colValues.filter((cell: CellValue) => cell === 1).length;
|
||||
if (colZeros > maxAllowed || colOnes > maxAllowed) return true;
|
||||
|
||||
// Vérifier les lignes/colonnes identiques uniquement en mode difficile
|
||||
if (difficulty === 'hard') {
|
||||
const rowStr = rowValues.join(',');
|
||||
const colStr = colValues.join(',');
|
||||
|
||||
// Vérifier si cette ligne est identique à une autre ligne
|
||||
for (let i = 0; i < size; i++) {
|
||||
if (i !== row) {
|
||||
const otherRowStr = grid[i].join(',');
|
||||
if (rowStr === otherRowStr) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier si cette colonne est identique à une autre colonne
|
||||
for (let i = 0; i < size; i++) {
|
||||
if (i !== col) {
|
||||
const otherColStr = grid.map((r: CellValue[]) => r[i]).join(',');
|
||||
if (colStr === otherColStr) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [grid, size, difficulty]);
|
||||
|
||||
// Mémoriser la grille pour éviter les recalculs inutiles
|
||||
const gridStyle = useMemo(() => ({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${size}, 1fr)`,
|
||||
gap: 1,
|
||||
justifyContent: 'center',
|
||||
}), [size]);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'inline-block',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
<Box sx={gridStyle}>
|
||||
{grid.map((row: CellValue[], rowIndex: number) =>
|
||||
row.map((cell: CellValue, colIndex: number) => (
|
||||
<BineroCell
|
||||
key={`${rowIndex}-${colIndex}`}
|
||||
value={cell}
|
||||
isConflict={isCellInConflict(rowIndex, colIndex)}
|
||||
showHints={showHints}
|
||||
onClick={() => onCellClick(rowIndex, colIndex)}
|
||||
theme={theme}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
BineroGrid.displayName = 'BineroGrid';
|
30
src/components/games/binero/BineroRules.tsx
Normal file
30
src/components/games/binero/BineroRules.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
'use client';
|
||||
|
||||
import { Box, Typography, List, ListItem, ListItemIcon, ListItemText, Paper, useTheme } from '@mui/material';
|
||||
import { Check, Close, Info } from '@mui/icons-material';
|
||||
|
||||
export const BineroRules = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Box sx={{ py: 2 }}>
|
||||
<Typography variant="body1" paragraph>
|
||||
Le Binero est un jeu de logique où vous devez remplir une grille avec des 0 et des 1 en respectant trois règles :
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ pl: 2, mb: 2 }}>
|
||||
<Typography component="li" variant="body1" paragraph>
|
||||
Chaque ligne et chaque colonne doit contenir un nombre égal de 0 et de 1.
|
||||
</Typography>
|
||||
<Typography component="li" variant="body1" paragraph>
|
||||
Il ne peut pas y avoir plus de deux chiffres identiques côte à côte (horizontalement ou verticalement).
|
||||
</Typography>
|
||||
<Typography component="li" variant="body1" paragraph>
|
||||
Deux lignes ou deux colonnes ne peuvent pas être identiques.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Cliquez sur une case pour alterner entre 0, 1 et vide. Double-cliquez pour effacer la valeur.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
194
src/components/games/binero/bineroLogic.ts
Normal file
194
src/components/games/binero/bineroLogic.ts
Normal file
|
@ -0,0 +1,194 @@
|
|||
type CellValue = 0 | 1 | null;
|
||||
type Grid = CellValue[][];
|
||||
|
||||
interface GridSize {
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
// Vérifie si une ligne ou colonne a un nombre égal de 0 et 1
|
||||
const hasEqualZerosAndOnes = (line: CellValue[]): boolean => {
|
||||
// Ne vérifier que si la ligne est complète
|
||||
if (line.some(cell => cell === null)) {
|
||||
return true; // Si la ligne n'est pas complète, on considère qu'elle est valide
|
||||
}
|
||||
|
||||
const zeros = line.filter(cell => cell === 0).length;
|
||||
const ones = line.filter(cell => cell === 1).length;
|
||||
return zeros === ones;
|
||||
};
|
||||
|
||||
// Vérifie s'il y a plus de deux chiffres identiques consécutifs
|
||||
const hasMoreThanTwoConsecutive = (line: CellValue[]): boolean => {
|
||||
// Ne vérifier que si la ligne est complète
|
||||
if (line.some(cell => cell === null)) {
|
||||
return false; // Si la ligne n'est pas complète, on ne vérifie pas cette règle
|
||||
}
|
||||
|
||||
for (let i = 0; i < line.length - 2; i++) {
|
||||
// Vérifier trois cellules consécutives
|
||||
if (line[i] !== null && line[i] === line[i + 1] && line[i] === line[i + 2]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Vérifie si deux lignes ou colonnes sont identiques
|
||||
const hasDuplicateLines = (grid: Grid): boolean => {
|
||||
// Ne vérifier que si la grille est complète
|
||||
if (grid.some(row => row.some(cell => cell === null))) {
|
||||
return false; // Si la grille n'est pas complète, on ne vérifie pas cette règle
|
||||
}
|
||||
|
||||
// Convertir les lignes et colonnes en chaînes de caractères pour la comparaison
|
||||
const rows = grid.map(row => row.join(','));
|
||||
const cols = grid[0].map((_, colIndex) =>
|
||||
grid.map(row => row[colIndex]).join(',')
|
||||
);
|
||||
|
||||
// Vérifier si le nombre de lignes/colonnes uniques est égal au nombre total
|
||||
// Si ce n'est pas le cas, il y a des doublons
|
||||
return new Set(rows).size !== rows.length || new Set(cols).size !== cols.length;
|
||||
};
|
||||
|
||||
// Génère une grille valide selon le niveau de difficulté
|
||||
export const generateBinero = (size: number, difficulty: 'easy' | 'medium' | 'hard'): Grid => {
|
||||
const grid: Grid = Array(size).fill(null).map(() => Array(size).fill(null));
|
||||
|
||||
// Nombre de cases pré-remplies selon la difficulté
|
||||
const filledCells = {
|
||||
easy: Math.floor(size * size * 0.3),
|
||||
medium: Math.floor(size * size * 0.2),
|
||||
hard: Math.floor(size * size * 0.1),
|
||||
}[difficulty];
|
||||
|
||||
// Fonction pour vérifier si une valeur peut être placée
|
||||
const canPlaceValue = (row: number, col: number, value: 0 | 1): boolean => {
|
||||
// Vérifier les chiffres consécutifs horizontalement
|
||||
if (col >= 2 && grid[row][col - 1] === value && grid[row][col - 2] === value) return false;
|
||||
if (col <= size - 3 && grid[row][col + 1] === value && grid[row][col + 2] === value) return false;
|
||||
if (col > 0 && col < size - 1 && grid[row][col - 1] === value && grid[row][col + 1] === value) return false;
|
||||
|
||||
// Vérifier les chiffres consécutifs verticalement
|
||||
if (row >= 2 && grid[row - 1][col] === value && grid[row - 2][col] === value) return false;
|
||||
if (row <= size - 3 && grid[row + 1][col] === value && grid[row + 2][col] === value) return false;
|
||||
if (row > 0 && row < size - 1 && grid[row - 1][col] === value && grid[row + 1][col] === value) return false;
|
||||
|
||||
// Vérifier le nombre de 0 et 1 dans la ligne
|
||||
const rowZeros = grid[row].filter(cell => cell === 0).length + (value === 0 ? 1 : 0);
|
||||
const rowOnes = grid[row].filter(cell => cell === 1).length + (value === 1 ? 1 : 0);
|
||||
if (rowZeros > size / 2 || rowOnes > size / 2) return false;
|
||||
|
||||
// Vérifier le nombre de 0 et 1 dans la colonne
|
||||
const colZeros = grid.map(r => r[col]).filter(cell => cell === 0).length + (value === 0 ? 1 : 0);
|
||||
const colOnes = grid.map(r => r[col]).filter(cell => cell === 1).length + (value === 1 ? 1 : 0);
|
||||
if (colZeros > size / 2 || colOnes > size / 2) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Fonction pour résoudre la grille
|
||||
const solve = (row: number = 0, col: number = 0): boolean => {
|
||||
if (col === size) {
|
||||
col = 0;
|
||||
row++;
|
||||
}
|
||||
if (row === size) return true;
|
||||
|
||||
if (grid[row][col] !== null) {
|
||||
return solve(row, col + 1);
|
||||
}
|
||||
|
||||
const values: (0 | 1)[] = Math.random() < 0.5 ? [0, 1] : [1, 0];
|
||||
for (const value of values) {
|
||||
if (canPlaceValue(row, col, value)) {
|
||||
grid[row][col] = value;
|
||||
if (solve(row, col + 1)) return true;
|
||||
grid[row][col] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Générer une solution valide
|
||||
solve();
|
||||
|
||||
// Retirer des cellules aléatoirement selon la difficulté
|
||||
const cellsToKeep = size * size - filledCells;
|
||||
let removed = 0;
|
||||
|
||||
while (removed < filledCells) {
|
||||
const row = Math.floor(Math.random() * size);
|
||||
const col = Math.floor(Math.random() * size);
|
||||
if (grid[row][col] !== null) {
|
||||
grid[row][col] = null;
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
return grid;
|
||||
};
|
||||
|
||||
// Vérifie si la grille est valide selon toutes les règles
|
||||
export const validateGrid = (grid: Grid, difficulty: 'easy' | 'medium' | 'hard'): boolean => {
|
||||
// Vérifier si la grille est complète
|
||||
if (!isGridComplete(grid)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifier chaque ligne et colonne
|
||||
for (let i = 0; i < grid.length; i++) {
|
||||
const row = grid[i];
|
||||
const col = grid.map(r => r[i]);
|
||||
|
||||
// Vérifier le nombre égal de 0 et 1
|
||||
if (!hasEqualZerosAndOnes(row) || !hasEqualZerosAndOnes(col)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifier les chiffres consécutifs
|
||||
if (hasMoreThanTwoConsecutive(row) || hasMoreThanTwoConsecutive(col)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier les lignes/colonnes identiques uniquement en mode difficile
|
||||
if (difficulty === 'hard' && hasDuplicateLines(grid)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Vérifie si la grille est complète
|
||||
export const isGridComplete = (grid: Grid): boolean => {
|
||||
return grid.every(row => row.every(cell => cell !== null));
|
||||
};
|
||||
|
||||
// Calcule le score en fonction du temps et des erreurs
|
||||
export const calculateScore = (
|
||||
difficulty: 'easy' | 'medium' | 'hard',
|
||||
timeInSeconds: number,
|
||||
mistakes: number,
|
||||
gridSize: GridSize
|
||||
): number => {
|
||||
const baseScore = 1000;
|
||||
const sizeMultiplier = (gridSize.rows * gridSize.cols) / 64; // 8x8 comme référence
|
||||
const timeMultiplier = Math.max(0, 1 - timeInSeconds / 1800); // 30 minutes max
|
||||
const mistakePenalty = Math.max(0, 1 - (mistakes * 0.1)); // -10% par erreur
|
||||
const difficultyMultiplier = {
|
||||
easy: 1,
|
||||
medium: 1.5,
|
||||
hard: 2,
|
||||
}[difficulty];
|
||||
|
||||
return Math.round(
|
||||
baseScore *
|
||||
sizeMultiplier *
|
||||
difficultyMultiplier *
|
||||
timeMultiplier *
|
||||
mistakePenalty
|
||||
);
|
||||
};
|
76
src/components/games/sudoku/SudokuGrid.tsx
Normal file
76
src/components/games/sudoku/SudokuGrid.tsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
'use client';
|
||||
|
||||
import { Box, TextField, useTheme } from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface SudokuGridProps {
|
||||
initialGrid: (number | null)[][];
|
||||
onCellChange: (row: number, col: number, value: number | null) => void;
|
||||
}
|
||||
|
||||
export const SudokuGrid = ({ initialGrid, onCellChange }: SudokuGridProps) => {
|
||||
const theme = useTheme();
|
||||
const [grid, setGrid] = useState(initialGrid);
|
||||
|
||||
const handleChange = (row: number, col: number, value: string) => {
|
||||
const numValue = value === '' ? null : parseInt(value);
|
||||
if (numValue === null || (numValue >= 1 && numValue <= 9)) {
|
||||
const newGrid = grid.map((r, i) =>
|
||||
r.map((c, j) => (i === row && j === col ? numValue : c))
|
||||
);
|
||||
setGrid(newGrid);
|
||||
onCellChange(row, col, numValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(9, 1fr)',
|
||||
gap: 0.5,
|
||||
width: '100%',
|
||||
maxWidth: '500px',
|
||||
margin: '0 auto',
|
||||
'& > div': {
|
||||
aspectRatio: '1/1',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{grid.map((row, rowIndex) =>
|
||||
row.map((cell, colIndex) => (
|
||||
<TextField
|
||||
key={`${rowIndex}-${colIndex}`}
|
||||
value={cell || ''}
|
||||
onChange={(e) => handleChange(rowIndex, colIndex, e.target.value)}
|
||||
inputProps={{
|
||||
maxLength: 1,
|
||||
style: {
|
||||
padding: 0,
|
||||
textAlign: 'center',
|
||||
fontSize: '1.2rem',
|
||||
height: '100%',
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
height: '100%',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
borderRadius: 0,
|
||||
borderRight: (colIndex + 1) % 3 === 0 ? 2 : 1,
|
||||
borderBottom: (rowIndex + 1) % 3 === 0 ? 2 : 1,
|
||||
borderColor: theme.palette.divider,
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
},
|
||||
'&.Mui-focused': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
72
src/components/games/sudoku/Timer.tsx
Normal file
72
src/components/games/sudoku/Timer.tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
|
||||
interface TimerProps {
|
||||
isRunning: boolean;
|
||||
onTimeUpdate?: (seconds: number) => void;
|
||||
}
|
||||
|
||||
export const Timer = ({ isRunning, onTimeUpdate }: TimerProps) => {
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
const intervalRef = useRef<NodeJS.Timeout>();
|
||||
const lastUpdateRef = useRef<number>(0);
|
||||
const isFirstRender = useRef(true);
|
||||
const secondsRef = useRef(0);
|
||||
|
||||
const updateTimer = useCallback(() => {
|
||||
const now = Date.now();
|
||||
const delta = Math.floor((now - lastUpdateRef.current) / 1000);
|
||||
if (delta > 0) {
|
||||
lastUpdateRef.current = now;
|
||||
secondsRef.current += delta;
|
||||
setSeconds(secondsRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
lastUpdateRef.current = Date.now();
|
||||
intervalRef.current = setInterval(updateTimer, 1000);
|
||||
} else {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [isRunning, updateTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onTimeUpdate) {
|
||||
onTimeUpdate(secondsRef.current);
|
||||
}
|
||||
}, [seconds, onTimeUpdate]);
|
||||
|
||||
const formatTime = (totalSeconds: number) => {
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Typography variant="h6" component="div">
|
||||
{formatTime(seconds)}
|
||||
</Typography>
|
||||
);
|
||||
};
|
148
src/components/games/sudoku/sudokuLogic.ts
Normal file
148
src/components/games/sudoku/sudokuLogic.ts
Normal file
|
@ -0,0 +1,148 @@
|
|||
type Grid = (number | null)[][];
|
||||
|
||||
// Vérifie si un nombre peut être placé dans une cellule
|
||||
const isValid = (grid: Grid, row: number, col: number, num: number): boolean => {
|
||||
// Vérifie la ligne
|
||||
for (let x = 0; x < 9; x++) {
|
||||
if (grid[row][x] === num) return false;
|
||||
}
|
||||
|
||||
// Vérifie la colonne
|
||||
for (let x = 0; x < 9; x++) {
|
||||
if (grid[x][col] === num) return false;
|
||||
}
|
||||
|
||||
// Vérifie le bloc 3x3
|
||||
const startRow = Math.floor(row / 3) * 3;
|
||||
const startCol = Math.floor(col / 3) * 3;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
for (let j = 0; j < 3; j++) {
|
||||
if (grid[i + startRow][j + startCol] === num) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Résout une grille de Sudoku
|
||||
const solveSudoku = (grid: Grid): boolean => {
|
||||
let row = 0;
|
||||
let col = 0;
|
||||
let isEmpty = false;
|
||||
|
||||
// Trouve une cellule vide
|
||||
for (let i = 0; i < 9; i++) {
|
||||
for (let j = 0; j < 9; j++) {
|
||||
if (grid[i][j] === null) {
|
||||
row = i;
|
||||
col = j;
|
||||
isEmpty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isEmpty) break;
|
||||
}
|
||||
|
||||
// Si toutes les cellules sont remplies, la grille est résolue
|
||||
if (!isEmpty) return true;
|
||||
|
||||
// Essaie les chiffres 1-9
|
||||
for (let num = 1; num <= 9; num++) {
|
||||
if (isValid(grid, row, col, num)) {
|
||||
grid[row][col] = num;
|
||||
if (solveSudoku(grid)) return true;
|
||||
grid[row][col] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Génère une nouvelle grille de Sudoku
|
||||
export const generateSudoku = (difficulty: 'easy' | 'medium' | 'hard'): Grid => {
|
||||
// Crée une grille vide
|
||||
const grid: Grid = Array(9).fill(null).map(() => Array(9).fill(null));
|
||||
|
||||
// Remplit quelques cellules aléatoirement
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const num = Math.floor(Math.random() * 9) + 1;
|
||||
const row = Math.floor(Math.random() * 9);
|
||||
const col = Math.floor(Math.random() * 9);
|
||||
if (isValid(grid, row, col, num)) {
|
||||
grid[row][col] = num;
|
||||
}
|
||||
}
|
||||
|
||||
// Résout la grille
|
||||
solveSudoku(grid);
|
||||
|
||||
// Détermine le nombre de cellules à retirer selon la difficulté
|
||||
const cellsToRemove = {
|
||||
easy: 40,
|
||||
medium: 50,
|
||||
hard: 60,
|
||||
}[difficulty];
|
||||
|
||||
// Retire des cellules aléatoirement
|
||||
let removed = 0;
|
||||
while (removed < cellsToRemove) {
|
||||
const row = Math.floor(Math.random() * 9);
|
||||
const col = Math.floor(Math.random() * 9);
|
||||
if (grid[row][col] !== null) {
|
||||
grid[row][col] = null;
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
return grid;
|
||||
};
|
||||
|
||||
// Vérifie si la grille est valide
|
||||
export const validateGrid = (grid: Grid): boolean => {
|
||||
// Vérifie chaque ligne
|
||||
for (let row = 0; row < 9; row++) {
|
||||
const nums = new Set();
|
||||
for (let col = 0; col < 9; col++) {
|
||||
const num = grid[row][col];
|
||||
if (num !== null) {
|
||||
if (nums.has(num)) return false;
|
||||
nums.add(num);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifie chaque colonne
|
||||
for (let col = 0; col < 9; col++) {
|
||||
const nums = new Set();
|
||||
for (let row = 0; row < 9; row++) {
|
||||
const num = grid[row][col];
|
||||
if (num !== null) {
|
||||
if (nums.has(num)) return false;
|
||||
nums.add(num);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifie chaque bloc 3x3
|
||||
for (let block = 0; block < 9; block++) {
|
||||
const nums = new Set();
|
||||
const startRow = Math.floor(block / 3) * 3;
|
||||
const startCol = (block % 3) * 3;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
for (let j = 0; j < 3; j++) {
|
||||
const num = grid[startRow + i][startCol + j];
|
||||
if (num !== null) {
|
||||
if (nums.has(num)) return false;
|
||||
nums.add(num);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Vérifie si la grille est complète
|
||||
export const isGridComplete = (grid: Grid): boolean => {
|
||||
return grid.every(row => row.every(cell => cell !== null)) && validateGrid(grid);
|
||||
};
|
57
src/context/ColorModeContext.tsx
Normal file
57
src/context/ColorModeContext.tsx
Normal file
|
@ -0,0 +1,57 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, useState, useMemo, ReactNode, useEffect } from 'react';
|
||||
import { ThemeProvider, PaletteMode } from '@mui/material';
|
||||
import { getTheme } from '@/theme/theme';
|
||||
|
||||
interface ColorModeContextType {
|
||||
toggleColorMode: () => void;
|
||||
mode: PaletteMode;
|
||||
}
|
||||
|
||||
export const ColorModeContext = createContext<ColorModeContextType>({
|
||||
toggleColorMode: () => {},
|
||||
mode: 'light',
|
||||
});
|
||||
|
||||
interface ColorModeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ColorModeProvider = ({ children }: ColorModeProviderProps) => {
|
||||
const [mode, setMode] = useState<PaletteMode>('light');
|
||||
|
||||
// Charger le thème depuis localStorage au montage
|
||||
useEffect(() => {
|
||||
const savedMode = localStorage.getItem('themeMode') as PaletteMode;
|
||||
if (savedMode) {
|
||||
setMode(savedMode);
|
||||
} else {
|
||||
// Détecter le thème système par défaut
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
setMode(prefersDark ? 'dark' : 'light');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const colorMode = useMemo(
|
||||
() => ({
|
||||
toggleColorMode: () => {
|
||||
setMode((prevMode) => {
|
||||
const newMode = prevMode === 'light' ? 'dark' : 'light';
|
||||
localStorage.setItem('themeMode', newMode);
|
||||
return newMode;
|
||||
});
|
||||
},
|
||||
mode,
|
||||
}),
|
||||
[mode]
|
||||
);
|
||||
|
||||
const theme = useMemo(() => getTheme(mode), [mode]);
|
||||
|
||||
return (
|
||||
<ColorModeContext.Provider value={colorMode}>
|
||||
<ThemeProvider theme={theme}>{children}</ThemeProvider>
|
||||
</ColorModeContext.Provider>
|
||||
);
|
||||
};
|
40
src/hooks/useLocalStorage.ts
Normal file
40
src/hooks/useLocalStorage.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T) {
|
||||
// État pour stocker notre valeur
|
||||
// Passe la fonction d'initialisation à useState pour que la logique ne s'exécute qu'une seule fois
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return initialValue;
|
||||
}
|
||||
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
console.warn(`Error reading localStorage key "${key}":`, error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
// Retourne une version enveloppée de la fonction setState de useState qui ...
|
||||
// ... persiste la nouvelle valeur dans localStorage.
|
||||
const setValue = (value: T | ((val: T) => T)) => {
|
||||
try {
|
||||
// Permet à la valeur d'être une fonction pour que nous ayons la même API que useState
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||
|
||||
// Sauvegarde l'état
|
||||
setStoredValue(valueToStore);
|
||||
|
||||
// Sauvegarde dans localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error setting localStorage key "${key}":`, error);
|
||||
}
|
||||
};
|
||||
|
||||
return [storedValue, setValue] as const;
|
||||
}
|
50
src/theme/theme.ts
Normal file
50
src/theme/theme.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { createTheme, PaletteMode } from '@mui/material';
|
||||
|
||||
export const getTheme = (mode: PaletteMode) => {
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode,
|
||||
primary: {
|
||||
main: mode === 'light' ? '#2C3E50' : '#3498DB',
|
||||
contrastText: '#fff',
|
||||
},
|
||||
secondary: {
|
||||
main: mode === 'light' ? '#E74C3C' : '#E74C3C',
|
||||
},
|
||||
background: {
|
||||
default: mode === 'light' ? '#F5F6FA' : '#1A1A2E',
|
||||
paper: mode === 'light' ? '#FFFFFF' : '#16213E',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
|
||||
h1: {
|
||||
fontSize: '2.5rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
h2: {
|
||||
fontSize: '2rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
body1: {
|
||||
fontSize: '1rem',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: mode === 'light' ? '#FFFFFF' : '#16213E',
|
||||
color: mode === 'light' ? '#2C3E50' : '#FFFFFF',
|
||||
boxShadow: 'none',
|
||||
borderBottom: `1px solid ${mode === 'light' ? '#E0E0E0' : '#2C3E50'}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
});
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue