From 8c934ab3b043e61dbe582b0c4def33edf10f2940 Mon Sep 17 00:00:00 2001 From: GuillaumeSD <47183782+GuillaumeSD@users.noreply.github.com> Date: Thu, 8 May 2025 00:43:55 +0200 Subject: [PATCH] refacto : board player header --- .eslintrc.json | 3 +- package-lock.json | 45 +++++++++ package.json | 2 + src/components/board/index.tsx | 69 +++----------- src/components/board/playerHeader.tsx | 35 +++++++ src/hooks/usePlayerNames.ts | 92 +++++++++---------- src/lib/chess.ts | 4 +- src/lib/chessCom.ts | 12 +++ src/pages/_app.tsx | 11 ++- src/sections/analysis/board/index.tsx | 11 +-- .../movesClassificationsRecap/index.tsx | 8 +- .../engineSettings/engineSettingsDialog.tsx | 42 +++++++-- src/sections/play/board.tsx | 15 +-- .../play/gameSettings/gameSettingsDialog.tsx | 21 ++--- src/types/game.ts | 3 +- 15 files changed, 219 insertions(+), 154 deletions(-) create mode 100644 src/components/board/playerHeader.tsx diff --git a/.eslintrc.json b/.eslintrc.json index d6e85eb..9b8c57c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -9,6 +9,7 @@ "eslint:recommended", "plugin:import/recommended", "plugin:import/typescript", + "plugin:@tanstack/eslint-plugin-query/recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended", "plugin:deprecation/recommended", @@ -21,7 +22,7 @@ "sourceType": "module" }, "ignorePatterns": [".out/*"], - "plugins": ["@typescript-eslint", "import", "prettier"], + "plugins": ["@typescript-eslint", "import", "prettier", "@tanstack/query"], "rules": { "quotes": ["error", "double", { "avoidEscape": true }], "prettier/prettier": ["error", {"endOfLine": "auto"}], diff --git a/package-lock.json b/package-lock.json index 2ef2b5b..2dd72e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@mui/material": "^6.3.0", "@mui/x-data-grid": "^7.23.5", "@sentry/nextjs": "^8.47.0", + "@tanstack/react-query": "^5.75.5", "chess.js": "^1.2.0", "firebase": "^11.1.0", "idb": "^8.0.1", @@ -28,6 +29,7 @@ "recharts": "^2.15.0" }, "devDependencies": { + "@tanstack/eslint-plugin-query": "^5.74.7", "@types/node": "^22.10.2", "@types/react": "18.2.11", "@types/react-dom": "^18.3.5", @@ -3644,6 +3646,49 @@ "tslib": "^2.8.0" } }, + "node_modules/@tanstack/eslint-plugin-query": { + "version": "5.74.7", + "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.74.7.tgz", + "integrity": "sha512-EeHuaaYiCOD+XOGyB7LMNEx9OEByAa5lkgP+S3ZggjKJpmIO6iRWeoIYYDKo2F8uc3qXcVhTfC7pn7NddQiNtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.18.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.75.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.75.5.tgz", + "integrity": "sha512-kPDOxtoMn2Ycycb76Givx2fi+2pzo98F9ifHL/NFiahEDpDwSVW6o12PRuQ0lQnBOunhRG5etatAhQij91M3MQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.75.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.75.5.tgz", + "integrity": "sha512-QrLCJe40BgBVlWdAdf2ZEVJ0cISOuEy/HKupId1aTKU6gPJZVhSvZpH+Si7csRflCJphzlQ77Yx6gUxGW9o0XQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.75.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@types/connect": { "version": "3.4.36", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.36.tgz", diff --git a/package.json b/package.json index bd5961f..deb8f72 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@mui/material": "^6.3.0", "@mui/x-data-grid": "^7.23.5", "@sentry/nextjs": "^8.47.0", + "@tanstack/react-query": "^5.75.5", "chess.js": "^1.2.0", "firebase": "^11.1.0", "idb": "^8.0.1", @@ -30,6 +31,7 @@ "recharts": "^2.15.0" }, "devDependencies": { + "@tanstack/eslint-plugin-query": "^5.74.7", "@types/node": "^22.10.2", "@types/react": "18.2.11", "@types/react-dom": "^18.3.5", diff --git a/src/components/board/index.tsx b/src/components/board/index.tsx index 9ad2729..1cc5a2c 100644 --- a/src/components/board/index.tsx +++ b/src/components/board/index.tsx @@ -1,4 +1,4 @@ -import { Grid2 as Grid, Typography } from "@mui/material"; +import { Grid2 as Grid } from "@mui/material"; import { Chessboard } from "react-chessboard"; import { PrimitiveAtom, atom, useAtomValue, useSetAtom } from "jotai"; import { @@ -14,19 +14,17 @@ import { Chess } from "chess.js"; import { getSquareRenderer } from "./squareRenderer"; import { CurrentPosition } from "@/types/eval"; import EvaluationBar from "./evaluationBar"; -import CapturedPieces from "./capturedPieces"; import { moveClassificationColors } from "@/lib/chess"; -import Avatar from "@mui/material/Avatar"; +import { Player } from "@/types/game"; +import PlayerHeader from "./playerHeader"; export interface Props { id: string; canPlay?: Color | boolean; gameAtom: PrimitiveAtom; boardSize?: number; - whitePlayer?: string; - blackPlayer?: string; - whiteAvatar?: string; - blackAvatar?: string; + whitePlayer: Player; + blackPlayer: Player; boardOrientation?: Color; currentPositionAtom?: PrimitiveAtom; showBestMoveArrow?: boolean; @@ -41,8 +39,6 @@ export default function Board({ boardSize, whitePlayer, blackPlayer, - whiteAvatar, - blackAvatar, boardOrientation = Color.White, currentPositionAtom = atom({}), showBestMoveArrow = false, @@ -247,30 +243,11 @@ export default function Board({ paddingLeft={showEvaluationBar ? 2 : 0} size="grow" > - - {/* Player avatar, only render if URL is available */} - {(boardOrientation === Color.White ? blackAvatar : whiteAvatar) && ( - - ) } - - {boardOrientation === Color.White ? blackPlayer : whitePlayer} - - - - + - - {/* Player avatar, only render if URL is available */} - { (boardOrientation === Color.White ? whiteAvatar : blackAvatar) && ( - - ) } - - {boardOrientation === Color.White ? whitePlayer : blackPlayer} - - - - + ); diff --git a/src/components/board/playerHeader.tsx b/src/components/board/playerHeader.tsx new file mode 100644 index 0000000..0b6a637 --- /dev/null +++ b/src/components/board/playerHeader.tsx @@ -0,0 +1,35 @@ +import { Color } from "@/types/enums"; +import { Player } from "@/types/game"; +import { Avatar, Grid2 as Grid, Typography } from "@mui/material"; +import CapturedPieces from "./capturedPieces"; + +export interface Props { + player: Player; + color: Color; + fen: string; +} + +export default function PlayerHeader({ color, player, fen }: Props) { + return ( + + {player.avatarUrl && ( + + )} + + {player.rating ? `${player.name} (${player.rating})` : player.name} + + + + + ); +} diff --git a/src/hooks/usePlayerNames.ts b/src/hooks/usePlayerNames.ts index 7c70faa..6284c17 100644 --- a/src/hooks/usePlayerNames.ts +++ b/src/hooks/usePlayerNames.ts @@ -1,9 +1,13 @@ import { Chess } from "chess.js"; import { PrimitiveAtom, useAtomValue } from "jotai"; import { useGameDatabase } from "./useGameDatabase"; -import { useState, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { getChessComUserAvatar } from "@/lib/chessCom"; +import { Player } from "@/types/game"; -export const usePlayersNames = (gameAtom: PrimitiveAtom) => { +export const usePlayersData = ( + gameAtom: PrimitiveAtom +): { white: Player; black: Player } => { const game = useAtomValue(gameAtom); const { gameFromUrl } = useGameDatabase(); const headers = game.getHeaders(); @@ -16,57 +20,49 @@ export const usePlayersNames = (gameAtom: PrimitiveAtom) => { const whiteName = gameFromUrl?.white?.name || headersWhiteName || "White"; const blackName = gameFromUrl?.black?.name || headersBlackName || "Black"; - const whiteElo = gameFromUrl?.white?.rating || headers.WhiteElo || undefined; - const blackElo = gameFromUrl?.black?.rating || headers.BlackElo || undefined; + const whiteElo = + gameFromUrl?.white?.rating || Number(headers.WhiteElo) || undefined; + const blackElo = + gameFromUrl?.black?.rating || Number(headers.BlackElo) || undefined; - // Determine if this game came from Chess.com (via PGN header or URL) - const siteHeader = gameFromUrl?.site || headers.Site || ""; + const siteHeader = gameFromUrl?.site || headers.Site || "unknown"; const isChessCom = siteHeader.toLowerCase().includes("chess.com"); - // Avatars fetched only for Chess.com games - const [whiteAvatar, setWhiteAvatar] = useState(undefined); - const [blackAvatar, setBlackAvatar] = useState(undefined); + const whiteAvatarUrl = usePlayerAvatarUrl( + whiteName, + isChessCom && !!whiteName && whiteName !== "White" + ); - // Fetch white avatar - useEffect(() => { - if (isChessCom && whiteName && whiteName !== "White") { - // Normalize and encode username - const trimmedWhiteName = whiteName.trim().toLowerCase(); - const usernameParam = encodeURIComponent(trimmedWhiteName); - fetch(`https://api.chess.com/pub/player/${usernameParam}`) - .then((res) => res.json()) - .then((data) => setWhiteAvatar(data.avatar || undefined)) - .catch(() => { - setWhiteAvatar(undefined); - }); - } else { - setWhiteAvatar(undefined); - } - }, [isChessCom, whiteName]); - - // Fetch black avatar - useEffect(() => { - if (isChessCom && blackName && blackName !== "Black") { - // Normalize and encode username - const trimmedBlackName = blackName.trim().toLowerCase(); - const usernameParamBlack = encodeURIComponent(trimmedBlackName); - fetch(`https://api.chess.com/pub/player/${usernameParamBlack}`) - .then((res) => res.json()) - .then((data) => setBlackAvatar(data.avatar || undefined)) - .catch(() => { - setBlackAvatar(undefined); - }); - } else { - setBlackAvatar(undefined); - } - }, [isChessCom, blackName]); + const blackAvatarUrl = usePlayerAvatarUrl( + blackName, + isChessCom && !!blackName && blackName !== "Black" + ); return { - whiteName, - blackName, - whiteElo, - blackElo, - whiteAvatar, - blackAvatar, + white: { + name: whiteName, + rating: whiteElo, + avatarUrl: whiteAvatarUrl ?? undefined, + }, + black: { + name: blackName, + rating: blackElo, + avatarUrl: blackAvatarUrl ?? undefined, + }, }; }; + +const usePlayerAvatarUrl = ( + playerName: string, + enabled: boolean +): string | null | undefined => { + const { data: avatarUrl } = useQuery({ + queryKey: ["CCAvatar", playerName], + enabled, + queryFn: () => getChessComUserAvatar(playerName), + staleTime: 1000 * 60 * 60, // 1 hour + gcTime: 1000 * 60 * 60 * 24, // 1 day + }); + + return avatarUrl; +}; diff --git a/src/lib/chess.ts b/src/lib/chess.ts index 3376124..165dfb0 100644 --- a/src/lib/chess.ts +++ b/src/lib/chess.ts @@ -34,11 +34,11 @@ export const formatGameToDatabase = (game: Chess): Omit => { date: headers.Date, round: headers.Round ?? "?", white: { - name: headers.White, + name: headers.White || "White", rating: headers.WhiteElo ? Number(headers.WhiteElo) : undefined, }, black: { - name: headers.Black, + name: headers.Black || "Black", rating: headers.BlackElo ? Number(headers.BlackElo) : undefined, }, result: headers.Result, diff --git a/src/lib/chessCom.ts b/src/lib/chessCom.ts index 1fab516..63eecb9 100644 --- a/src/lib/chessCom.ts +++ b/src/lib/chessCom.ts @@ -39,3 +39,15 @@ export const getChessComUserRecentGames = async ( return gamesToReturn; }; + +export const getChessComUserAvatar = async ( + username: string +): Promise => { + const usernameParam = encodeURIComponent(username.trim().toLowerCase()); + + const res = await fetch(`https://api.chess.com/pub/player/${usernameParam}`); + const data = await res.json(); + const avatarUrl = data?.avatar; + + return typeof avatarUrl === "string" ? avatarUrl : null; +}; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index c877f20..f863fb7 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -4,11 +4,16 @@ import "@fontsource/roboto/500.css"; import "@fontsource/roboto/700.css"; import { AppProps } from "next/app"; import Layout from "@/sections/layout"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient(); export default function MyApp({ Component, pageProps }: AppProps) { return ( - - - + + + + + ); } diff --git a/src/sections/analysis/board/index.tsx b/src/sections/analysis/board/index.tsx index a1f0828..6bb99c0 100644 --- a/src/sections/analysis/board/index.tsx +++ b/src/sections/analysis/board/index.tsx @@ -11,14 +11,13 @@ import { useMemo } from "react"; import { useScreenSize } from "@/hooks/useScreenSize"; import { Color } from "@/types/enums"; import Board from "@/components/board"; -import { usePlayersNames } from "@/hooks/usePlayerNames"; +import { usePlayersData } from "@/hooks/usePlayerNames"; export default function BoardContainer() { const screenSize = useScreenSize(); const boardOrientation = useAtomValue(boardOrientationAtom); const showBestMoveArrow = useAtomValue(showBestMoveArrowAtom); - const { whiteName, whiteElo, blackName, blackElo, whiteAvatar, blackAvatar } = - usePlayersNames(gameAtom); + const { white, black } = usePlayersData(gameAtom); const boardSize = useMemo(() => { const width = screenSize.width; @@ -38,10 +37,8 @@ export default function BoardContainer() { boardSize={boardSize} canPlay={true} gameAtom={boardAtom} - whitePlayer={whiteElo ? `${whiteName} (${whiteElo})` : whiteName} - blackPlayer={blackElo ? `${blackName} (${blackElo})` : blackName} - whiteAvatar={whiteAvatar} - blackAvatar={blackAvatar} + whitePlayer={white} + blackPlayer={black} boardOrientation={boardOrientation ? Color.White : Color.Black} currentPositionAtom={currentPositionAtom} showBestMoveArrow={showBestMoveArrow} diff --git a/src/sections/analysis/panelBody/classificationTab/movesClassificationsRecap/index.tsx b/src/sections/analysis/panelBody/classificationTab/movesClassificationsRecap/index.tsx index 62a8dc8..58106da 100644 --- a/src/sections/analysis/panelBody/classificationTab/movesClassificationsRecap/index.tsx +++ b/src/sections/analysis/panelBody/classificationTab/movesClassificationsRecap/index.tsx @@ -1,4 +1,4 @@ -import { usePlayersNames } from "@/hooks/usePlayerNames"; +import { usePlayersData } from "@/hooks/usePlayerNames"; import { Grid2 as Grid, Typography } from "@mui/material"; import { gameAtom, gameEvalAtom } from "../../../states"; import { MoveClassification } from "@/types/enums"; @@ -6,7 +6,7 @@ import ClassificationRow from "./classificationRow"; import { useAtomValue } from "jotai"; export default function MovesClassificationsRecap() { - const { whiteName, blackName } = usePlayersNames(gameAtom); + const { white, black } = usePlayersData(gameAtom); const gameEval = useAtomValue(gameEvalAtom); if (!gameEval?.positions.length) return null; @@ -29,13 +29,13 @@ export default function MovesClassificationsRecap() { size={12} > - {whiteName} + {white.name} - {blackName} + {black.name} diff --git a/src/sections/engineSettings/engineSettingsDialog.tsx b/src/sections/engineSettings/engineSettingsDialog.tsx index 1e30088..0271d6a 100644 --- a/src/sections/engineSettings/engineSettingsDialog.tsx +++ b/src/sections/engineSettings/engineSettingsDialog.tsx @@ -92,7 +92,7 @@ export default function EngineSettingsDialog({ open, onClose }: Props) { value={engine} disabled={!isEngineSupported(engine)} > - {engineLabel[engine]} + {engineLabel[engine].full} ))} @@ -129,12 +129,34 @@ export default function EngineSettingsDialog({ open, onClose }: Props) { ); } -const engineLabel: Record = { - [EngineName.Stockfish17]: "Stockfish 17 (75MB)", - [EngineName.Stockfish17Lite]: "Stockfish 17 Lite (6MB)", - [EngineName.Stockfish16_1]: "Stockfish 16.1 (64MB)", - [EngineName.Stockfish16_1Lite]: "Stockfish 16.1 Lite (6MB)", - [EngineName.Stockfish16NNUE]: "Stockfish 16 (40MB)", - [EngineName.Stockfish16]: "Stockfish 16 Lite (HCE)", - [EngineName.Stockfish11]: "Stockfish 11", -}; +export const engineLabel: Record = + { + [EngineName.Stockfish17]: { + full: "Stockfish 17 (75MB)", + small: "Stockfish 17", + }, + [EngineName.Stockfish17Lite]: { + full: "Stockfish 17 Lite (6MB)", + small: "Stockfish 17 Lite", + }, + [EngineName.Stockfish16_1]: { + full: "Stockfish 16.1 (64MB)", + small: "Stockfish 16.1", + }, + [EngineName.Stockfish16_1Lite]: { + full: "Stockfish 16.1 Lite (6MB)", + small: "Stockfish 16.1 Lite", + }, + [EngineName.Stockfish16NNUE]: { + full: "Stockfish 16 (40MB)", + small: "Stockfish 16", + }, + [EngineName.Stockfish16]: { + full: "Stockfish 16 Lite (HCE)", + small: "Stockfish 16 Lite", + }, + [EngineName.Stockfish11]: { + full: "Stockfish 11 (HCE)", + small: "Stockfish 11", + }, + }; diff --git a/src/sections/play/board.tsx b/src/sections/play/board.tsx index b4fe592..d00b5c3 100644 --- a/src/sections/play/board.tsx +++ b/src/sections/play/board.tsx @@ -10,17 +10,18 @@ import { import { useChessActions } from "@/hooks/useChessActions"; import { useEffect, useMemo } from "react"; import { useScreenSize } from "@/hooks/useScreenSize"; -import { Color } from "@/types/enums"; import { useEngine } from "@/hooks/useEngine"; import { uciMoveParams } from "@/lib/chess"; import Board from "@/components/board"; import { useGameData } from "@/hooks/useGameData"; +import { usePlayersData } from "@/hooks/usePlayerNames"; export default function BoardContainer() { const screenSize = useScreenSize(); const engineName = useAtomValue(enginePlayNameAtom); const engine = useEngine(engineName, 1); const game = useAtomValue(gameAtom); + const { white, black } = usePlayersData(gameAtom); const playerColor = useAtomValue(playerColorAtom); const { makeMove: makeGameMove } = useChessActions(gameAtom); const engineSkillLevel = useAtomValue(engineSkillLevelAtom); @@ -72,16 +73,8 @@ export default function BoardContainer() { canPlay={isGameInProgress ? playerColor : false} gameAtom={gameAtom} boardSize={boardSize} - whitePlayer={ - playerColor === Color.White - ? "You 🧠" - : `Stockfish level ${engineSkillLevel} 🤖` - } - blackPlayer={ - playerColor === Color.Black - ? "You 🧠" - : `Stockfish level ${engineSkillLevel} 🤖` - } + whitePlayer={white} + blackPlayer={black} boardOrientation={playerColor} currentPositionAtom={gameDataAtom} /> diff --git a/src/sections/play/gameSettings/gameSettingsDialog.tsx b/src/sections/play/gameSettings/gameSettingsDialog.tsx index ab6348f..6c6a21f 100644 --- a/src/sections/play/gameSettings/gameSettingsDialog.tsx +++ b/src/sections/play/gameSettings/gameSettingsDialog.tsx @@ -32,6 +32,7 @@ import { logAnalyticsEvent } from "@/lib/firebase"; import { useEffect } from "react"; import { isEngineSupported } from "@/lib/engine/shared"; import { Stockfish16_1 } from "@/lib/engine/stockfish16_1"; +import { engineLabel } from "@/sections/engineSettings/engineSettingsDialog"; interface Props { open: boolean; @@ -55,9 +56,13 @@ export default function GameSettingsDialog({ open, onClose }: Props) { onClose(); resetGame({ whiteName: - playerColor === Color.White ? "You" : `Stockfish level ${skillLevel}`, + playerColor === Color.White + ? "You" + : `${engineLabel[engineName].small} level ${skillLevel}`, blackName: - playerColor === Color.Black ? "You" : `Stockfish level ${skillLevel}`, + playerColor === Color.Black + ? "You" + : `${engineLabel[engineName].small} level ${skillLevel}`, }); playGameStartSound(); setIsGameInProgress(true); @@ -117,7 +122,7 @@ export default function GameSettingsDialog({ open, onClose }: Props) { value={engine} disabled={!isEngineSupported(engine)} > - {engineLabel[engine]} + {engineLabel[engine].full} ))} @@ -166,13 +171,3 @@ export default function GameSettingsDialog({ open, onClose }: Props) { ); } - -const engineLabel: Record = { - [EngineName.Stockfish17]: "Stockfish 17 (75MB)", - [EngineName.Stockfish17Lite]: "Stockfish 17 Lite (6MB)", - [EngineName.Stockfish16_1]: "Stockfish 16.1 (64MB)", - [EngineName.Stockfish16_1Lite]: "Stockfish 16.1 Lite (6MB)", - [EngineName.Stockfish16NNUE]: "Stockfish 16 (40MB)", - [EngineName.Stockfish16]: "Stockfish 16 Lite (HCE)", - [EngineName.Stockfish11]: "Stockfish 11", -}; diff --git a/src/types/game.ts b/src/types/game.ts index ae1937a..6c32bc6 100644 --- a/src/types/game.ts +++ b/src/types/game.ts @@ -16,6 +16,7 @@ export interface Game { } export interface Player { - name?: string; + name: string; rating?: number; + avatarUrl?: string; }