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

@@ -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}
color={boardOrientation === Color.White ? Color.Black : Color.White}
/>
</Grid>
<PlayerHeader
color={boardOrientation === Color.White ? Color.Black : Color.White}
fen={gameFen}
player={boardOrientation === Color.White ? blackPlayer : whitePlayer}
/>
<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 }}
/>
) }
<Typography>
{boardOrientation === Color.White ? whitePlayer : blackPlayer}
</Typography>
<CapturedPieces fen={gameFen} color={boardOrientation} />
</Grid>
<PlayerHeader
color={boardOrientation}
fen={gameFen}
player={boardOrientation === Color.White ? whitePlayer : blackPlayer}
/>
</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 (
<Layout>
<Component {...pageProps} />
</Layout>
<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;
}