From 2a74b62bae4401fd8d60134559fd39109811128b Mon Sep 17 00:00:00 2001 From: GuillaumeSD Date: Thu, 22 Feb 2024 00:26:07 +0100 Subject: [PATCH] feat : add games database --- package-lock.json | 37 +++++ package.json | 2 + src/hooks/useGameDatabase.ts | 87 +++++++++++ src/hooks/useLocalStorage.ts | 8 +- src/lib/chess.ts | 19 +++ src/lib/engine/stockfish.ts | 2 +- src/pages/game-database.tsx | 135 ++++++++++++++++++ src/pages/index.tsx | 3 + .../selectGame/selectGameOrigin.tsx | 1 - src/sections/gameReport/states.ts | 2 +- src/sections/layout/NavMenu.tsx | 5 + src/sections/layout/index.tsx | 7 +- src/sections/loadGame/loadGameButton.tsx | 17 +++ src/sections/loadGame/loadGameDialog.tsx | 118 +++++++++++++++ src/types/enums.ts | 1 - src/types/{eval.ts => game.ts} | 13 ++ 16 files changed, 450 insertions(+), 7 deletions(-) create mode 100644 src/hooks/useGameDatabase.ts create mode 100644 src/pages/game-database.tsx create mode 100644 src/sections/loadGame/loadGameButton.tsx create mode 100644 src/sections/loadGame/loadGameDialog.tsx rename src/types/{eval.ts => game.ts} (55%) diff --git a/package-lock.json b/package-lock.json index 746a67c..a087840 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,9 @@ "@fontsource/roboto": "^5.0.3", "@iconify/react": "^4.1.0", "@mui/material": "^5.13.4", + "@mui/x-data-grid": "^6.19.4", "chess.js": "^1.0.0-beta.7", + "idb": "^8.0.0", "jotai": "^2.6.4", "next": "13.5.6", "react": "18.2.0", @@ -757,6 +759,31 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" }, + "node_modules/@mui/x-data-grid": { + "version": "6.19.4", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.19.4.tgz", + "integrity": "sha512-qXBe2mSetdsl3ZPqB/1LpKNkEiaYUiFXIaMHTIjuzLyusXgt+w7UsHYO7R+aJYUU7c3FeHla0R1nwRMY3kZ5ng==", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@mui/utils": "^5.14.16", + "clsx": "^2.0.0", + "prop-types": "^15.8.1", + "reselect": "^4.1.8" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + }, + "peerDependencies": { + "@mui/material": "^5.4.1", + "@mui/system": "^5.4.1", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + } + }, "node_modules/@next/env": { "version": "13.5.6", "resolved": "https://registry.npmjs.org/@next/env/-/env-13.5.6.tgz", @@ -2956,6 +2983,11 @@ "node": ">=14.18.0" } }, + "node_modules/idb": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.0.tgz", + "integrity": "sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==" + }, "node_modules/ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", @@ -4254,6 +4286,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", + "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==" + }, "node_modules/resolve": { "version": "1.22.2", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", diff --git a/package.json b/package.json index 32aee56..b6e9cd2 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "@fontsource/roboto": "^5.0.3", "@iconify/react": "^4.1.0", "@mui/material": "^5.13.4", + "@mui/x-data-grid": "^6.19.4", "chess.js": "^1.0.0-beta.7", + "idb": "^8.0.0", "jotai": "^2.6.4", "next": "13.5.6", "react": "18.2.0", diff --git a/src/hooks/useGameDatabase.ts b/src/hooks/useGameDatabase.ts new file mode 100644 index 0000000..5c1d6ba --- /dev/null +++ b/src/hooks/useGameDatabase.ts @@ -0,0 +1,87 @@ +import { Game, GameEval } from "@/types/game"; +import { openDB, DBSchema, IDBPDatabase } from "idb"; +import { atom, useAtom } from "jotai"; +import { useCallback, useEffect, useState } from "react"; + +interface GameDatabaseSchema extends DBSchema { + games: { + value: Game; + key: number; + }; +} + +const gamesAtom = atom([]); +const fetchGamesAtom = atom(false); + +export const useGameDatabase = (shouldFetchGames?: boolean) => { + const [db, setDb] = useState | null>(null); + const [games, setGames] = useAtom(gamesAtom); + const [fetchGames, setFetchGames] = useAtom(fetchGamesAtom); + + useEffect(() => { + if (shouldFetchGames !== undefined) { + setFetchGames(shouldFetchGames); + } + }, [shouldFetchGames, setFetchGames]); + + useEffect(() => { + const initDatabase = async () => { + const db = await openDB("games", 1, { + upgrade(db) { + db.createObjectStore("games", { keyPath: "id", autoIncrement: true }); + }, + }); + setDb(db); + }; + + initDatabase(); + }, []); + + const loadGames = useCallback(async () => { + if (db && fetchGames) { + const games = await db.getAll("games"); + setGames(games); + } + }, [db, fetchGames, setGames]); + + useEffect(() => { + loadGames(); + }, [loadGames]); + + const addGame = async (game: Omit) => { + if (!db) throw new Error("Database not initialized"); + + await db.add("games", game as Game); + + loadGames(); + }; + + const setGameEval = async (gameId: number, evaluation: GameEval) => { + if (!db) throw new Error("Database not initialized"); + + const game = await db.get("games", gameId); + if (!game) throw new Error("Game not found"); + + await db.put("games", { ...game, eval: evaluation }); + + loadGames(); + }; + + const getGame = async (gameId: number) => { + if (!db) return undefined; + + return db.get("games", gameId); + }; + + const deleteGame = async (gameId: number) => { + if (!db) throw new Error("Database not initialized"); + + await db.delete("games", gameId); + + loadGames(); + }; + + const isReady = db !== null; + + return { addGame, setGameEval, getGame, deleteGame, games, isReady }; +}; diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts index 86e28f3..d636604 100644 --- a/src/hooks/useLocalStorage.ts +++ b/src/hooks/useLocalStorage.ts @@ -5,17 +5,21 @@ type SetValue = Dispatch>; export function useLocalStorage( key: string, initialValue: T -): [T, SetValue] { - const [storedValue, setStoredValue] = useState(initialValue); +): [T | null, SetValue] { + const [storedValue, setStoredValue] = useState(null); useEffect(() => { const item = window.localStorage.getItem(key); if (item) { setStoredValue(parseJSON(item)); + } else { + setStoredValue(initialValue); } }, [key]); const setValue: SetValue = (value) => { + if (storedValue === null) + throw new Error("setLocalStorage value isn't ready yet"); const newValue = value instanceof Function ? value(storedValue) : value; window.localStorage.setItem(key, JSON.stringify(newValue)); setStoredValue(newValue); diff --git a/src/lib/chess.ts b/src/lib/chess.ts index e3898b2..e792650 100644 --- a/src/lib/chess.ts +++ b/src/lib/chess.ts @@ -1,3 +1,4 @@ +import { Game } from "@/types/game"; import { Chess } from "chess.js"; export const pgnToFens = (pgn: string): string[] => { @@ -5,3 +6,21 @@ export const pgnToFens = (pgn: string): string[] => { game.loadPgn(pgn); return game.history({ verbose: true }).map((move) => move.before); }; + +export const getGameFromPgn = (pgn: string): Omit => { + const game = new Chess(); + game.loadPgn(pgn); + + const headers: Record = game.header(); + + return { + pgn, + event: headers.Event, + site: headers.Site, + date: headers.Date, + round: headers.Round, + white: headers.White, + black: headers.Black, + result: headers.Result, + }; +}; diff --git a/src/lib/engine/stockfish.ts b/src/lib/engine/stockfish.ts index d631c0e..8acc7e0 100644 --- a/src/lib/engine/stockfish.ts +++ b/src/lib/engine/stockfish.ts @@ -1,4 +1,4 @@ -import { GameEval, LineEval, MoveEval } from "@/types/eval"; +import { GameEval, LineEval, MoveEval } from "@/types/game"; export class Stockfish { private worker: Worker; diff --git a/src/pages/game-database.tsx b/src/pages/game-database.tsx new file mode 100644 index 0000000..30b9ce5 --- /dev/null +++ b/src/pages/game-database.tsx @@ -0,0 +1,135 @@ +import { Grid, Typography } from "@mui/material"; +import { Icon } from "@iconify/react"; +import { + DataGrid, + GridColDef, + GridLocaleText, + GRID_DEFAULT_LOCALE_TEXT, + GridActionsCellItem, + GridRowId, +} from "@mui/x-data-grid"; +import { useCallback, useMemo } from "react"; +import { red } from "@mui/material/colors"; +import LoadGameButton from "@/sections/loadGame/loadGameButton"; +import { useGameDatabase } from "@/hooks/useGameDatabase"; + +const gridLocaleText: GridLocaleText = { + ...GRID_DEFAULT_LOCALE_TEXT, + noRowsLabel: "No games found", +}; + +export default function GameDatabase() { + const { games, deleteGame } = useGameDatabase(true); + + const handleDeleteGameRow = useCallback( + (id: GridRowId) => async () => { + if (typeof id !== "number") { + throw new Error("Unable to remove game"); + } + await deleteGame(id); + }, + [deleteGame] + ); + + const columns: GridColDef[] = useMemo( + () => [ + { + field: "event", + headerName: "Event", + width: 150, + }, + { + field: "site", + headerName: "Site", + width: 150, + }, + { + field: "date", + headerName: "Date", + width: 150, + }, + { + field: "round", + headerName: "Round", + headerAlign: "center", + align: "center", + width: 150, + }, + { + field: "white", + headerName: "White", + width: 150, + headerAlign: "center", + align: "center", + }, + { + field: "result", + headerName: "Result", + headerAlign: "center", + align: "center", + width: 100, + }, + { + field: "black", + headerName: "Black", + width: 150, + headerAlign: "center", + align: "center", + }, + { + field: "actions", + type: "actions", + headerName: "Delete", + width: 100, + cellClassName: "actions", + getActions: ({ id }) => { + return [ + + } + label="Supprimer" + onClick={handleDeleteGameRow(id)} + color="inherit" + key={`${id}-delete-button`} + />, + ]; + }, + }, + ], + [handleDeleteGameRow] + ); + + return ( + + + + + + + + + You have {0} games in your database + + + + + + + ); +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index fd02f93..13e73f2 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -38,6 +38,9 @@ export default function GameReport() { xs={12} sx={{ backgroundColor: "secondary.main", + borderRadius: 2, + borderColor: "primary.main", + borderWidth: 2, }} paddingY={3} > diff --git a/src/sections/gameReport/selectGame/selectGameOrigin.tsx b/src/sections/gameReport/selectGame/selectGameOrigin.tsx index a2f226d..464fdd1 100644 --- a/src/sections/gameReport/selectGame/selectGameOrigin.tsx +++ b/src/sections/gameReport/selectGame/selectGameOrigin.tsx @@ -42,5 +42,4 @@ const gameOriginLabel: Record = { [GameOrigin.Pgn]: "PGN", [GameOrigin.ChessCom]: "Chess.com", [GameOrigin.Lichess]: "Lichess", - [GameOrigin.Json]: "JSON", }; diff --git a/src/sections/gameReport/states.ts b/src/sections/gameReport/states.ts index 59bee2d..c8b20f6 100644 --- a/src/sections/gameReport/states.ts +++ b/src/sections/gameReport/states.ts @@ -1,4 +1,4 @@ -import { GameEval } from "@/types/eval"; +import { GameEval } from "@/types/game"; import { Chess } from "chess.js"; import { atom } from "jotai"; diff --git a/src/sections/layout/NavMenu.tsx b/src/sections/layout/NavMenu.tsx index 126da21..512e6b4 100644 --- a/src/sections/layout/NavMenu.tsx +++ b/src/sections/layout/NavMenu.tsx @@ -13,6 +13,11 @@ import { const MenuOptions = [ { text: "Game Report", icon: "streamline:magnifying-glass-solid", href: "/" }, + { + text: "Game Database", + icon: "streamline:database-solid", + href: "/game-database", + }, ]; interface Props { diff --git a/src/sections/layout/index.tsx b/src/sections/layout/index.tsx index 79b3994..506e6e3 100644 --- a/src/sections/layout/index.tsx +++ b/src/sections/layout/index.tsx @@ -15,14 +15,19 @@ export default function Layout({ children }: PropsWithChildren) { error: { main: red[400], }, + primary: { + main: "#5d9948", + }, secondary: { - main: useDarkMode ? "#424242" : "#90caf9", + main: useDarkMode ? "#424242" : "#ffffff", }, }, }), [useDarkMode] ); + if (useDarkMode === null) return null; + return ( diff --git a/src/sections/loadGame/loadGameButton.tsx b/src/sections/loadGame/loadGameButton.tsx new file mode 100644 index 0000000..5f1f18b --- /dev/null +++ b/src/sections/loadGame/loadGameButton.tsx @@ -0,0 +1,17 @@ +import { Button } from "@mui/material"; +import { useState } from "react"; +import NewGameDialog from "./loadGameDialog"; + +export default function LoadGameButton() { + const [openDialog, setOpenDialog] = useState(false); + + return ( + <> + + + setOpenDialog(false)} /> + + ); +} diff --git a/src/sections/loadGame/loadGameDialog.tsx b/src/sections/loadGame/loadGameDialog.tsx new file mode 100644 index 0000000..6a641e8 --- /dev/null +++ b/src/sections/loadGame/loadGameDialog.tsx @@ -0,0 +1,118 @@ +import { useGameDatabase } from "@/hooks/useGameDatabase"; +import { getGameFromPgn } from "@/lib/chess"; +import { GameOrigin } from "@/types/enums"; +import { + MenuItem, + Select, + Button, + Dialog, + DialogTitle, + DialogContent, + Box, + FormControl, + InputLabel, + OutlinedInput, + DialogActions, + TextField, + Typography, +} from "@mui/material"; +import { useState } from "react"; + +interface Props { + open: boolean; + onClose: () => void; +} + +export default function NewGameDialog({ open, onClose }: Props) { + const [pgn, setPgn] = useState(""); + const [parsingError, setParsingError] = useState(""); + const { addGame } = useGameDatabase(); + + const handleAddGame = () => { + if (!pgn) return; + setParsingError(""); + + try { + const gameToAdd = getGameFromPgn(pgn); + addGame(gameToAdd); + handleClose(); + } catch (error) { + console.error(error); + setParsingError( + error instanceof Error + ? `${error.message} !` + : "Unknown error while parsing PGN !" + ); + } + }; + + const handleClose = () => { + setPgn(""); + setParsingError(""); + onClose(); + }; + + return ( + + + Add a game to your database + + + Only PGN input is supported at the moment + + + Game origin + + + + setPgn(e.target.value)} + /> + + {parsingError && ( + + + {parsingError} + + + )} + + + + + + + + ); +} + +const gameOriginLabel: Record = { + [GameOrigin.Pgn]: "PGN", + [GameOrigin.ChessCom]: "Chess.com", + [GameOrigin.Lichess]: "Lichess", +}; diff --git a/src/types/enums.ts b/src/types/enums.ts index ff5b676..bc11f22 100644 --- a/src/types/enums.ts +++ b/src/types/enums.ts @@ -2,5 +2,4 @@ export enum GameOrigin { Pgn = "pgn", ChessCom = "chesscom", Lichess = "lichess", - Json = "json", } diff --git a/src/types/eval.ts b/src/types/game.ts similarity index 55% rename from src/types/eval.ts rename to src/types/game.ts index b1da711..ebcebba 100644 --- a/src/types/eval.ts +++ b/src/types/game.ts @@ -14,3 +14,16 @@ export interface GameEval { whiteAccuracy: number; blackAccuracy: number; } + +export interface Game { + id: number; + pgn: string; + event?: string; + site?: string; + date?: string; + round?: string; + white?: string; + black?: string; + result?: string; + eval?: GameEval; +}