Compare commits

..

10 Commits

Author SHA1 Message Date
haunter
c187b7a395 Initial commit: migrated from GitHub 2025-07-24 13:09:51 +02:00
GuillaumeSD
05908e3b03 fix : reset board to starting position 2025-07-04 17:57:21 +02:00
GuillaumeSD
2674243b0c fix : issue#58 startingPositionInput trim 2025-07-04 17:31:37 +02:00
GuillaumeSD
aece5db7ce fix : PR#57 game loading from URL 2025-07-04 17:21:46 +02:00
supertorpe
7cbad399ed feat : add autoload pgn with a base64 encoded "pgn" query param (#57) 2025-07-04 15:45:45 +02:00
GuillaumeSD
da30ba1fc6 fix : filter out games without pgn 2025-06-20 23:22:06 +02:00
supertorpe
021d36adb1 fix : predictive search, show the most recently used first (#48) 2025-06-16 16:38:05 +02:00
Roman A
d4158c8d11 doc : update README.md (#47) 2025-06-16 16:36:19 +02:00
GuillaumeSD
f906c81c67 style : enhance load game UI 2025-06-16 05:01:52 +02:00
GuillaumeSD
5e2d944513 refacto : load game 2025-06-10 02:42:43 +02:00
28 changed files with 487 additions and 680 deletions

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
# Use Node LTS image
FROM node:18-alpine
# Set working directory
WORKDIR /app
# Copy dependency files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy the rest of the app
COPY . .
# Expose development port
EXPOSE 3000
# Start the dev server
CMD ["npm", "run", "dev"]

View File

@@ -41,7 +41,8 @@ Deployed on AWS with [AWS CDK](https://docs.aws.amazon.com/cdk/v2/guide/home.htm
## Running the app in dev mode ## Running the app in dev mode
At least [Node.js](https://nodejs.org) 22.11 is required. > [!IMPORTANT]
> At least [Node.js](https://nodejs.org) 22.11 is required.
Install the dependencies : Install the dependencies :

14
docker-compose.yml Normal file
View File

@@ -0,0 +1,14 @@
version: '3.8'
services:
chesskit:
build: .
ports:
- "3100:3000"
volumes:
- .:/app
- /app/node_modules # Prevent overwriting node_modules
environment:
- NODE_ENV=development
stdin_open: true
tty: true

37
package-lock.json generated
View File

@@ -3286,9 +3286,9 @@
} }
}, },
"node_modules/@sentry/bundler-plugin-core/node_modules/brace-expansion": { "node_modules/@sentry/bundler-plugin-core/node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
@@ -4108,9 +4108,9 @@
} }
}, },
"node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4248,9 +4248,9 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": { "node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4330,9 +4330,9 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4457,9 +4457,9 @@
} }
}, },
"node_modules/@typescript-eslint/utils/node_modules/brace-expansion": { "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -5512,10 +5512,11 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
"concat-map": "0.0.1" "concat-map": "0.0.1"

View File

@@ -39,6 +39,9 @@ if (
"RuntimeError: Out of bounds memory access (evaluating 'n.apply(null,arguments)')", "RuntimeError: Out of bounds memory access (evaluating 'n.apply(null,arguments)')",
"Uncaught RuntimeError: Aborted(CompileError: WebAssembly.instantiate():", "Uncaught RuntimeError: Aborted(CompileError: WebAssembly.instantiate():",
"Uncaught RangeError: WebAssembly.Memory(): could not allocate memory", "Uncaught RangeError: WebAssembly.Memory(): could not allocate memory",
"Aborted(NetworkError: Failed to execute 'send' on 'XMLHttpRequest'",
"Aborted(CompileError: WebAssembly.Module doesn't parse at byte",
"Aborted(NetworkError: A network error occurred",
], ],
}); });
} }

View File

@@ -1,10 +1,11 @@
import { ChessComRawGameData } from "@/types/chessCom"; import { ChessComGame } from "@/types/chessCom";
import { getPaddedNumber } from "./helpers"; import { getPaddedNumber } from "./helpers";
import { LoadedGame } from "@/types/game";
export const getChessComUserRecentGames = async ( export const getChessComUserRecentGames = async (
username: string, username: string,
signal?: AbortSignal signal?: AbortSignal
): Promise<ChessComRawGameData[]> => { ): Promise<LoadedGame[]> => {
const date = new Date(); const date = new Date();
const year = date.getUTCFullYear(); const year = date.getUTCFullYear();
const month = date.getUTCMonth() + 1; const month = date.getUTCMonth() + 1;
@@ -24,7 +25,7 @@ export const getChessComUserRecentGames = async (
throw new Error("Error fetching games from Chess.com"); throw new Error("Error fetching games from Chess.com");
} }
const games: ChessComRawGameData[] = data?.games ?? []; const games: ChessComGame[] = data?.games ?? [];
if (games.length < 50) { if (games.length < 50) {
const previousMonth = month === 1 ? 12 : month - 1; const previousMonth = month === 1 ? 12 : month - 1;
@@ -41,8 +42,10 @@ export const getChessComUserRecentGames = async (
} }
const gamesToReturn = games const gamesToReturn = games
.filter((game) => game.pgn && game.end_time)
.sort((a, b) => b.end_time - a.end_time) .sort((a, b) => b.end_time - a.end_time)
.slice(0, 50); .slice(0, 50)
.map(formatChessComGame);
return gamesToReturn; return gamesToReturn;
}; };
@@ -58,3 +61,57 @@ export const getChessComUserAvatar = async (
return typeof avatarUrl === "string" ? avatarUrl : null; return typeof avatarUrl === "string" ? avatarUrl : null;
}; };
const formatChessComGame = (data: ChessComGame): LoadedGame => {
const result = data.pgn.match(/\[Result "(.*?)"]/)?.[1];
const movesNb = data.pgn.match(/\d+?\. /g)?.length;
return {
id: data.uuid || data.url?.split("/").pop() || data.id,
pgn: data.pgn || "",
white: {
name: data.white?.username || "White",
rating: data.white?.rating || 0,
title: data.white?.title,
},
black: {
name: data.black?.username || "Black",
rating: data.black?.rating || 0,
title: data.black?.title,
},
result,
timeControl: getGameTimeControl(data),
date: data.end_time
? new Date(data.end_time * 1000).toLocaleDateString()
: new Date().toLocaleDateString(),
movesNb: movesNb ? movesNb * 2 : undefined,
url: data.url,
};
};
const getGameTimeControl = (game: ChessComGame): string | undefined => {
const rawTimeControl = game.time_control;
if (!rawTimeControl) return undefined;
const [firstPart, secondPart] = rawTimeControl.split("+");
if (!firstPart) return undefined;
const timeControl = Number(firstPart);
const increment = secondPart ? `+${secondPart}` : "";
if (timeControl < 60) return `${timeControl}s${increment}`;
if (timeControl < 3600) {
const minutes = Math.floor(timeControl / 60);
const seconds = timeControl % 60;
return seconds
? `${minutes}m${getPaddedNumber(seconds)}s${increment}`
: `${minutes}m${increment}`;
}
const hours = Math.floor(timeControl / 3600);
const minutes = Math.floor((timeControl % 3600) / 60);
return minutes
? `${hours}h${getPaddedNumber(minutes)}m${increment}`
: `${hours}h${increment}`;
};

View File

@@ -4,25 +4,34 @@ import { Stockfish16 } from "./stockfish16";
import { Stockfish16_1 } from "./stockfish16_1"; import { Stockfish16_1 } from "./stockfish16_1";
import { Stockfish17 } from "./stockfish17"; import { Stockfish17 } from "./stockfish17";
export const isWasmSupported = () => export const isWasmSupported = (): boolean =>
typeof WebAssembly === "object" && typeof WebAssembly === "object" &&
WebAssembly.validate( WebAssembly.validate(
Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00) Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00)
); );
export const isMultiThreadSupported = () => { export const isIosDevice = (): boolean => {
if (typeof navigator !== "undefined") {
return /iPhone|iPad|iPod/i.test(navigator.userAgent);
}
return false;
};
export const isMobileDevice = (): boolean => {
if (typeof navigator !== "undefined") {
return isIosDevice() || /Android|Opera Mini/i.test(navigator.userAgent);
}
return false;
};
export const isMultiThreadSupported = (): boolean => {
try { try {
return SharedArrayBuffer !== undefined && !isIosDevice(); return typeof SharedArrayBuffer !== "undefined" && !isIosDevice();
} catch { } catch {
return false; return false;
} }
}; };
export const isIosDevice = () => /iPhone|iPad|iPod/i.test(navigator.userAgent);
export const isMobileDevice = () =>
isIosDevice() || /Android|Opera Mini/i.test(navigator.userAgent);
export const isEngineSupported = (name: EngineName): boolean => { export const isEngineSupported = (name: EngineName): boolean => {
switch (name) { switch (name) {
case EngineName.Stockfish17: case EngineName.Stockfish17:
@@ -36,5 +45,7 @@ export const isEngineSupported = (name: EngineName): boolean => {
return Stockfish16.isSupported(); return Stockfish16.isSupported();
case EngineName.Stockfish11: case EngineName.Stockfish11:
return Stockfish11.isSupported(); return Stockfish11.isSupported();
default:
return false;
} }
}; };

View File

@@ -45,16 +45,21 @@ export const sendCommandsToWorker = (
}; };
export const getRecommendedWorkersNb = (): number => { export const getRecommendedWorkersNb = (): number => {
const maxWorkersNbFromThreads = Math.max( let maxWorkersNbFromThreads = 4;
1, let maxWorkersNbFromMemory = 4;
Math.round(navigator.hardwareConcurrency - 4),
Math.floor((navigator.hardwareConcurrency * 2) / 3)
);
const maxWorkersNbFromMemory = if (typeof navigator !== "undefined") {
"deviceMemory" in navigator && typeof navigator.deviceMemory === "number" maxWorkersNbFromThreads = Math.max(
? Math.max(1, Math.round(navigator.deviceMemory)) 1,
: 4; Math.round(navigator.hardwareConcurrency - 4),
Math.floor((navigator.hardwareConcurrency * 2) / 3)
);
maxWorkersNbFromMemory =
"deviceMemory" in navigator && typeof navigator.deviceMemory === "number"
? Math.max(1, Math.round(navigator.deviceMemory))
: 4;
}
const maxWorkersNbFromDevice = isIosDevice() ? 2 : isMobileDevice() ? 4 : 8; const maxWorkersNbFromDevice = isIosDevice() ? 2 : isMobileDevice() ? 4 : 8;

View File

@@ -16,3 +16,13 @@ export const isInViewport = (element: HTMLElement) => {
export const sleep = (ms: number) => export const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms)); new Promise((resolve) => setTimeout(resolve, ms));
export const decodeBase64 = (encoded: string | null): string | null => {
if (!encoded) return null;
try {
return atob(encoded);
} catch (err) {
console.error("Error decoding base64:", err);
return null;
}
};

View File

@@ -3,11 +3,12 @@ import { sortLines } from "./engine/helpers/parseResults";
import { import {
LichessError, LichessError,
LichessEvalBody, LichessEvalBody,
LichessRawGameData, LichessGame,
LichessResponse, LichessResponse,
} from "@/types/lichess"; } from "@/types/lichess";
import { logErrorToSentry } from "./sentry"; import { logErrorToSentry } from "./sentry";
import { formatUciPv } from "./chess"; import { formatUciPv } from "./chess";
import { LoadedGame } from "@/types/game";
export const getLichessEval = async ( export const getLichessEval = async (
fen: string, fen: string,
@@ -58,7 +59,7 @@ export const getLichessEval = async (
export const getLichessUserRecentGames = async ( export const getLichessUserRecentGames = async (
username: string, username: string,
signal?: AbortSignal signal?: AbortSignal
): Promise<LichessRawGameData[]> => { ): Promise<LoadedGame[]> => {
const res = await fetch( const res = await fetch(
`https://lichess.org/api/games/user/${username}?until=${Date.now()}&max=50&pgnInJson=true&sort=dateDesc&clocks=true`, `https://lichess.org/api/games/user/${username}?until=${Date.now()}&max=50&pgnInJson=true&sort=dateDesc&clocks=true`,
{ method: "GET", headers: { accept: "application/x-ndjson" }, signal } { method: "GET", headers: { accept: "application/x-ndjson" }, signal }
@@ -69,12 +70,12 @@ export const getLichessUserRecentGames = async (
} }
const rawData = await res.text(); const rawData = await res.text();
const games: LichessRawGameData[] = rawData const games: LichessGame[] = rawData
.split("\n") .split("\n")
.filter((game) => game.length > 0) .filter((game) => game.length > 0)
.map((game) => JSON.parse(game)); .map((game) => JSON.parse(game));
return games; return games.map(formatLichessGame);
}; };
const fetchLichessEval = async ( const fetchLichessEval = async (
@@ -94,3 +95,33 @@ const fetchLichessEval = async (
return { error: LichessError.NotFound }; return { error: LichessError.NotFound };
} }
}; };
const formatLichessGame = (data: LichessGame): LoadedGame => {
return {
id: data.id,
pgn: data.pgn || "",
white: {
name: data.players.white.user?.name || "White",
rating: data.players.white.rating,
title: data.players.white.user?.title,
},
black: {
name: data.players.black.user?.name || "Black",
rating: data.players.black.rating,
title: data.players.black.user?.title,
},
result: getGameResult(data),
timeControl: `${Math.floor(data.clock?.initial / 60 || 0)}+${data.clock?.increment || 0}`,
date: new Date(data.createdAt || data.lastMoveAt).toLocaleDateString(),
movesNb: data.moves?.split(" ").length || 0,
url: `https://lichess.org/${data.id}`,
};
};
const getGameResult = (data: LichessGame): string => {
if (data.status === "draw") return "1/2-1/2";
if (data.winner) return data.winner === "white" ? "1-0" : "0-1";
return "*";
};

View File

@@ -1,15 +1,9 @@
import { useChessActions } from "@/hooks/useChessActions";
import Board from "@/sections/analysis/board"; import Board from "@/sections/analysis/board";
import PanelHeader from "@/sections/analysis/panelHeader"; import PanelHeader from "@/sections/analysis/panelHeader";
import PanelToolBar from "@/sections/analysis/panelToolbar"; import PanelToolBar from "@/sections/analysis/panelToolbar";
import AnalysisTab from "@/sections/analysis/panelBody/analysisTab"; import AnalysisTab from "@/sections/analysis/panelBody/analysisTab";
import ClassificationTab from "@/sections/analysis/panelBody/classificationTab"; import ClassificationTab from "@/sections/analysis/panelBody/classificationTab";
import { import { boardAtom, gameAtom, gameEvalAtom } from "@/sections/analysis/states";
boardAtom,
boardOrientationAtom,
gameAtom,
gameEvalAtom,
} from "@/sections/analysis/states";
import { import {
Box, Box,
Divider, Divider,
@@ -19,8 +13,7 @@ import {
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useAtomValue } from "jotai";
import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import EngineSettingsButton from "@/sections/engineSettings/engineSettingsButton"; import EngineSettingsButton from "@/sections/engineSettings/engineSettingsButton";
@@ -32,24 +25,9 @@ export default function GameAnalysis() {
const [tab, setTab] = useState(0); const [tab, setTab] = useState(0);
const isLgOrGreater = useMediaQuery(theme.breakpoints.up("lg")); const isLgOrGreater = useMediaQuery(theme.breakpoints.up("lg"));
const { reset: resetBoard } = useChessActions(boardAtom); const gameEval = useAtomValue(gameEvalAtom);
const { reset: resetGame } = useChessActions(gameAtom);
const [gameEval, setGameEval] = useAtom(gameEvalAtom);
const game = useAtomValue(gameAtom); const game = useAtomValue(gameAtom);
const board = useAtomValue(boardAtom); const board = useAtomValue(boardAtom);
const setBoardOrientation = useSetAtom(boardOrientationAtom);
const router = useRouter();
const { gameId } = router.query;
useEffect(() => {
if (!gameId) {
resetBoard();
setGameEval(undefined);
setBoardOrientation(true);
resetGame({ noHeaders: true });
}
}, [gameId, setGameEval, setBoardOrientation, resetBoard, resetGame]);
const showMovesTab = game.history().length > 0 || board.history().length > 0; const showMovesTab = game.history().length > 0 || board.history().length > 0;

View File

@@ -12,6 +12,8 @@ import { useGameDatabase } from "@/hooks/useGameDatabase";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { Chess } from "chess.js"; import { Chess } from "chess.js";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { decodeBase64 } from "@/lib/helpers";
import { Game } from "@/types/game";
export default function LoadGame() { export default function LoadGame() {
const router = useRouter(); const router = useRouter();
@@ -32,25 +34,49 @@ export default function LoadGame() {
[resetBoard, setGamePgn, setEval] [resetBoard, setGamePgn, setEval]
); );
useEffect(() => { const { pgn: pgnParam, orientation: orientationParam } = router.query;
const loadGame = async () => {
if (!gameFromUrl) return;
useEffect(() => {
const loadGameFromIdParam = (gameUrl: Game) => {
const gamefromDbChess = new Chess(); const gamefromDbChess = new Chess();
gamefromDbChess.loadPgn(gameFromUrl.pgn); gamefromDbChess.loadPgn(gameUrl.pgn);
if (game.history().join() === gamefromDbChess.history().join()) return; if (game.history().join() === gamefromDbChess.history().join()) return;
resetAndSetGamePgn(gameFromUrl.pgn); resetAndSetGamePgn(gameUrl.pgn);
setEval(gameFromUrl.eval); setEval(gameUrl.eval);
setBoardOrientation( setBoardOrientation(
gameFromUrl.black.name === "You" && gameFromUrl.site === "Chesskit.org" gameUrl.black.name === "You" && gameUrl.site === "Chesskit.org"
? false ? false
: true : true
); );
}; };
loadGame(); const loadGameFromPgnParam = (encodedPgn: string) => {
}, [gameFromUrl, game, resetAndSetGamePgn, setEval, setBoardOrientation]); const decodedPgn = decodeBase64(encodedPgn);
if (!decodedPgn) return;
const gameFromPgnParam = new Chess();
gameFromPgnParam.loadPgn(decodedPgn || "");
if (game.history().join() === gameFromPgnParam.history().join()) return;
resetAndSetGamePgn(decodedPgn);
setBoardOrientation(orientationParam !== "black");
};
if (gameFromUrl) {
loadGameFromIdParam(gameFromUrl);
} else if (typeof pgnParam === "string") {
loadGameFromPgnParam(pgnParam);
}
}, [
gameFromUrl,
pgnParam,
orientationParam,
game,
resetAndSetGamePgn,
setEval,
setBoardOrientation,
]);
const isGameLoaded = const isGameLoaded =
gameFromUrl !== undefined || gameFromUrl !== undefined ||

View File

@@ -1,11 +1,12 @@
import { DEFAULT_ENGINE } from "@/constants"; import { DEFAULT_ENGINE } from "@/constants";
import { getRecommendedWorkersNb } from "@/lib/engine/worker"; import { getRecommendedWorkersNb } from "@/lib/engine/worker"; // ✅ Already includes navigator guards
import { EngineName } from "@/types/enums"; import { EngineName } from "@/types/enums";
import { CurrentPosition, GameEval, SavedEvals } from "@/types/eval"; import { CurrentPosition, GameEval, SavedEvals } from "@/types/eval";
import { Chess } from "chess.js"; import { Chess } from "chess.js";
import { atom } from "jotai"; import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils"; import { atomWithStorage } from "jotai/utils";
// ✅ Core atoms for game state and UI
export const gameEvalAtom = atom<GameEval | undefined>(undefined); export const gameEvalAtom = atom<GameEval | undefined>(undefined);
export const gameAtom = atom(new Chess()); export const gameAtom = atom(new Chess());
export const boardAtom = atom(new Chess()); export const boardAtom = atom(new Chess());
@@ -15,13 +16,16 @@ export const boardOrientationAtom = atom(true);
export const showBestMoveArrowAtom = atom(true); export const showBestMoveArrowAtom = atom(true);
export const showPlayerMoveIconAtom = atom(true); export const showPlayerMoveIconAtom = atom(true);
// ✅ Engine config atoms
export const engineNameAtom = atom<EngineName>(DEFAULT_ENGINE); export const engineNameAtom = atom<EngineName>(DEFAULT_ENGINE);
export const engineDepthAtom = atom(14); export const engineDepthAtom = atom(14);
export const engineMultiPvAtom = atom(3); export const engineMultiPvAtom = atom(3);
// ✅ This line is now safe thanks to navigator guards in the function
export const engineWorkersNbAtom = atomWithStorage( export const engineWorkersNbAtom = atomWithStorage(
"engineWorkersNb", "engineWorkersNb",
getRecommendedWorkersNb() getRecommendedWorkersNb()
); );
export const evaluationProgressAtom = atom(0);
export const evaluationProgressAtom = atom(0);
export const savedEvalsAtom = atom<SavedEvals>({}); export const savedEvalsAtom = atom<SavedEvals>({});

View File

@@ -11,103 +11,13 @@ import {
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useDebounce } from "@/hooks/useDebounce"; import { useDebounce } from "@/hooks/useDebounce";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ChessComGameItem } from "./chess-com-game-item";
import { ChessComRawGameData, NormalizedGameData } from "@/types/chessCom";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { GameItem } from "./gameItem";
interface Props { interface Props {
onSelect: (pgn: string, boardOrientation?: boolean) => void; onSelect: (pgn: string, boardOrientation?: boolean) => void;
} }
// Helper function to normalize Chess.com data
const normalizeChessComData = (
data: ChessComRawGameData
): NormalizedGameData => {
const timeControl = data.time_control + "s" || "unknown";
// Todo Convert from seconds to minutes to time + increment seconds
// Determine result from multiple sources
let gameResult = "*"; // default to ongoing
if (data.result) {
gameResult = data.result;
} else if (data.white?.result && data.black?.result) {
if (data.white.result === "win") {
gameResult = "1-0";
} else if (data.black.result === "win") {
gameResult = "0-1";
} else if (
(data.white.result === "stalemate" &&
data.black.result === "stalemate") ||
(data.white.result === "repetition" &&
data.black.result === "repetition") ||
(data.white.result === "insufficient" &&
data.black.result === "insufficient") ||
(data.white.result === "50move" && data.black.result === "50move") ||
(data.white.result === "agreed" && data.black.result === "agreed")
) {
gameResult = "1/2-1/2";
}
}
//* Function to count moves from PGN. Generated from claude..... :)
const countMovesFromPGN = (pgn: string) => {
if (!pgn) return 0;
// Split PGN into lines and find the moves section (after headers)
const lines = pgn.split("\n");
let movesSection = "";
let inMoves = false;
for (const line of lines) {
if (line.trim() === "" && !inMoves) {
inMoves = true;
continue;
}
if (inMoves) {
movesSection += line + " ";
}
}
// Remove comments in curly braces and square brackets
movesSection = movesSection
.replace(/\{[^}]*\}/g, "")
.replace(/\[[^\]]*\]/g, "");
// Remove result indicators
movesSection = movesSection.replace(/1-0|0-1|1\/2-1\/2|\*/g, "");
// Split by move numbers and count them
// Match pattern like "1." "58." etc.
const moveNumbers = movesSection.match(/\d+\./g);
return moveNumbers ? moveNumbers.length : 0;
};
return {
id: data.uuid || data.url?.split("/").pop() || data.id,
white: {
username: data.white?.username || "White",
rating: data.white?.rating || 0,
title: data.white?.title,
},
black: {
username: data.black?.username || "Black",
rating: data.black?.rating || 0,
title: data.black?.title,
},
result: gameResult,
timeControl: timeControl,
date: data.end_time
? new Date(data.end_time * 1000).toLocaleDateString()
: new Date().toLocaleDateString(),
opening: data.opening?.name || data.eco,
moves: data.pgn ? countMovesFromPGN(data.pgn) : 0,
url: data.url,
};
};
export default function ChessComInput({ onSelect }: Props) { export default function ChessComInput({ onSelect }: Props) {
const [rawStoredValue, setStoredValues] = useLocalStorage<string>( const [rawStoredValue, setStoredValues] = useLocalStorage<string>(
"chesscom-username", "chesscom-username",
@@ -141,11 +51,12 @@ export default function ChessComInput({ onSelect }: Props) {
if (!trimmed) return; if (!trimmed) return;
const lower = trimmed.toLowerCase(); const lower = trimmed.toLowerCase();
const exists = storedValues.some((u) => u.toLowerCase() === lower); const updated = [
if (!exists) { trimmed,
const updated = [trimmed, ...storedValues.slice(0, 7)]; ...storedValues.filter((u) => u.toLowerCase() !== lower),
setStoredValues(updated.join(",")); ].slice(0, 8);
}
setStoredValues(updated.join(","));
}; };
const deleteUsername = (usernameToDelete: string) => { const deleteUsername = (usernameToDelete: string) => {
@@ -175,7 +86,7 @@ export default function ChessComInput({ onSelect }: Props) {
return ( return (
<> <>
<FormControl sx={{ m: 1, width: 300 }}> <FormControl sx={{ my: 1, width: 300 }}>
<Autocomplete <Autocomplete
freeSolo freeSolo
options={storedValues} options={storedValues}
@@ -237,26 +148,25 @@ export default function ChessComInput({ onSelect }: Props) {
No games found. Please check your username. No games found. Please check your username.
</span> </span>
) : ( ) : (
<List sx={{ width: "100%", maxWidth: 800 }}> <List sx={{ width: "100%" }}>
{games.map((game) => { {games.map((game) => {
const normalizedGame = normalizeChessComData(game);
const perspectiveUserColor = const perspectiveUserColor =
normalizedGame.white.username.toLowerCase() === game.white.name.toLowerCase() ===
chessComUsername.toLowerCase() debouncedUsername.toLowerCase()
? "white" ? "white"
: "black"; : "black";
return ( return (
<ChessComGameItem <GameItem
key={game.uuid} key={game.id}
{...normalizedGame} game={game}
perspectiveUserColor={perspectiveUserColor} perspectiveUserColor={perspectiveUserColor}
onClick={() => { onClick={() => {
updateHistory(debouncedUsername);
const boardOrientation = const boardOrientation =
debouncedUsername.toLowerCase() !== debouncedUsername.toLowerCase() !==
game.black?.username?.toLowerCase(); game.black?.name?.toLowerCase();
onSelect(game.pgn, boardOrientation); onSelect(game.pgn, boardOrientation);
updateHistory(debouncedUsername);
}} }}
/> />
); );

View File

@@ -1,105 +0,0 @@
import { Icon } from "@iconify/react";
import { Chip, Tooltip, useTheme } from "@mui/material";
import React from "react";
export const GameResult: React.FC<{
result: string;
perspectiveUserColor: "white" | "black";
}> = ({ result, perspectiveUserColor }) => {
const theme = useTheme();
let color = theme.palette.text.secondary; // Neutral gray for ongoing
let bgColor = theme.palette.action.hover;
let icon = <Icon icon="material-symbols:play-circle-outline" />;
let label = "Game in Progress";
if (result === "1-0") {
// White wins
if (perspectiveUserColor === "white") {
color = theme.palette.success.main; // Success green
bgColor = `${theme.palette.success.main}1A`; // 10% opacity
icon = <Icon icon="material-symbols:emoji-events" />;
} else {
// perspectiveUserColor is black
color = theme.palette.error.main; // Confident red
bgColor = `${theme.palette.error.main}1A`; // 10% opacity
icon = <Icon icon="material-symbols:sentiment-dissatisfied" />; // A suitable icon for loss
}
label = "White Wins";
} else if (result === "0-1") {
// Black wins
if (perspectiveUserColor === "black") {
color = theme.palette.success.main; // Success green
bgColor = `${theme.palette.success.main}1A`; // 10% opacity
icon = <Icon icon="material-symbols:emoji-events" />;
} else {
// perspectiveUserColor is white
color = theme.palette.error.main; // Confident red
bgColor = `${theme.palette.error.main}1A`; // 10% opacity
icon = <Icon icon="material-symbols:sentiment-dissatisfied" />; // A suitable icon for loss
}
label = "Black Wins";
} else if (result === "1/2-1/2") {
color = theme.palette.info.main; // Balanced blue (using info for a neutral, distinct color)
bgColor = `${theme.palette.info.main}1A`; // 10% opacity
icon = <Icon icon="material-symbols:handshake" />;
label = "Draw";
}
return (
<Tooltip title={label}>
<Chip
icon={icon}
label={result}
size="small"
sx={{
color,
backgroundColor: bgColor,
fontWeight: "600",
minWidth: 65,
border: `1px solid ${color}20`,
"& .MuiChip-icon": {
color: color,
},
}}
/>
</Tooltip>
);
};
export const TimeControlChip: React.FC<{ timeControl: string }> = ({
timeControl,
}) => {
return (
<Tooltip title="Time Control">
<Chip
icon={<Icon icon="material-symbols:timer-outline" />}
label={timeControl}
size="small"
/>
</Tooltip>
);
};
export const MovesChip: React.FC<{ moves: number }> = ({ moves }) => {
return (
<Tooltip title="Number of Moves">
<Chip
icon={<Icon icon="heroicons:hashtag-20-solid" />}
label={`${Math.round(moves / 2)} moves`}
size="small"
/>
</Tooltip>
);
};
export const DateChip: React.FC<{ date: string }> = ({ date }) => {
return (
<Tooltip title="Date Played">
<Chip
icon={<Icon icon="material-symbols:calendar-today" />}
label={date}
size="small"
/>
</Tooltip>
);
};

View File

@@ -0,0 +1,20 @@
import { Icon } from "@iconify/react";
import { Chip, Tooltip } from "@mui/material";
interface Props {
date?: string;
}
export default function DateChip({ date }: Props) {
if (!date) return null;
return (
<Tooltip title="Date Played">
<Chip
icon={<Icon icon="material-symbols:calendar-today" />}
label={date}
size="small"
/>
</Tooltip>
);
}

View File

@@ -0,0 +1,81 @@
import { Chip, Theme, Tooltip, useTheme } from "@mui/material";
import React from "react";
interface Props {
result?: string;
perspectiveUserColor: "white" | "black";
}
export default function GameResultChip({
result,
perspectiveUserColor,
}: Props) {
const theme = useTheme();
const { label, color, bgColor } = getResultSpecs(
theme,
perspectiveUserColor,
result
);
return (
<Tooltip title={label}>
<Chip
label={result}
size="small"
sx={{
color,
backgroundColor: bgColor,
fontWeight: "600",
minWidth: { sm: 40 },
border: `1px solid ${color}20`,
"& .MuiChip-icon": {
color: color,
},
}}
/>
</Tooltip>
);
}
const getResultSpecs = (
theme: Theme,
perspectiveUserColor: "white" | "black",
result?: string
) => {
if (
(result === "1-0" && perspectiveUserColor === "white") ||
(result === "0-1" && perspectiveUserColor === "black")
) {
return {
label: result === "1-0" ? "White won" : "Black won",
color: theme.palette.success.main,
bgColor: `${theme.palette.success.main}1A`,
};
}
if (
(result === "1-0" && perspectiveUserColor === "black") ||
(result === "0-1" && perspectiveUserColor === "white")
) {
return {
label: result === "1-0" ? "White won" : "Black won",
color: theme.palette.error.main,
bgColor: `${theme.palette.error.main}1A`,
};
}
if (result === "1/2-1/2") {
return {
label: "Draw",
color: theme.palette.info.main,
bgColor: `${theme.palette.info.main}1A`,
};
}
return {
label: "Game in Progress",
color: theme.palette.text.secondary,
bgColor: theme.palette.action.hover,
};
};

View File

@@ -5,55 +5,26 @@ import {
Typography, Typography,
Box, Box,
useTheme, useTheme,
IconButton,
Tooltip,
} from "@mui/material"; } from "@mui/material";
import { Icon } from "@iconify/react"; import { LoadedGame } from "@/types/game";
import { import TimeControlChip from "./timeControlChip";
DateChip, import MovesNbChip from "./movesNbChip";
GameResult, import DateChip from "./dateChip";
MovesChip, import GameResultChip from "./gameResultChip";
TimeControlChip,
} from "./game-item-utils";
type ChessComPlayer = { interface Props {
username: string; game: LoadedGame;
rating: number; onClick: () => void;
title?: string;
};
type ChessComGameProps = {
id: string;
white: ChessComPlayer;
black: ChessComPlayer;
result: string;
timeControl: string;
date: string;
opening?: string;
moves?: number;
url: string;
onClick?: () => void;
perspectiveUserColor: "white" | "black"; perspectiveUserColor: "white" | "black";
}; }
export const ChessComGameItem: React.FC<ChessComGameProps> = ({ export const GameItem: React.FC<Props> = ({
white, game,
black,
result,
timeControl,
date,
moves,
url,
onClick, onClick,
perspectiveUserColor, perspectiveUserColor,
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const { white, black, result, timeControl, date, movesNb } = game;
const formatPlayerName = (player: ChessComPlayer) => {
return player.title
? `${player.title} ${player.username}`
: player.username;
};
const whiteWon = result === "1-0"; const whiteWon = result === "1-0";
const blackWon = result === "0-1"; const blackWon = result === "0-1";
@@ -67,34 +38,34 @@ export const ChessComGameItem: React.FC<ChessComGameProps> = ({
transition: "all 0.2s ease-in-out", transition: "all 0.2s ease-in-out",
"&:hover": { "&:hover": {
backgroundColor: theme.palette.action.hover, backgroundColor: theme.palette.action.hover,
transform: "translateY(-1px)",
boxShadow: theme.shadows[3], boxShadow: theme.shadows[3],
}, },
border: `1px solid ${theme.palette.divider}`, border: `1px solid ${theme.palette.divider}`,
cursor: onClick ? "pointer" : "default", cursor: "pointer",
}} }}
onClick={onClick} onClick={onClick}
> >
<ListItemText <ListItemText
disableTypography
primary={ primary={
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
flexWrap: "wrap", gap: { xs: 1, sm: 1.5 },
gap: 1.5,
mb: 1, mb: 1,
}} }}
> >
<Typography <Typography
variant="subtitle1" variant="subtitle1"
component="span" component="span"
noWrap
sx={{ sx={{
fontWeight: "700", fontWeight: "700",
color: whiteWon color: whiteWon
? theme.palette.success.main ? theme.palette.success.main
: theme.palette.text.primary, : theme.palette.text.primary,
opacity: blackWon ? 0.7 : 1, opacity: whiteWon ? 1 : blackWon ? 0.7 : 0.8,
}} }}
> >
{formatPlayerName(white)} ({white.rating}) {formatPlayerName(white)} ({white.rating})
@@ -103,7 +74,10 @@ export const ChessComGameItem: React.FC<ChessComGameProps> = ({
<Typography <Typography
variant="body2" variant="body2"
component="span" component="span"
sx={{ color: theme.palette.text.secondary, fontWeight: "500" }} sx={{
color: theme.palette.text.secondary,
fontWeight: "500",
}}
> >
vs vs
</Typography> </Typography>
@@ -111,18 +85,19 @@ export const ChessComGameItem: React.FC<ChessComGameProps> = ({
<Typography <Typography
variant="subtitle1" variant="subtitle1"
component="span" component="span"
noWrap
sx={{ sx={{
fontWeight: "700", fontWeight: "700",
color: blackWon color: blackWon
? theme.palette.success.main ? theme.palette.success.main
: theme.palette.text.primary, : theme.palette.text.primary,
opacity: whiteWon ? 0.7 : 1, opacity: blackWon ? 1 : whiteWon ? 0.7 : 0.8,
}} }}
> >
{formatPlayerName(black)} ({black.rating}) {formatPlayerName(black)} ({black.rating})
</Typography> </Typography>
<GameResult <GameResultChip
result={result} result={result}
perspectiveUserColor={perspectiveUserColor} perspectiveUserColor={perspectiveUserColor}
/> />
@@ -132,40 +107,20 @@ export const ChessComGameItem: React.FC<ChessComGameProps> = ({
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
flexWrap: "wrap",
gap: 1, gap: 1,
alignItems: "center", alignItems: "center",
}} }}
> >
<TimeControlChip timeControl={timeControl} /> <TimeControlChip timeControl={timeControl} />
{moves && moves > 0 && <MovesChip moves={moves} />} <MovesNbChip movesNb={movesNb} />
<DateChip date={date} /> <DateChip date={date} />
</Box> </Box>
} }
sx={{ mr: 2 }}
/> />
<Box sx={{ display: "flex", alignItems: "center", ml: "auto" }}>
<Tooltip title="View on Chess.com">
<IconButton
onClick={(e) => {
e.stopPropagation();
window.open(url, "_blank");
}}
size="small"
sx={{
color: theme.palette.primary.main,
"&:hover": {
backgroundColor: theme.palette.action.hover,
transform: "scale(1.1)",
},
transition: "all 0.2s ease-in-out",
}}
>
<Icon icon="material-symbols:open-in-new" />
</IconButton>
</Tooltip>
</Box>
</ListItem> </ListItem>
); );
}; };
const formatPlayerName = (player: LoadedGame["white"]) => {
return player.title ? `${player.title} ${player.name}` : player.name;
};

View File

@@ -0,0 +1,20 @@
import { Icon } from "@iconify/react";
import { Chip, Tooltip } from "@mui/material";
interface Props {
movesNb?: number;
}
export default function MovesNbChip({ movesNb }: Props) {
if (!movesNb) return null;
return (
<Tooltip title="Number of Moves" sx={{ overflow: "hidden" }}>
<Chip
icon={<Icon icon="heroicons:hashtag-20-solid" />}
label={`${Math.ceil(movesNb / 2)} moves`}
size="small"
/>
</Tooltip>
);
}

View File

@@ -0,0 +1,20 @@
import { Icon } from "@iconify/react";
import { Chip, Tooltip } from "@mui/material";
interface Props {
timeControl?: string;
}
export default function TimeControlChip({ timeControl }: Props) {
if (!timeControl) return null;
return (
<Tooltip title="Time Control">
<Chip
icon={<Icon icon="material-symbols:timer-outline" />}
label={timeControl}
size="small"
/>
</Tooltip>
);
}

View File

@@ -10,16 +10,15 @@ interface Props {
export default function GamePgnInput({ pgn, setPgn }: Props) { export default function GamePgnInput({ pgn, setPgn }: Props) {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) { if (!file) return;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
const fileContent = e.target?.result as string; const fileContent = e.target?.result as string;
setPgn(fileContent); setPgn(fileContent);
}; };
reader.readAsText(file); // Read the file as text reader.readAsText(file);
}
}; };
return ( return (
@@ -38,13 +37,8 @@ export default function GamePgnInput({ pgn, setPgn }: Props) {
component="label" component="label"
startIcon={<Icon icon="material-symbols:upload" />} startIcon={<Icon icon="material-symbols:upload" />}
> >
Choose PGN File Upload PGN File
<input <input type="file" hidden accept=".pgn" onChange={handleFileChange} />
type="file"
hidden // Hide the default file input
accept=".pgn" // Only allow .pgn files
onChange={handleFileChange}
/>
</Button> </Button>
</FormControl> </FormControl>
); );

View File

@@ -1,179 +0,0 @@
import type React from "react";
import {
ListItem,
ListItemText,
Typography,
Chip,
Box,
useTheme,
IconButton,
Tooltip,
} from "@mui/material";
import { Icon } from "@iconify/react";
import {
DateChip,
GameResult,
MovesChip,
TimeControlChip,
} from "./game-item-utils";
type LichessPlayer = {
username: string;
rating: number;
title?: string;
};
type LichessGameProps = {
id: string;
white: LichessPlayer;
black: LichessPlayer;
result: string;
timeControl: string;
date: string;
opening?: string;
moves?: number;
url: string;
onClick?: () => void;
perspectiveUserColor: "white" | "black";
};
export const LichessGameItem: React.FC<LichessGameProps> = ({
white,
black,
result,
timeControl,
date,
perspectiveUserColor,
moves,
url,
onClick,
}) => {
const theme = useTheme();
// If it is a titled played append the title to the start of the name
const formatPlayerName = (player: LichessPlayer) => {
return player.title
? `${player.title} ${player.username}`
: player.username;
};
const whiteWon = result === "1-0";
const blackWon = result === "0-1";
return (
<ListItem
alignItems="flex-start"
sx={{
borderRadius: 2,
mb: 1.5,
transition: "all 0.2s ease-in-out",
"&:hover": {
backgroundColor: theme.palette.action.hover,
transform: "translateY(-1px)",
boxShadow: theme.shadows[3],
},
border: `1px solid ${theme.palette.divider}`,
cursor: onClick ? "pointer" : "default",
}}
onClick={onClick}
>
<ListItemText
primary={
<Box
sx={{
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: 1.5,
mb: 1,
}}
>
<Typography
variant="subtitle1"
component="span"
sx={{
fontWeight: "700",
color: whiteWon
? theme.palette.success.main
: theme.palette.text.primary,
opacity: blackWon ? 0.7 : 1,
}}
>
{formatPlayerName(white)} ({white.rating})
</Typography>
<Typography
variant="body2"
component="span"
sx={{ color: theme.palette.text.secondary, fontWeight: "500" }}
>
vs
</Typography>
<Typography
variant="subtitle1"
component="span"
sx={{
fontWeight: "700",
color: blackWon
? theme.palette.success.main
: theme.palette.text.primary,
opacity: whiteWon ? 0.7 : 1,
}}
>
{formatPlayerName(black)} ({black.rating})
</Typography>
<GameResult
result={result}
perspectiveUserColor={perspectiveUserColor}
/>
</Box>
}
secondary={
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: 1,
alignItems: "center",
}}
>
<TimeControlChip timeControl={timeControl} />
{moves && moves > 0 && <MovesChip moves={moves} />}
<DateChip date={date} />
<Chip
icon={<Icon icon="simple-icons:lichess" />}
label="Lichess"
size="small"
/>
</Box>
}
sx={{ mr: 2 }}
/>
<Box sx={{ display: "flex", alignItems: "center", ml: "auto" }}>
<Tooltip title="View on Lichess">
<IconButton
onClick={(e) => {
e.stopPropagation();
window.open(url, "_blank");
}}
size="small"
sx={{
color: theme.palette.primary.main,
"&:hover": {
backgroundColor: theme.palette.action.hover,
transform: "scale(1.1)",
},
transition: "all 0.2s ease-in-out",
}}
>
<Icon icon="material-symbols:open-in-new" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
);
};

View File

@@ -1,5 +1,3 @@
"use client";
import { useLocalStorage } from "@/hooks/useLocalStorage"; import { useLocalStorage } from "@/hooks/useLocalStorage";
import { getLichessUserRecentGames } from "@/lib/lichess"; import { getLichessUserRecentGames } from "@/lib/lichess";
import { import {
@@ -13,44 +11,13 @@ import {
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useDebounce } from "@/hooks/useDebounce"; import { useDebounce } from "@/hooks/useDebounce";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { LichessGameItem } from "./lichess-game-item";
import { LichessRawGameData, NormalizedLichessGameData } from "@/types/lichess";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { GameItem } from "./gameItem";
interface Props { interface Props {
onSelect: (pgn: string, boardOrientation?: boolean) => void; onSelect: (pgn: string, boardOrientation?: boolean) => void;
} }
// Helper function to normalize Lichess data
const normalizeLichessData = (
data: LichessRawGameData
): NormalizedLichessGameData => ({
id: data.id,
white: {
username: data.players.white.user?.name || "Anonymous",
rating: data.players.white.rating,
title: data.players.white.user?.title,
},
black: {
username: data.players.black.user?.name || "Anonymous",
rating: data.players.black.rating,
title: data.players.black.user?.title,
},
result:
data.status === "draw"
? "1/2-1/2"
: data.winner
? data.winner === "white"
? "1-0"
: "0-1"
: "*",
timeControl: `${Math.floor(data.clock?.initial / 60 || 0)}+${data.clock?.increment || 0}`,
date: new Date(data.createdAt || data.lastMoveAt).toLocaleDateString(),
opening: data.opening?.name,
moves: data.moves?.split(" ").length || 0,
url: `https://lichess.org/${data.id}`,
});
export default function LichessInput({ onSelect }: Props) { export default function LichessInput({ onSelect }: Props) {
const [rawStoredValue, setStoredValues] = useLocalStorage<string>( const [rawStoredValue, setStoredValues] = useLocalStorage<string>(
"lichess-username", "lichess-username",
@@ -83,11 +50,12 @@ export default function LichessInput({ onSelect }: Props) {
if (!trimmed) return; if (!trimmed) return;
const lower = trimmed.toLowerCase(); const lower = trimmed.toLowerCase();
const exists = storedValues.some((u) => u.toLowerCase() === lower); const updated = [
if (!exists) { trimmed,
const updated = [trimmed, ...storedValues.slice(0, 7)]; ...storedValues.filter((u) => u.toLowerCase() !== lower),
setStoredValues(updated.join(",")); ].slice(0, 8);
}
setStoredValues(updated.join(","));
}; };
const deleteUsername = (usernameToDelete: string) => { const deleteUsername = (usernameToDelete: string) => {
@@ -117,7 +85,7 @@ export default function LichessInput({ onSelect }: Props) {
return ( return (
<> <>
<FormControl sx={{ m: 1, width: 300 }}> <FormControl sx={{ my: 1, width: 300 }}>
<Autocomplete <Autocomplete
freeSolo freeSolo
options={storedValues} options={storedValues}
@@ -179,26 +147,25 @@ export default function LichessInput({ onSelect }: Props) {
No games found. Please check your username. No games found. Please check your username.
</span> </span>
) : ( ) : (
<List sx={{ width: "100%", maxWidth: 800 }}> <List sx={{ width: "100%" }}>
{games.map((game) => { {games.map((game) => {
const normalizedGame = normalizeLichessData(game);
const perspectiveUserColor = const perspectiveUserColor =
normalizedGame.white.username.toLowerCase() === game.white.name.toLowerCase() ===
lichessUsername.toLowerCase() debouncedUsername.toLowerCase()
? "white" ? "white"
: "black"; : "black";
return ( return (
<LichessGameItem <GameItem
key={game.id} key={game.id}
{...normalizedGame} game={game}
perspectiveUserColor={perspectiveUserColor} perspectiveUserColor={perspectiveUserColor}
onClick={() => { onClick={() => {
const boardOrientation =
debouncedUsername.toLowerCase() !==
game.black.name.toLowerCase();
onSelect(game.pgn, boardOrientation);
updateHistory(debouncedUsername); updateHistory(debouncedUsername);
const boardOrientation =
debouncedUsername.toLowerCase() !==
game.players?.black?.user?.name?.toLowerCase();
onSelect(game.pgn, boardOrientation);
}} }}
/> />
); );

View File

@@ -91,12 +91,14 @@ export default function NewGameDialog({ open, onClose, setGame }: Props) {
open={open} open={open}
onClose={handleClose} onClose={handleClose}
maxWidth="md" maxWidth="md"
fullWidth
slotProps={{ slotProps={{
paper: { paper: {
sx: { sx: {
position: "fixed", position: "fixed",
top: 0, top: 0,
width: "calc(100% - 10px)",
marginY: { xs: "3vh", sm: 5 },
maxHeight: { xs: "calc(100% - 5vh)", sm: "calc(100% - 64px)" },
}, },
}, },
}} }}
@@ -104,7 +106,7 @@ export default function NewGameDialog({ open, onClose, setGame }: Props) {
<DialogTitle marginY={1} variant="h5"> <DialogTitle marginY={1} variant="h5">
{setGame ? "Load a game" : "Add a game to your database"} {setGame ? "Load a game" : "Add a game to your database"}
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent sx={{ padding: { xs: 2, md: 3 } }}>
<Grid <Grid
container container
marginTop={1} marginTop={1}
@@ -112,7 +114,7 @@ export default function NewGameDialog({ open, onClose, setGame }: Props) {
justifyContent="start" justifyContent="start"
rowGap={2} rowGap={2}
> >
<FormControl sx={{ m: 1, width: 150 }}> <FormControl sx={{ my: 1, mr: 2, width: 150 }}>
<InputLabel id="dialog-select-label">Game origin</InputLabel> <InputLabel id="dialog-select-label">Game origin</InputLabel>
<Select <Select
labelId="dialog-select-label" labelId="dialog-select-label"

View File

@@ -59,9 +59,10 @@ export default function GameSettingsDialog({ open, onClose }: Props) {
setParsingError(""); setParsingError("");
try { try {
const startingFen = startingPositionInput.startsWith("[") const input = startingPositionInput.trim();
? getGameFromPgn(startingPositionInput).fen() const startingFen = input.startsWith("[")
: startingPositionInput || undefined; ? getGameFromPgn(input).fen()
: input || undefined;
resetGame({ resetGame({
white: { white: {

View File

@@ -1,46 +1,20 @@
interface ChessComPlayerData { interface ChessComPlayer {
username: string; username: string;
rating: number; rating: number;
result?: string; result?: string;
title?: string; title?: string;
} }
interface ChessComOpeningData { export interface ChessComGame {
name: string;
eco?: string;
}
export interface ChessComRawGameData {
uuid: string; uuid: string;
id: string; id: string;
url: string; url: string;
pgn: string; pgn: string;
white: ChessComPlayerData; white: ChessComPlayer;
black: ChessComPlayerData; black: ChessComPlayer;
result: string; result: string;
time_control: string; time_control: string;
end_time: number; end_time: number;
opening?: ChessComOpeningData;
eco?: string; eco?: string;
termination?: string; termination?: string;
} }
export interface NormalizedGameData {
id: string;
white: {
username: string;
rating: number;
title?: string;
};
black: {
username: string;
rating: number;
title?: string;
};
result: string;
timeControl: string;
date: string;
opening?: string;
moves: number;
url: string;
}

View File

@@ -19,4 +19,17 @@ export interface Player {
name: string; name: string;
rating?: number; rating?: number;
avatarUrl?: string; avatarUrl?: string;
title?: string;
}
export interface LoadedGame {
id: string;
pgn: string;
date?: string;
white: Player;
black: Player;
result?: string;
timeControl?: string;
movesNb?: number;
url?: string;
} }

View File

@@ -17,7 +17,7 @@ export enum LichessError {
NotFound = "No cloud evaluation available for that position", NotFound = "No cloud evaluation available for that position",
} }
interface LichessPlayerData { interface LichessPlayer {
user: { user: {
name: string; name: string;
title?: string; title?: string;
@@ -25,51 +25,24 @@ interface LichessPlayerData {
rating: number; rating: number;
} }
interface LichessClockData { interface LichessClock {
initial: number; initial: number;
increment: number; increment: number;
totalTime: number; totalTime: number;
} }
interface LichessOpeningData { export interface LichessGame {
eco: string;
name: string;
ply: number;
}
export interface LichessRawGameData {
id: string; id: string;
createdAt: number; createdAt: number;
lastMoveAt: number; lastMoveAt: number;
status: string; status: string;
players: { players: {
white: LichessPlayerData; white: LichessPlayer;
black: LichessPlayerData; black: LichessPlayer;
}; };
winner?: "white" | "black"; winner?: "white" | "black";
opening?: LichessOpeningData;
moves: string; moves: string;
pgn: string; pgn: string;
clock: LichessClockData; clock: LichessClock;
url?: string; url?: string;
} }
export interface NormalizedLichessGameData {
id: string;
white: {
username: string;
rating: number;
title?: string;
};
black: {
username: string;
rating: number;
title?: string;
};
result: string;
timeControl: string;
date: string;
opening?: string;
moves: number;
url: string;
}