Compare commits
10 Commits
839f87a2e8
...
c187b7a395
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c187b7a395 | ||
|
|
05908e3b03 | ||
|
|
2674243b0c | ||
|
|
aece5db7ce | ||
|
|
7cbad399ed | ||
|
|
da30ba1fc6 | ||
|
|
021d36adb1 | ||
|
|
d4158c8d11 | ||
|
|
f906c81c67 | ||
|
|
5e2d944513 |
20
Dockerfile
Normal file
20
Dockerfile
Normal 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"]
|
||||||
@@ -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
14
docker-compose.yml
Normal 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
37
package-lock.json
generated
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,16 +45,21 @@ export const sendCommandsToWorker = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getRecommendedWorkersNb = (): number => {
|
export const getRecommendedWorkersNb = (): number => {
|
||||||
const maxWorkersNbFromThreads = Math.max(
|
let maxWorkersNbFromThreads = 4;
|
||||||
|
let maxWorkersNbFromMemory = 4;
|
||||||
|
|
||||||
|
if (typeof navigator !== "undefined") {
|
||||||
|
maxWorkersNbFromThreads = Math.max(
|
||||||
1,
|
1,
|
||||||
Math.round(navigator.hardwareConcurrency - 4),
|
Math.round(navigator.hardwareConcurrency - 4),
|
||||||
Math.floor((navigator.hardwareConcurrency * 2) / 3)
|
Math.floor((navigator.hardwareConcurrency * 2) / 3)
|
||||||
);
|
);
|
||||||
|
|
||||||
const maxWorkersNbFromMemory =
|
maxWorkersNbFromMemory =
|
||||||
"deviceMemory" in navigator && typeof navigator.deviceMemory === "number"
|
"deviceMemory" in navigator && typeof navigator.deviceMemory === "number"
|
||||||
? Math.max(1, Math.round(navigator.deviceMemory))
|
? Math.max(1, Math.round(navigator.deviceMemory))
|
||||||
: 4;
|
: 4;
|
||||||
|
}
|
||||||
|
|
||||||
const maxWorkersNbFromDevice = isIosDevice() ? 2 : isMobileDevice() ? 4 : 8;
|
const maxWorkersNbFromDevice = isIosDevice() ? 2 : isMobileDevice() ? 4 : 8;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 "*";
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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>({});
|
||||||
|
|||||||
@@ -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),
|
||||||
|
].slice(0, 8);
|
||||||
|
|
||||||
setStoredValues(updated.join(","));
|
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);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
20
src/sections/loadGame/gameItem/dateChip.tsx
Normal file
20
src/sections/loadGame/gameItem/dateChip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/sections/loadGame/gameItem/gameResultChip.tsx
Normal file
81
src/sections/loadGame/gameItem/gameResultChip.tsx
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
20
src/sections/loadGame/gameItem/movesNbChip.tsx
Normal file
20
src/sections/loadGame/gameItem/movesNbChip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/sections/loadGame/gameItem/timeControlChip.tsx
Normal file
20
src/sections/loadGame/gameItem/timeControlChip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ 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) => {
|
||||||
@@ -18,8 +18,7 @@ export default function GamePgnInput({ pgn, setPgn }: Props) {
|
|||||||
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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),
|
||||||
|
].slice(0, 8);
|
||||||
|
|
||||||
setStoredValues(updated.join(","));
|
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={() => {
|
||||||
updateHistory(debouncedUsername);
|
|
||||||
const boardOrientation =
|
const boardOrientation =
|
||||||
debouncedUsername.toLowerCase() !==
|
debouncedUsername.toLowerCase() !==
|
||||||
game.players?.black?.user?.name?.toLowerCase();
|
game.black.name.toLowerCase();
|
||||||
onSelect(game.pgn, boardOrientation);
|
onSelect(game.pgn, boardOrientation);
|
||||||
|
updateHistory(debouncedUsername);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user