1 Commits
Author SHA1 Message Date
alexis 08e86ffd74 use vite to start & build app 2025-07-03 12:42:45 +02:00
29 changed files with 5880 additions and 14392 deletions
+19 -9
View File
@@ -1,16 +1,26 @@
# Developement
.git
.prettierrc
.gitignore
README.md
# Node # Node
node_modules node_modules
npm-debug.log npm-debug.log
# Build
build
# Docker # Docker
Dockerfile Dockerfile
.dockerignore .dockerignore
# Git
.git
.github
.gitignore
# Builds
dist
# Dev configs
.prettierrc
.prettierignore
jsconfig.json
# Markdown files
*.md
# Release scripts
release
+15 -4
View File
@@ -1,12 +1,23 @@
{ {
"useTabs": true,
"tabWidth": 4,
"arrowParens": "always", "arrowParens": "always",
"bracketSpacing": true,
"bracketSameLine": false,
"endOfLine": "lf", "endOfLine": "lf",
"jsxSingleQuote": true, "jsxSingleQuote": true,
"overrides": [
{
"files": "*.scss",
"options": {
"singleQuote": false
}
}
],
"printWidth": 500,
"proseWrap": "always",
"useTabs": true,
"semi": true, "semi": true,
"trailingComma": "all",
"singleAttributePerLine": true, "singleAttributePerLine": true,
"singleQuote": true, "singleQuote": true,
"printWidth": 500 "tabWidth": 4,
"trailingComma": "all"
} }
+18 -11
View File
@@ -1,23 +1,30 @@
# Build stage # Build stage
FROM node:lts-alpine as build FROM node:lts-alpine AS base
WORKDIR /usr/src/app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci
# Production dependencies
FROM base AS prod-deps
RUN npm ci --omit=dev
# Build
FROM base AS build
COPY . . COPY . .
RUN npm ci
RUN npm run build RUN npm run build
# Final stage # Final stage
FROM node:lts-alpine as final FROM node:lts-alpine AS final
WORKDIR /app
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=build /app/dist /app/dist
EXPOSE 3000 EXPOSE 3000
WORKDIR /usr/src/app CMD ["npm", "start"]
COPY --from=build /usr/src/app/build .
RUN npm i -g serve
CMD serve
+2 -2
View File
@@ -79,7 +79,7 @@ Minesweeper is a game where the player has to clear a minefield without detonati
docker run --name minesweeper -dp 3000:3000 minesweeper docker run --name minesweeper -dp 3000:3000 minesweeper
``` ```
# 👩‍❤️‍👨 Credits # 👷‍♂️ Credits
- [KeunotorCagoule](https://www.github.com/KeunotorCagoule)
- [UnEpicier](https://www.github.com/UnEpicier) - [UnEpicier](https://www.github.com/UnEpicier)
- [KeunotorCagoule](https://www.github.com/KeunotorCagoule)
+38
View File
@@ -0,0 +1,38 @@
import js from '@eslint/js';
import { Linter } from 'eslint';
import react from 'eslint-plugin-react';
import globals from 'globals';
const config: Linter.Config[] = [
js.configs.recommended,
react.configs.flat.recommended,
{
plugins: {
react: react,
},
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {
'react/prop-types': 'off',
'react/display-name': 'off',
'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx'] }],
},
settings: {
react: {
version: 'detect',
},
},
},
];
export default config;
+16 -7
View File
@@ -1,15 +1,15 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link
rel="icon"
href="%PUBLIC_URL%/favicon.ico"
/>
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1" content="width=device-width, initial-scale=1"
/> />
<meta
name="mobile-web-app-capable"
content="yes"
/>
<meta <meta
name="theme-color" name="theme-color"
content="#000000" content="#000000"
@@ -18,18 +18,27 @@
name="description" name="description"
content="Démineur en React & Typescript" content="Démineur en React & Typescript"
/> />
<link
rel="icon"
href="favicon.ico"
/>
<link <link
rel="apple-touch-icon" rel="apple-touch-icon"
href="%PUBLIC_URL%/logo192.png" href="logo192.png"
/> />
<link <link
rel="manifest" rel="manifest"
href="%PUBLIC_URL%/manifest.json" href="manifest.json"
/> />
<title>Démineur</title> <title>Démineur</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>
<script
type="module"
src="/src/index.tsx"
></script>
</body> </body>
</html> </html>
+5265 -13955
View File
File diff suppressed because it is too large Load Diff
+27 -33
View File
@@ -2,44 +2,38 @@
"name": "demineur", "name": "demineur",
"version": "1.0.0", "version": "1.0.0",
"description": "Démineur en ReactJS & TypeScript", "description": "Démineur en ReactJS & TypeScript",
"author": "KeunotorCagoulé", "author": "UnEpicier",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "react-scripts start", "dev": "vite",
"build": "react-scripts build", "build": "vite build",
"preview": "vite preview",
"start": "serve -s -l 8000 dist",
"docker:build": "docker rmi minesweeper && docker build -t minesweeper ." "docker:build": "docker rmi minesweeper && docker build -t minesweeper ."
}, },
"dependencies": {
"@types/node": "^20.11.16",
"@types/react": "^18.2.53",
"@types/react-dom": "^18.2.18",
"localforage": "^1.10.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"recoil": "^0.7.7",
"sass": "^1.70.0",
"typescript": "^4.9.5"
},
"devDependencies": { "devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11" "@eslint/js": "^9.30.1",
"@types/node": "^24.0.10",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"dotenv": "^17.0.1",
"eslint": "^9.30.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"globals": "^16.3.0",
"jiti": "^2.4.2",
"localforage": "^1.10.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"sass": "^1.89.2",
"typescript": "^5.8.3",
"vite": "^7.0.0",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-pwa": "^1.0.1",
"zustand": "^5.0.6"
}, },
"eslintConfig": { "dependencies": {
"extends": [ "serve": "^14.2.4"
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
} }
} }
-103
View File
@@ -1,103 +0,0 @@
// ---------------------------------------------------------------------------------------------------------------------
//! Imports
// ---------------------------------------------------------------------------------------------------------------------
// ------------------------------------------------------ React --------------------------------------------------------
import { useEffect, useState } from 'react';
// ---------------------------------------------------------------------------------------------------------------------
// ----------------------------------------------------- Context -------------------------------------------------------
import { useRecoilState } from 'recoil';
import { gameStateAtom } from './contexts/gameState';
import { langAtom } from './contexts/langState';
// ---------------------------------------------------------------------------------------------------------------------
// --------------------------------------------------- Components ------------------------------------------------------
import Home from './components/Home/Home';
import Timer from './components/Stats/Timer/Timer';
import MinesCounter from './components/Stats/MinesCounter/MinesCounter';
import Grid from './components/Grid/Grid';
import Help from './components/Help/Help';
import LeaderBoard from './components/Leaderboard/Leaderboard';
// ---------------------------------------------------------------------------------------------------------------------
// ------------------------------------------------------ Langs --------------------------------------------------------
import frConf from './langs/fr.json';
import enConf from './langs/en.json';
// ---------------------------------------------------------------------------------------------------------------------
// ----------------------------------------------------- Styles --------------------------------------------------------
import Logo from './assets/logo';
import './App.scss';
import LangToggler from './components/LangToggler/LangToggler';
// ---------------------------------------------------------------------------------------------------------------------
const App = () => {
const [helpDisplayed, displayHelp] = useState(false);
const [gameState] = useRecoilState(gameStateAtom);
const [lang, setLang] = useRecoilState(langAtom);
useEffect(() => {
const storedLang = localStorage.getItem('lang');
if (storedLang === null) {
localStorage.setItem('lang', 'fr');
setLang({
key: 'fr',
config: frConf,
});
return;
}
if (storedLang === 'fr') {
setLang({
key: 'fr',
config: frConf,
});
return;
}
setLang({
key: 'en',
config: enConf,
});
}, [lang.key]);
if (lang.config) {
return (
<div className='App'>
<LangToggler />
{gameState.status === 'settings' && <Home />}
{(gameState.status === 'idle' || gameState.status === 'playing') && (
<>
<div className='stats'>
<Timer />
<Logo className={'logo'} />
<MinesCounter />
</div>
<Grid />
<button
className='helpButton'
onClick={() => displayHelp(true)}
>
<span>{lang.config.how}</span>
</button>
{helpDisplayed && (
<Help
onClose={() => {
displayHelp(false);
}}
/>
)}
</>
)}
{gameState.status === 'board' && <LeaderBoard />}
</div>
);
}
return <></>;
};
export default App;
+1 -1
View File
@@ -18,7 +18,7 @@
} }
} }
& > .helpButton { & > .help-button {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
align-items: center; align-items: center;
+99
View File
@@ -0,0 +1,99 @@
// ---------------------------------------------------------------------------------------------------------------------
//! Imports
// ---------------------------------------------------------------------------------------------------------------------
// ------------------------------------------------------ React --------------------------------------------------------
import { useEffect, useState } from 'react';
// ---------------------------------------------------------------------------------------------------------------------
// --------------------------------------------------- Components ------------------------------------------------------
import Home from '../components/Home/Home';
import Timer from '../components/Stats/Timer/Timer';
import MinesCounter from '../components/Stats/MinesCounter/MinesCounter';
import Grid from '../components/Grid/Grid';
import Help from '../components/Help/Help';
import LeaderBoard from '../components/Leaderboard/Leaderboard';
import LangToggler from '../components/LangToggler/LangToggler';
// ---------------------------------------------------------------------------------------------------------------------
// -------------------------------------------------- Hooks & Utils ----------------------------------------------------
import { useLangStore } from '../store/langState';
import { useGameStateStore } from '@/store/gameState';
import frConf from '../langs/fr.json';
import enConf from '../langs/en.json';
// ---------------------------------------------------------------------------------------------------------------------
// ----------------------------------------------------- Styles --------------------------------------------------------
import Logo from '../assets/logo';
import './App.scss';
// ---------------------------------------------------------------------------------------------------------------------
const App = () => {
const [helpDisplayed, displayHelp] = useState(false);
const gameStatus = useGameStateStore((state) => state.status);
const { key, config, setLang } = useLangStore();
useEffect(() => {
const storedLang = localStorage.getItem('lang');
if (storedLang === null) {
localStorage.setItem('lang', 'fr');
setLang({
key: 'fr',
config: frConf,
});
return;
}
if (storedLang === 'fr') {
setLang({
key: 'fr',
config: frConf,
});
return;
}
setLang({
key: 'en',
config: enConf,
});
}, [key]);
if (!config) {
return;
}
return (
<div className='App'>
<LangToggler />
{gameStatus === 'settings' && <Home />}
{(gameStatus === 'idle' || gameStatus === 'playing') && (
<>
<div className='stats'>
<Timer />
<Logo className={'logo'} />
<MinesCounter />
</div>
<Grid />
<button
className='help-button'
onClick={() => displayHelp(true)}
>
<span>{config.how}</span>
</button>
{helpDisplayed && (
<Help
onClose={() => {
displayHelp(false);
}}
/>
)}
</>
)}
{gameStatus === 'board' && <LeaderBoard />}
</div>
);
};
export default App;
+11 -28
View File
@@ -2,43 +2,26 @@
//! Imports //! Imports
// --------------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------------
// ------------------------------------------------------ React -------------------------------------------------------- // -------------------------------------------------- Hooks & Utils ----------------------------------------------------
import { useState } from 'react'; import { useLangStore } from '@/store/langState';
// ---------------------------------------------------------------------------------------------------------------------
// ----------------------------------------------------- Context -------------------------------------------------------
import { useRecoilState } from 'recoil';
import { gameStateAtom } from '../../contexts/gameState';
import { langAtom } from '../../contexts/langState';
// ---------------------------------------------------------------------------------------------------------------------
// ------------------------------------------------------ Hooks --------------------------------------------------------
import { useActions } from './hooks/useActions'; import { useActions } from './hooks/useActions';
// ---------------------------------------------------------------------------------------------------------------------
// ------------------------------------------------------ Utils --------------------------------------------------------
import { genGrid } from '../../utils/generation';
import { getCellDisplayContent } from '../../utils/gameInteractions'; import { getCellDisplayContent } from '../../utils/gameInteractions';
// --------------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------------
// ----------------------------------------------------- Assets -------------------------------------------------------- // ------------------------------------------------- Assets & Styles ---------------------------------------------------
import Podium from '../../assets/podium'; import Podium from '../../assets/podium';
import './styles.scss'; import './styles.scss';
// --------------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------------
const Grid = () => { const Grid = () => {
const [gameState, setGameState] = useRecoilState(gameStateAtom); const config = useLangStore((state) => state.config);
const [grid, setGrid] = useState(genGrid(gameState.gridSize)); const { grid, gridSize, endType, handleLeftClick, handleRightClick, openLeaderboard } = useActions();
const [lang] = useRecoilState(langAtom);
const { handleLeftClick, handleRightClick } = useActions(grid, setGrid, gameState, setGameState);
return ( return (
<div <div
className='grid' className='grid'
style={{ style={{
gridTemplateRows: `repeat(${gameState.gridSize}, var(--size))`, gridTemplateRows: `repeat(${gridSize}, var(--size))`,
}} }}
> >
{grid.map((row, rowIndex) => ( {grid.map((row, rowIndex) => (
@@ -46,7 +29,7 @@ const Grid = () => {
key={`row${rowIndex}`} key={`row${rowIndex}`}
className='row' className='row'
style={{ style={{
gridTemplateColumns: `repeat(${gameState.gridSize}, var(--size))`, gridTemplateColumns: `repeat(${gridSize}, var(--size))`,
}} }}
> >
{row.map((cell, cellIndex) => { {row.map((cell, cellIndex) => {
@@ -63,19 +46,19 @@ const Grid = () => {
})} })}
</div> </div>
))} ))}
{gameState.endType !== '' && ( {endType !== '' && (
<div <div
className='overlay' className='overlay'
style={ style={
{ {
'--overlayColor': gameState.endType === 'win' ? '#5f8e59' : '#ca5940', '--overlayColor': endType === 'win' ? '#5f8e59' : '#ca5940',
} as React.CSSProperties } as React.CSSProperties
} }
> >
<p className='overlayTitle'>{gameState.endType === 'win' ? lang.config.win : lang.config.loose}</p> <p className='overlayTitle'>{endType === 'win' ? config.win : config.loose}</p>
<button <button
className='overlayButton' className='overlayButton'
onClick={() => setGameState((prev) => ({ ...prev, status: 'board' }))} onClick={openLeaderboard}
> >
<Podium /> <Podium />
Leaderboard Leaderboard
+45 -44
View File
@@ -2,22 +2,33 @@
//! Imports //! Imports
// --------------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------------
// ------------------------------------------------------ React -------------------------------------------------------- // -------------------------------------------------- Hooks & Utils ----------------------------------------------------
import { MouseEvent, useCallback, useState } from 'react'; import { MouseEvent, useCallback, useState } from 'react';
// --------------------------------------------------------------------------------------------------------------------- import { GridType } from '../../../types/game';
// ------------------------------------------------------ Types --------------------------------------------------------
import { SetterOrUpdater } from 'recoil';
import { Difficulty, GridType } from '../../../types/game';
import { GameState } from '../../../types/gameState';
// ---------------------------------------------------------------------------------------------------------------------
// ------------------------------------------------------ Utils --------------------------------------------------------
import { discoverAroundCell, revealAllGrid } from '../../../utils/gameInteractions'; import { discoverAroundCell, revealAllGrid } from '../../../utils/gameInteractions';
import { startGame } from '../../../utils/generation'; import { genGrid, startGame } from '../../../utils/generation';
import { useGameStateStore } from '@/store/gameState';
import { useShallow } from 'zustand/react/shallow';
// --------------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------------
export const useActions = (grid: GridType, setGrid: React.Dispatch<React.SetStateAction<GridType>>, gameState: GameState, setGameState: SetterOrUpdater<GameState>) => { export const useActions = () => {
const { gridSize, endType, status, difficulty, bombs, placedFlags, setStatus, setBombs, setEndType, setPlacedFlags } = useGameStateStore(
useShallow((state) => ({
gridSize: state.gridSize,
endType: state.endType,
status: state.status,
difficulty: state.difficulty,
bombs: state.bombs,
placedFlags: state.placedFlags,
setStatus: state.setStatus,
setBombs: state.setBombs,
setEndType: state.setEndType,
setPlacedFlags: state.setPlacedFlags,
})),
);
const [grid, setGrid] = useState(genGrid(gridSize));
const [, updateGrid] = useState({}); const [, updateGrid] = useState({});
const forceUpdate = useCallback(() => updateGrid({}), []); const forceUpdate = useCallback(() => updateGrid({}), []);
@@ -30,25 +41,19 @@ export const useActions = (grid: GridType, setGrid: React.Dispatch<React.SetStat
let bombsCount: number | null = null; let bombsCount: number | null = null;
// Start the game with making sure the clicked cell will be empty after generation // Start the game with making sure the clicked cell will be empty after generation
if (gameState.status === 'idle') { if (status === 'idle') {
const { grid: newGrid, bombsCount: _bombsCount } = startGame(gameState.gridSize, gameState.difficulty as Difficulty, true, rowIndex, colIndex); const { grid: newGrid, bombsCount: _bombsCount } = startGame(gridSize, difficulty, true, rowIndex, colIndex);
prev = newGrid; prev = newGrid;
bombsCount = _bombsCount; bombsCount = _bombsCount;
setGameState((prevGameState) => ({ setStatus('playing');
...prevGameState, setBombs(_bombsCount);
status: 'playing',
bombs: _bombsCount,
}));
} }
// If the clicked cell hide a bomb, loose immediatly // If the clicked cell hide a bomb, loose immediatly
if (prev[rowIndex][colIndex].value === 'bomb') { if (prev[rowIndex][colIndex].value === 'bomb') {
setGameState((prevGameState) => ({ setEndType('loose');
...prevGameState,
endType: 'loose',
}));
return revealAllGrid(grid, true); return revealAllGrid(grid, true);
} }
// Discover surronding cells if the clicked is empty // Discover surronding cells if the clicked is empty
@@ -58,11 +63,8 @@ export const useActions = (grid: GridType, setGrid: React.Dispatch<React.SetStat
prev[rowIndex][colIndex].hidden = false; prev[rowIndex][colIndex].hidden = false;
} }
if (hasWin(prev, bombsCount || gameState.bombs, gameState.placedFlags)) { if (hasWin(prev, bombsCount || bombs, placedFlags)) {
setGameState((prevGameState) => ({ setEndType('win');
...prevGameState,
endType: 'win',
}));
return revealAllGrid(prev, true); return revealAllGrid(prev, true);
} }
@@ -83,27 +85,21 @@ export const useActions = (grid: GridType, setGrid: React.Dispatch<React.SetStat
let bombsCount: number | null = null; let bombsCount: number | null = null;
// Start the game without making sure the clicked cell will be empty after generation // Start the game without making sure the clicked cell will be empty after generation
if (gameState.status === 'idle') { if (status === 'idle') {
const { grid: newGrid, bombsCount: _bombsCount } = startGame(gameState.gridSize, gameState.difficulty as Difficulty, false); const { grid: newGrid, bombsCount: _bombsCount } = startGame(gridSize, difficulty, false);
prev = newGrid; prev = newGrid;
bombsCount = _bombsCount; bombsCount = _bombsCount;
setGameState((prevGameState) => ({ setStatus('playing');
...prevGameState, setBombs(_bombsCount);
status: 'playing',
bombs: _bombsCount,
}));
} }
prev = prev.map((row, idx) => { prev = prev.map((row, idx) => {
if (idx === rowIndex) { if (idx === rowIndex) {
return row.map((col, idy) => { return row.map((col, idy) => {
if (idy === colIndex) { if (idy === colIndex) {
setGameState((prevGameState) => ({ setPlacedFlags(col.flag ? placedFlags - 1 : placedFlags + 1);
...prevGameState,
placedFlags: col.flag ? prevGameState.placedFlags - 1 : prevGameState.placedFlags + 1,
}));
return { return {
...col, ...col,
@@ -116,11 +112,8 @@ export const useActions = (grid: GridType, setGrid: React.Dispatch<React.SetStat
return row; return row;
}); });
if (hasWin(prev, bombsCount || gameState.bombs, gameState.placedFlags)) { if (hasWin(prev, bombsCount || bombs, placedFlags)) {
setGameState((prev) => ({ setEndType('win');
...prev,
endType: 'win',
}));
return revealAllGrid(grid, true); return revealAllGrid(grid, true);
} }
@@ -129,9 +122,17 @@ export const useActions = (grid: GridType, setGrid: React.Dispatch<React.SetStat
} }
}; };
const openLeaderboard = useCallback(() => {
setStatus('board');
}, [setStatus]);
return { return {
grid,
gridSize,
endType,
handleLeftClick, handleLeftClick,
handleRightClick, handleRightClick,
openLeaderboard,
}; };
}; };
+34 -36
View File
@@ -2,18 +2,15 @@
//! Imports //! Imports
// --------------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------------
// ------------------------------------------------------ React -------------------------------------------------------- // -------------------------------------------------- Hooks & Utils ----------------------------------------------------
import { useEffect, useRef } from 'react'; import { useCallback, useRef } from 'react';
// ---------------------------------------------------------------------------------------------------------------------
// ----------------------------------------------- Context & Stockage --------------------------------------------------
import localforage from 'localforage'; import localforage from 'localforage';
import { useRecoilState } from 'recoil'; import { useShallow } from 'zustand/react/shallow';
import { gameStateAtom } from '../../contexts/gameState'; import { useGameStateStore } from '../../store/gameState';
import { langAtom } from '../../contexts/langState'; import { useLangStore } from '../../store/langState';
// --------------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------------
// ----------------------------------------------------- Styles -------------------------------------------------------- // ------------------------------------------------- Assets & Styles ---------------------------------------------------
import Size from '../../assets/size'; import Size from '../../assets/size';
import Difficulty from '../../assets/difficulty'; import Difficulty from '../../assets/difficulty';
import './styles.scss'; import './styles.scss';
@@ -27,43 +24,46 @@ const store = localforage.createInstance({
description: 'Store 10 best game time', description: 'Store 10 best game time',
}); });
const Home = () => { export default function Home() {
const difficultyRef = useRef<HTMLSelectElement>(null); const difficultyRef = useRef<HTMLSelectElement>(null);
const sizeRef = useRef<HTMLSelectElement>(null); const sizeRef = useRef<HTMLSelectElement>(null);
const [, setGameState] = useRecoilState(gameStateAtom); const { setDifficulty, setGridSize, setStatus } = useGameStateStore(
const [lang] = useRecoilState(langAtom); useShallow((state) => ({
setDifficulty: state.setDifficulty,
setGridSize: state.setGridSize,
setStatus: state.setStatus,
})),
);
const onClick = () => { const config = useLangStore((state) => state.config);
const onClick = useCallback(() => {
if (difficultyRef.current !== null && sizeRef.current !== null) { if (difficultyRef.current !== null && sizeRef.current !== null) {
const difficulty = difficultyRef.current.value; const difficulty = difficultyRef.current.value;
const size = sizeRef.current.value; const size = sizeRef.current.value;
const initIDB = async () => { (async () => {
if ((await store.getItem(`${difficulty}:${size}`)) === null) { if ((await store.getItem(`${difficulty}:${size}`)) === null) {
await store.setItem(`${difficulty}:${size}`, []); await store.setItem(`${difficulty}:${size}`, []);
} }
}; })();
initIDB();
setGameState((prev) => ({ setGridSize(parseInt(size) as 12 | 16 | 20);
...prev, setDifficulty(difficulty as 'beginner' | 'intermediate' | 'expert');
difficulty: difficulty, setStatus('idle');
gridSize: parseInt(size),
status: 'idle',
}));
} }
}; }, []);
return ( return (
<div className='gameContainer'> <div className='gameContainer'>
<h1 className='title'>{lang.config.appTitle}</h1> <h1 className='title'>{config.appTitle}</h1>
<div className='settingsContainer'> <div className='settingsContainer'>
<div className='selectorBox'> <div className='selectorBox'>
<p className='settingLabel'> <p className='settingLabel'>
<Size /> <Size />
{lang.config.settings.size.label} {config.settings.size.label}
</p> </p>
<select <select
ref={sizeRef} ref={sizeRef}
@@ -71,16 +71,16 @@ const Home = () => {
className='select' className='select'
defaultValue={'12'} defaultValue={'12'}
> >
<option value='12'>{lang.config.settings.size.values.small}</option> <option value='12'>{config.settings.size.values.small}</option>
<option value='16'>{lang.config.settings.size.values.medium}</option> <option value='16'>{config.settings.size.values.medium}</option>
<option value='20'>{lang.config.settings.size.values.large}</option> <option value='20'>{config.settings.size.values.large}</option>
</select> </select>
</div> </div>
<div className='selectorBox'> <div className='selectorBox'>
<p className='settingLabel'> <p className='settingLabel'>
<Difficulty /> <Difficulty />
{lang.config.settings.difficulty.label} {config.settings.difficulty.label}
</p> </p>
<select <select
ref={difficultyRef} ref={difficultyRef}
@@ -88,9 +88,9 @@ const Home = () => {
className='select' className='select'
defaultValue={'beginner'} defaultValue={'beginner'}
> >
<option value='beginner'>{lang.config.settings.difficulty.values.beginner}</option> <option value='beginner'>{config.settings.difficulty.values.beginner}</option>
<option value='intermediate'>{lang.config.settings.difficulty.values.intermediate}</option> <option value='intermediate'>{config.settings.difficulty.values.intermediate}</option>
<option value='expert'>{lang.config.settings.difficulty.values.expert}</option> <option value='expert'>{config.settings.difficulty.values.expert}</option>
</select> </select>
</div> </div>
@@ -98,11 +98,9 @@ const Home = () => {
className='startButton' className='startButton'
onClick={onClick} onClick={onClick}
> >
{lang.config.start} {config.start}
</button> </button>
</div> </div>
</div> </div>
); );
}; }
export default Home;
+15 -14
View File
@@ -1,33 +1,34 @@
import { useRecoilState } from 'recoil'; // ---------------------------------------------------------------------------------------------------------------------
import { langAtom } from '../../contexts/langState'; //! Imports
// ---------------------------------------------------------------------------------------------------------------------
// -------------------------------------------------- Hooks & Utils ----------------------------------------------------
import { useLangStore } from '../../store/langState';
// ---------------------------------------------------------------------------------------------------------------------
// ------------------------------------------------- Assets & Styles ---------------------------------------------------
import fr from '../../assets/fr.png'; import fr from '../../assets/fr.png';
import en from '../../assets/en.png'; import en from '../../assets/en.png';
import './styles.scss'; import './styles.scss';
// ---------------------------------------------------------------------------------------------------------------------
const LangToggler = () => { const LangToggler = () => {
const [lang, setLang] = useRecoilState(langAtom); const { key, setKey } = useLangStore();
return ( return (
<button <button
className='langToggler' className='lang-goggler'
onClick={() => { onClick={() => {
if (lang.key === 'fr') { if (key === 'fr') {
localStorage.setItem('lang', 'en'); localStorage.setItem('lang', 'en');
setLang((prev) => ({ setKey('eb');
...prev,
key: 'en',
}));
} else { } else {
localStorage.setItem('lang', 'fr'); localStorage.setItem('lang', 'fr');
setLang((prev) => ({ setKey('fr');
...prev,
key: 'fr',
}));
} }
}} }}
> >
{lang.key == 'fr' ? ( {key == 'fr' ? (
<img <img
src={fr} src={fr}
alt='fr flag' alt='fr flag'
+1 -1
View File
@@ -1,4 +1,4 @@
.langToggler { .lang-goggler {
position: fixed; position: fixed;
top: 10px; top: 10px;
right: 10px; right: 10px;
+17 -64
View File
@@ -2,20 +2,8 @@
//! Imports //! Imports
// --------------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------------
// ------------------------------------------------------ React -------------------------------------------------------- // -------------------------------------------------- Hooks & Utils ----------------------------------------------------
import { useEffect, useState } from 'react'; import useLeaderboard from './hooks/useLeaderboard';
// ---------------------------------------------------------------------------------------------------------------------
// ----------------------------------------------- Context & Stockage --------------------------------------------------
import localforage from 'localforage';
import { useRecoilState } from 'recoil';
import { gameStateAtom } from '../../contexts/gameState';
import { langAtom } from '../../contexts/langState';
// ---------------------------------------------------------------------------------------------------------------------
// -------------------------------------------------- Utils & Types ----------------------------------------------------
import { Difficulty, LeaderBoardItem } from '../../types/game';
import { getDifficultyLabel } from '../../utils/generics';
// --------------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------------
// ----------------------------------------------------- Assets -------------------------------------------------------- // ----------------------------------------------------- Assets --------------------------------------------------------
@@ -29,63 +17,31 @@ import Size from '../../assets/size';
import './styles.scss'; import './styles.scss';
// --------------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------------
const store = localforage.createInstance({
name: 'leaderboard',
driver: localforage.INDEXEDDB,
version: 1.0,
storeName: 'leaderboard',
description: 'Store 10 best game time',
});
const LeaderBoard = () => { const LeaderBoard = () => {
const [gameState, setGameState] = useRecoilState(gameStateAtom); const { config, clearLeaderboard, clearAllLeaderboards, data, difficulty, gridSize, openSettings, replay } = useLeaderboard();
const [data, setData] = useState<LeaderBoardItem[]>([]);
const [lang] = useRecoilState(langAtom);
const getData = async () => {
const storeData = await store.getItem<LeaderBoardItem[]>(`${gameState.difficulty}:${gameState.gridSize}`);
setData(storeData || []);
};
useEffect(() => {
if (data.length === 0) {
getData();
}
}, [data.length, getData]);
const clearAllLeaderboards = async () => {
const keys = await store.keys();
for (let i = 0; i < keys.length; i++) {
await store.removeItem(keys[i]);
}
getData();
};
return ( return (
<> <>
<h1 className='title'>{lang.config.leaderboardTitle}</h1> <h1 className='title'>{config.leaderboardTitle}</h1>
<div className='board'> <div className='board'>
<div className='header'> <div className='header'>
<p>#</p> <p>#</p>
<p> <p>
<CalendarIcon /> <CalendarIcon />
{lang.config.date} {config.date}
</p> </p>
<p> <p>
<DifficultyIcon style={{ rotate: '-45deg' }} /> <DifficultyIcon style={{ rotate: '-45deg' }} />
{lang.config.settings.difficulty.label} {config.settings.difficulty.label}
</p> </p>
<p> <p>
<Size /> <Size />
{lang.config.settings.size.label} {config.settings.size.label}
</p> </p>
<p> <p>
<TimerIcon /> <TimerIcon />
{lang.config.time} {config.time}
</p> </p>
</div> </div>
@@ -98,8 +54,8 @@ const LeaderBoard = () => {
> >
<span>{index + 1}</span> <span>{index + 1}</span>
<span>{item.date}</span> <span>{item.date}</span>
<span>{lang.config.settings.difficulty.values[gameState.difficulty]}</span> <span>{config.settings.difficulty.values[difficulty]}</span>
<span>{gameState.gridSize}</span> <span>{gridSize}</span>
<span>{item.time}</span> <span>{item.time}</span>
</div> </div>
); );
@@ -110,31 +66,28 @@ const LeaderBoard = () => {
<div className='controls'> <div className='controls'>
<button <button
className='controlButton home' className='controlButton home'
onClick={() => setGameState((prev) => ({ ...prev, status: 'settings', endType: '', placedFlags: 0, bombs: 0, gameTime: '' }))} onClick={openSettings}
> >
<Settings /> <Settings />
{lang.config.settingsBtn} {config.settingsBtn}
</button> </button>
<button <button
className='controlButton replay' className='controlButton replay'
onClick={() => setGameState((prev) => ({ ...prev, status: 'idle', endType: '', placedFlags: 0, bombs: 0, gameTime: '' }))} onClick={replay}
> >
<Replay /> <Replay />
{lang.config.replay} {config.replay}
</button> </button>
<button <button
className='controlButton clear' className='controlButton clear'
onClick={async () => { onClick={clearLeaderboard}
await store.setItem(`${gameState.difficulty}:${gameState.gridSize}`, []);
await getData();
}}
> >
<Trash /> <Trash />
{lang.config.delete} {config.delete}
</button> </button>
<button <button
@@ -142,7 +95,7 @@ const LeaderBoard = () => {
onClick={clearAllLeaderboards} onClick={clearAllLeaderboards}
> >
<Replay /> <Replay />
{lang.config.deleteAll} {config.deleteAll}
</button> </button>
</div> </div>
</> </>
@@ -0,0 +1,85 @@
import { useShallow } from 'zustand/react/shallow';
import { useGameStateStore } from '@/store/gameState';
import { useLangStore } from '@/store/langState';
import { LeaderBoardItem } from '@/types/game';
import { useCallback, useEffect, useState } from 'react';
import localforage from 'localforage';
const store = localforage.createInstance({
name: 'leaderboard',
driver: localforage.INDEXEDDB,
version: 1.0,
storeName: 'leaderboard',
description: 'Store 10 best game time',
});
export default function useLeaderboard() {
const { gridSize, difficulty, setBombs, setEndType, setGameTime, setPlacedFlags, setStatus } = useGameStateStore(
useShallow((state) => ({
gridSize: state.gridSize,
difficulty: state.difficulty,
setStatus: state.setStatus,
setEndType: state.setEndType,
setPlacedFlags: state.setPlacedFlags,
setBombs: state.setBombs,
setGameTime: state.setGameTime,
})),
);
const config = useLangStore((state) => state.config);
const [data, setData] = useState<LeaderBoardItem[]>([]);
const getData = useCallback(async () => {
const storeData = await store.getItem<LeaderBoardItem[]>(`${difficulty}:${gridSize}`);
setData(storeData || []);
}, [difficulty, gridSize]);
useEffect(() => {
if (data.length === 0) {
getData();
}
}, [data, getData]);
const clearAllLeaderboards = useCallback(async () => {
const keys = await store.keys();
for (let i = 0; i < keys.length; i++) {
await store.removeItem(keys[i]);
}
getData();
}, [getData]);
const clearLeaderboard = useCallback(async () => {
await store.setItem(`${difficulty}:${gridSize}`, []);
await getData();
}, []);
const openSettings = useCallback(() => {
setStatus('settings');
setEndType('');
setPlacedFlags(0);
setBombs(0);
setGameTime('');
}, [setStatus, setEndType, setPlacedFlags, setBombs, setGameTime]);
const replay = useCallback(() => {
setStatus('idle');
setEndType('');
setPlacedFlags(0);
setBombs(0);
setGameTime('');
}, [setStatus, setEndType, setPlacedFlags, setBombs, setGameTime]);
return {
config,
clearLeaderboard,
clearAllLeaderboards,
data,
difficulty,
gridSize,
openSettings,
replay,
};
}
@@ -2,21 +2,26 @@
//! Imports //! Imports
// --------------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------------
// ----------------------------------------------------- Recoil -------------------------------------------------------- // -------------------------------------------------- Hooks & Utls -----------------------------------------------------
import { useRecoilState } from 'recoil'; import { useShallow } from 'zustand/react/shallow';
import { gameStateAtom } from '../../../contexts/gameState'; import { useGameStateStore } from '../../../store/gameState';
// --------------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------------
// ----------------------------------------------------- Assets -------------------------------------------------------- // ------------------------------------------------- Assets & Styles ---------------------------------------------------
import Flag from '../../../assets/flag'; import Flag from '../../../assets/flag';
import '../styles.scss'; import '../styles.scss';
// --------------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------------
const MinesCounter = () => { const MinesCounter = () => {
const [{ bombs, placedFlags }] = useRecoilState(gameStateAtom); const { bombs, placedFlags } = useGameStateStore(
useShallow((state) => ({
bombs: state.bombs,
placedFlags: state.placedFlags,
})),
);
return ( return (
<div className='statContainer'> <div className='stat-container'>
<Flag /> <Flag />
<p> <p>
{placedFlags} / {bombs} {placedFlags} / {bombs}
+24 -25
View File
@@ -2,40 +2,42 @@
//! Imports //! Imports
// --------------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------------
// ------------------------------------------------------ React -------------------------------------------------------- // -------------------------------------------------- Hooks & Utils ----------------------------------------------------
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
// --------------------------------------------------------------------------------------------------------------------- import { useShallow } from 'zustand/react/shallow';
import { useGameStateStore } from '@/store/gameState';
// ----------------------------------------------------- Context -------------------------------------------------------
import { useRecoilState } from 'recoil';
import { gameStateAtom } from '../../../contexts/gameState';
// ---------------------------------------------------------------------------------------------------------------------
// -------------------------------------------------- Utils & Types ----------------------------------------------------
import { Difficulty } from '../../../types/game';
import { saveToLocalStorage } from '../../../utils/generics'; import { saveToLocalStorage } from '../../../utils/generics';
// --------------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------------
// ----------------------------------------------------- Assets -------------------------------------------------------- // ------------------------------------------------- Assets & Styles ---------------------------------------------------
import Clock from '../../../assets/clock'; import Clock from '../../../assets/clock';
import '../styles.scss'; import '../styles.scss';
// --------------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------------
const Timer = () => { const Timer = () => {
const [gameState, setGameState] = useRecoilState(gameStateAtom); const { status, endType, difficulty, gridSize, setGameTime } = useGameStateStore(
useShallow((state) => ({
status: state.status,
endType: state.endType,
difficulty: state.difficulty,
gridSize: state.gridSize,
setGameTime: state.setGameTime,
})),
);
const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null); const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null);
const [startedTime, setStartedTime] = useState<number | null>(null); const [startedTime, setStartedTime] = useState<number | null>(null);
const [displayedTime, setDisplayedTime] = useState<string>('00:00'); const [displayedTime, setDisplayedTime] = useState<string>('00:00');
useEffect(() => { useEffect(() => {
if (gameState.status === 'win' || gameState.status === 'loose') { if (endType === 'win' || endType === 'loose') {
setStartedTime(null); setStartedTime(null);
} }
}, [gameState.status]); }, [status]);
useEffect(() => { useEffect(() => {
if (!startedTime && gameState.endType === '' && gameState.status === 'playing') { if (!startedTime && endType === '' && status === 'playing') {
const now = Date.now(); const now = Date.now();
setStartedTime(now); setStartedTime(now);
@@ -53,25 +55,22 @@ const Timer = () => {
setIntervalId(interval); setIntervalId(interval);
} }
}, [startedTime, gameState.endType, gameState.status]); }, [startedTime, endType, status]);
useEffect(() => { useEffect(() => {
if (intervalId && gameState.endType !== '') { if (intervalId && endType !== '') {
setGameState((prev) => ({ setGameTime(displayedTime);
...prev,
gameTime: displayedTime,
}));
clearInterval(intervalId); clearInterval(intervalId);
if (gameState.endType === 'win') { if (endType === 'win') {
saveToLocalStorage(displayedTime, gameState.difficulty as Difficulty, gameState.gridSize); saveToLocalStorage(displayedTime, difficulty, gridSize);
} }
} }
}, [intervalId, gameState.endType, gameState.difficulty, gameState.gridSize, setGameState, displayedTime]); }, [intervalId, endType, difficulty, gridSize, displayedTime, setGameTime]);
return ( return (
<div className='statContainer'> <div className='stat-container'>
<Clock /> <Clock />
<p>{displayedTime}</p> <p>{displayedTime}</p>
</div> </div>
+1 -1
View File
@@ -1,4 +1,4 @@
.statContainer { .stat-container {
display: grid; display: grid;
grid-template-columns: 25px 40px; grid-template-columns: 25px 40px;
align-items: center; align-items: center;
-25
View File
@@ -1,25 +0,0 @@
import { atom } from 'recoil';
/**
* Values:
* - Status: 'settings' | 'idle' | 'playing' | 'board'
* - Difficulty: 'beginner' | 'intermediate' | 'expert'
* - Grid Size: 12, 16, 20
* - Bombs: ]0;{GridSize}]
* - Placed Flags: [0;{GridSize}]
* - gameTime: Timestamp
* - End Type: 'win' | 'loose'
*/
export const gameStateAtom = atom({
key: 'gameState',
default: {
status: 'settings',
difficulty: 'beginner',
gridSize: 12,
bombs: 0,
placedFlags: 0,
gameTime: '',
endType: '',
},
});
-9
View File
@@ -1,9 +0,0 @@
import { atom } from 'recoil';
export const langAtom = atom({
key: 'lang',
default: {
key: null as any,
config: null as any,
},
});
+15 -8
View File
@@ -1,15 +1,22 @@
// ---------------------------------------------------------------------------------------------------------------------
//! Imports
// ---------------------------------------------------------------------------------------------------------------------
// --------------------------------------------------- Components ------------------------------------------------------
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import App from './app/App';
// ---------------------------------------------------------------------------------------------------------------------
import './index.scss'; import './index.scss';
import App from './App';
import { RecoilRoot } from 'recoil';
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); const appElement = document.getElementById('root');
if (!appElement) throw Error('No app root element found');
root.render( const reactRoot = createRoot(appElement);
reactRoot.render(
<React.StrictMode> <React.StrictMode>
<RecoilRoot> <App />
<App />
</RecoilRoot>
</React.StrictMode>, </React.StrictMode>,
); );
-1
View File
@@ -1 +0,0 @@
/// <reference types="react-scripts" />
+48
View File
@@ -0,0 +1,48 @@
import { create } from 'zustand';
type GameStateStore = {
status: 'settings' | 'idle' | 'playing' | 'board';
difficulty: 'beginner' | 'intermediate' | 'expert';
gridSize: 12 | 16 | 20;
bombs: number;
placedFlags: number;
gameTime: string;
endType: '' | 'win' | 'loose';
setStatus: (status: 'settings' | 'idle' | 'playing' | 'board') => void;
setDifficulty: (difficulty: 'beginner' | 'intermediate' | 'expert') => void;
setGridSize: (gridSize: 12 | 16 | 20) => void;
setBombs: (amount: number) => void;
setPlacedFlags: (amount: number) => void;
setGameTime: (time: string) => void;
setEndType: (endType: '' | 'win' | 'loose') => void;
};
/**
* Values:
* - Status: 'settings' | 'idle' | 'playing' | 'board'
* - Difficulty: 'beginner' | 'intermediate' | 'expert'
* - Grid Size: 12, 16, 20
* - Bombs: ]0;{GridSize}]
* - Placed Flags: [0;{GridSize}]
* - gameTime: Timestamp
* - End Type: 'win' | 'loose'
*/
export const useGameStateStore = create<GameStateStore>((set) => ({
status: 'settings',
difficulty: 'beginner',
gridSize: 12,
bombs: 0,
placedFlags: 0,
gameTime: '',
endType: '',
setStatus: (newStatus) => set({ status: newStatus }),
setDifficulty: (newDifficulty) => set({ difficulty: newDifficulty }),
setGridSize: (size) => set({ gridSize: size }),
setBombs: (amount) => set({ bombs: amount }),
setPlacedFlags: (amount) => set({ placedFlags: amount }),
setGameTime: (time) => set({ gameTime: time }),
setEndType: (type) => set({ endType: type }),
}));
+16
View File
@@ -0,0 +1,16 @@
import { create } from 'zustand';
type LangStore = {
key: any;
config: any;
setKey: (key: any) => void;
setLang: ({ key, config }: { key: any; config: any }) => void;
};
export const useLangStore = create<LangStore>((set) => ({
key: null,
config: null,
setKey: (key) => set({ key: key }),
setLang: ({ key, config }) => set({ key: key, config: config }),
}));
+7 -4
View File
@@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es6",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
@@ -14,8 +14,11 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx" "jsx": "react-jsx",
"paths": {
"@/*": ["./src/*"]
}
}, },
"include": ["src"], "include": ["src/**/*"],
"exclude": ["node_modules"] "exclude": ["node_modules", "**/node_modules/*", "dist"]
} }
+49
View File
@@ -0,0 +1,49 @@
import path from 'path';
import dotenv from 'dotenv';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import eslint from 'vite-plugin-eslint';
import { VitePWA } from 'vite-plugin-pwa';
dotenv.config();
const alias = {
'@app': path.resolve(__dirname, 'src/app'),
'@assets': path.resolve(__dirname, 'src/assets'),
'@config': path.resolve(__dirname, 'src/config'),
'@components': path.resolve(__dirname, 'src/components'),
'@hooks': path.resolve(__dirname, 'src/hooks'),
'@pages': path.resolve(__dirname, 'src/pages'),
'@providers': path.resolve(__dirname, 'src/providers'),
'@selectors': path.resolve(__dirname, 'src/store/selectors'),
'@reducers': path.resolve(__dirname, 'src/store/reducers'),
'@services': path.resolve(__dirname, 'src/store/services'),
'@store': path.resolve(__dirname, 'src/store'),
'@utils': path.resolve(__dirname, 'src/utils'),
};
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
eslint(),
VitePWA({
srcDir: 'src',
filename: 'sw.js',
strategies: 'injectManifest',
manifest: false,
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src/'),
},
},
preview: {
port: 8000,
},
server: {
port: 8080,
},
});