refacto : board player header

This commit is contained in:
GuillaumeSD
2025-05-08 00:43:55 +02:00
parent 8167b9b621
commit 8c934ab3b0
15 changed files with 219 additions and 154 deletions

View File

@@ -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"}],

45
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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<Chess>;
boardSize?: number;
whitePlayer?: string;
blackPlayer?: string;
whiteAvatar?: string;
blackAvatar?: string;
whitePlayer: Player;
blackPlayer: Player;
boardOrientation?: Color;
currentPositionAtom?: PrimitiveAtom<CurrentPosition>;
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"
>
<Grid
container
justifyContent="center"
alignItems="center"
columnGap={2}
size={12}
>
{/* Player avatar, only render if URL is available */}
{(boardOrientation === Color.White ? blackAvatar : whiteAvatar) && (
<Avatar
src={boardOrientation === Color.White ? blackAvatar : whiteAvatar}
variant="circular"
sx={{ width: 24, height: 24 }}
/>
) }
<Typography>
{boardOrientation === Color.White ? blackPlayer : whitePlayer}
</Typography>
<CapturedPieces
fen={gameFen}
<PlayerHeader
color={boardOrientation === Color.White ? Color.Black : Color.White}
fen={gameFen}
player={boardOrientation === Color.White ? blackPlayer : whitePlayer}
/>
</Grid>
<Grid
container
@@ -304,27 +281,11 @@ export default function Board({
/>
</Grid>
<Grid
container
justifyContent="center"
alignItems="center"
columnGap={2}
size={12}
>
{/* Player avatar, only render if URL is available */}
{ (boardOrientation === Color.White ? whiteAvatar : blackAvatar) && (
<Avatar
src={boardOrientation === Color.White ? whiteAvatar : blackAvatar}
variant="circular"
sx={{ width: 24, height: 24 }}
<PlayerHeader
color={boardOrientation}
fen={gameFen}
player={boardOrientation === Color.White ? whitePlayer : blackPlayer}
/>
) }
<Typography>
{boardOrientation === Color.White ? whitePlayer : blackPlayer}
</Typography>
<CapturedPieces fen={gameFen} color={boardOrientation} />
</Grid>
</Grid>
</Grid>
);

View File

@@ -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 (
<Grid
container
justifyContent="center"
alignItems="center"
columnGap={2}
size={12}
>
{player.avatarUrl && (
<Avatar
src={player.avatarUrl}
variant="circular"
sx={{ width: 24, height: 24 }}
/>
)}
<Typography>
{player.rating ? `${player.name} (${player.rating})` : player.name}
</Typography>
<CapturedPieces fen={fen} color={color} />
</Grid>
);
}

View File

@@ -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<Chess>) => {
export const usePlayersData = (
gameAtom: PrimitiveAtom<Chess>
): { white: Player; black: Player } => {
const game = useAtomValue(gameAtom);
const { gameFromUrl } = useGameDatabase();
const headers = game.getHeaders();
@@ -16,57 +20,49 @@ export const usePlayersNames = (gameAtom: PrimitiveAtom<Chess>) => {
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<string | undefined>(undefined);
const [blackAvatar, setBlackAvatar] = useState<string | undefined>(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;
};

View File

@@ -34,11 +34,11 @@ export const formatGameToDatabase = (game: Chess): Omit<Game, "id"> => {
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,

View File

@@ -39,3 +39,15 @@ export const getChessComUserRecentGames = async (
return gamesToReturn;
};
export const getChessComUserAvatar = async (
username: string
): Promise<string | null> => {
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;
};

View File

@@ -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 (
<QueryClientProvider client={queryClient}>
<Layout>
<Component {...pageProps} />
</Layout>
</QueryClientProvider>
);
}

View File

@@ -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}

View File

@@ -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}
>
<Typography width="12rem" align="center" noWrap fontSize="0.9rem">
{whiteName}
{white.name}
</Typography>
<Typography width="7rem" />
<Typography width="12rem" align="center" noWrap fontSize="0.9rem">
{blackName}
{black.name}
</Typography>
</Grid>

View File

@@ -92,7 +92,7 @@ export default function EngineSettingsDialog({ open, onClose }: Props) {
value={engine}
disabled={!isEngineSupported(engine)}
>
{engineLabel[engine]}
{engineLabel[engine].full}
</MenuItem>
))}
</Select>
@@ -129,12 +129,34 @@ export default function EngineSettingsDialog({ open, onClose }: Props) {
);
}
const engineLabel: Record<EngineName, string> = {
[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, { small: string; full: string }> =
{
[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",
},
};

View File

@@ -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}
/>

View File

@@ -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}
</MenuItem>
))}
</Select>
@@ -166,13 +171,3 @@ export default function GameSettingsDialog({ open, onClose }: Props) {
</Dialog>
);
}
const engineLabel: Record<EngineName, string> = {
[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",
};

View File

@@ -16,6 +16,7 @@ export interface Game {
}
export interface Player {
name?: string;
name: string;
rating?: number;
avatarUrl?: string;
}