refacto : load game
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import { ChessComRawGameData } from "@/types/chessCom";
|
||||
import { ChessComGame } from "@/types/chessCom";
|
||||
import { getPaddedNumber } from "./helpers";
|
||||
import { LoadedGame } from "@/types/game";
|
||||
import { Chess } from "chess.js";
|
||||
|
||||
export const getChessComUserRecentGames = async (
|
||||
username: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<ChessComRawGameData[]> => {
|
||||
): Promise<LoadedGame[]> => {
|
||||
const date = new Date();
|
||||
const year = date.getUTCFullYear();
|
||||
const month = date.getUTCMonth() + 1;
|
||||
@@ -24,7 +26,7 @@ export const getChessComUserRecentGames = async (
|
||||
throw new Error("Error fetching games from Chess.com");
|
||||
}
|
||||
|
||||
const games: ChessComRawGameData[] = data?.games ?? [];
|
||||
const games: ChessComGame[] = data?.games ?? [];
|
||||
|
||||
if (games.length < 50) {
|
||||
const previousMonth = month === 1 ? 12 : month - 1;
|
||||
@@ -42,7 +44,8 @@ export const getChessComUserRecentGames = async (
|
||||
|
||||
const gamesToReturn = games
|
||||
.sort((a, b) => b.end_time - a.end_time)
|
||||
.slice(0, 50);
|
||||
.slice(0, 50)
|
||||
.map(formatChessComGame);
|
||||
|
||||
return gamesToReturn;
|
||||
};
|
||||
@@ -58,3 +61,57 @@ export const getChessComUserAvatar = async (
|
||||
|
||||
return typeof avatarUrl === "string" ? avatarUrl : null;
|
||||
};
|
||||
|
||||
const formatChessComGame = (data: ChessComGame): LoadedGame => {
|
||||
const game = new Chess();
|
||||
game.loadPgn(data.pgn);
|
||||
|
||||
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: game.getHeaders().Result,
|
||||
timeControl: getGameTimeControl(data),
|
||||
date: data.end_time
|
||||
? new Date(data.end_time * 1000).toLocaleDateString()
|
||||
: new Date().toLocaleDateString(),
|
||||
movesNb: game.history().length,
|
||||
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}`;
|
||||
};
|
||||
|
||||
@@ -3,11 +3,12 @@ import { sortLines } from "./engine/helpers/parseResults";
|
||||
import {
|
||||
LichessError,
|
||||
LichessEvalBody,
|
||||
LichessRawGameData,
|
||||
LichessGame,
|
||||
LichessResponse,
|
||||
} from "@/types/lichess";
|
||||
import { logErrorToSentry } from "./sentry";
|
||||
import { formatUciPv } from "./chess";
|
||||
import { LoadedGame } from "@/types/game";
|
||||
|
||||
export const getLichessEval = async (
|
||||
fen: string,
|
||||
@@ -58,7 +59,7 @@ export const getLichessEval = async (
|
||||
export const getLichessUserRecentGames = async (
|
||||
username: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<LichessRawGameData[]> => {
|
||||
): Promise<LoadedGame[]> => {
|
||||
const res = await fetch(
|
||||
`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 }
|
||||
@@ -69,12 +70,12 @@ export const getLichessUserRecentGames = async (
|
||||
}
|
||||
|
||||
const rawData = await res.text();
|
||||
const games: LichessRawGameData[] = rawData
|
||||
const games: LichessGame[] = rawData
|
||||
.split("\n")
|
||||
.filter((game) => game.length > 0)
|
||||
.map((game) => JSON.parse(game));
|
||||
|
||||
return games;
|
||||
return games.map(formatLichessGame);
|
||||
};
|
||||
|
||||
const fetchLichessEval = async (
|
||||
@@ -94,3 +95,33 @@ const fetchLichessEval = async (
|
||||
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 "*";
|
||||
};
|
||||
|
||||
@@ -11,103 +11,13 @@ import {
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
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 { GameItem } from "./gameItem";
|
||||
|
||||
interface Props {
|
||||
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) {
|
||||
const [rawStoredValue, setStoredValues] = useLocalStorage<string>(
|
||||
"chesscom-username",
|
||||
@@ -239,24 +149,23 @@ export default function ChessComInput({ onSelect }: Props) {
|
||||
) : (
|
||||
<List sx={{ width: "100%", maxWidth: 800 }}>
|
||||
{games.map((game) => {
|
||||
const normalizedGame = normalizeChessComData(game);
|
||||
const perspectiveUserColor =
|
||||
normalizedGame.white.username.toLowerCase() ===
|
||||
chessComUsername.toLowerCase()
|
||||
game.white.name.toLowerCase() ===
|
||||
debouncedUsername.toLowerCase()
|
||||
? "white"
|
||||
: "black";
|
||||
|
||||
return (
|
||||
<ChessComGameItem
|
||||
key={game.uuid}
|
||||
{...normalizedGame}
|
||||
<GameItem
|
||||
key={game.id}
|
||||
game={game}
|
||||
perspectiveUserColor={perspectiveUserColor}
|
||||
onClick={() => {
|
||||
updateHistory(debouncedUsername);
|
||||
const boardOrientation =
|
||||
debouncedUsername.toLowerCase() !==
|
||||
game.black?.username?.toLowerCase();
|
||||
onSelect(game.pgn, boardOrientation);
|
||||
debouncedUsername.toLowerCase() !==
|
||||
game.black?.name?.toLowerCase();
|
||||
onSelect(game.pgn, boardOrientation);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
86
src/sections/loadGame/gameItem/gameResultChip.tsx
Normal file
86
src/sections/loadGame/gameItem/gameResultChip.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Icon } from "@iconify/react";
|
||||
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, icon } = getResultSpecs(
|
||||
theme,
|
||||
perspectiveUserColor,
|
||||
result
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip title={label}>
|
||||
<Chip
|
||||
icon={icon}
|
||||
label={result}
|
||||
size="small"
|
||||
sx={{
|
||||
color,
|
||||
backgroundColor: bgColor,
|
||||
fontWeight: "600",
|
||||
minWidth: icon ? 65 : 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`,
|
||||
icon: <Icon icon="material-symbols:emoji-events" />,
|
||||
};
|
||||
}
|
||||
|
||||
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`,
|
||||
icon: <Icon icon="material-symbols:handshake" />,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: "Game in Progress",
|
||||
color: theme.palette.text.secondary,
|
||||
bgColor: theme.palette.action.hover,
|
||||
icon: <Icon icon="material-symbols:play-circle-outline" />,
|
||||
};
|
||||
};
|
||||
@@ -5,55 +5,26 @@ import {
|
||||
Typography,
|
||||
Box,
|
||||
useTheme,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import { Icon } from "@iconify/react";
|
||||
import {
|
||||
DateChip,
|
||||
GameResult,
|
||||
MovesChip,
|
||||
TimeControlChip,
|
||||
} from "./game-item-utils";
|
||||
import { LoadedGame } from "@/types/game";
|
||||
import TimeControlChip from "./timeControlChip";
|
||||
import MovesNbChip from "./movesNbChip";
|
||||
import DateChip from "./dateChip";
|
||||
import GameResultChip from "./gameResultChip";
|
||||
|
||||
type ChessComPlayer = {
|
||||
username: string;
|
||||
rating: number;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type ChessComGameProps = {
|
||||
id: string;
|
||||
white: ChessComPlayer;
|
||||
black: ChessComPlayer;
|
||||
result: string;
|
||||
timeControl: string;
|
||||
date: string;
|
||||
opening?: string;
|
||||
moves?: number;
|
||||
url: string;
|
||||
interface Props {
|
||||
game: LoadedGame;
|
||||
onClick?: () => void;
|
||||
perspectiveUserColor: "white" | "black";
|
||||
};
|
||||
}
|
||||
|
||||
export const ChessComGameItem: React.FC<ChessComGameProps> = ({
|
||||
white,
|
||||
black,
|
||||
result,
|
||||
timeControl,
|
||||
date,
|
||||
moves,
|
||||
url,
|
||||
export const GameItem: React.FC<Props> = ({
|
||||
game,
|
||||
onClick,
|
||||
perspectiveUserColor,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const formatPlayerName = (player: ChessComPlayer) => {
|
||||
return player.title
|
||||
? `${player.title} ${player.username}`
|
||||
: player.username;
|
||||
};
|
||||
const { white, black, result, timeControl, date, movesNb } = game;
|
||||
|
||||
const whiteWon = result === "1-0";
|
||||
const blackWon = result === "0-1";
|
||||
@@ -122,7 +93,7 @@ export const ChessComGameItem: React.FC<ChessComGameProps> = ({
|
||||
{formatPlayerName(black)} ({black.rating})
|
||||
</Typography>
|
||||
|
||||
<GameResult
|
||||
<GameResultChip
|
||||
result={result}
|
||||
perspectiveUserColor={perspectiveUserColor}
|
||||
/>
|
||||
@@ -138,14 +109,14 @@ export const ChessComGameItem: React.FC<ChessComGameProps> = ({
|
||||
}}
|
||||
>
|
||||
<TimeControlChip timeControl={timeControl} />
|
||||
{moves && moves > 0 && <MovesChip moves={moves} />}
|
||||
<MovesNbChip movesNb={movesNb} />
|
||||
<DateChip date={date} />
|
||||
</Box>
|
||||
}
|
||||
sx={{ mr: 2 }}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", ml: "auto" }}>
|
||||
{/* <Box sx={{ display: "flex", alignItems: "center", ml: "auto" }}>
|
||||
<Tooltip title="View on Chess.com">
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
@@ -165,7 +136,11 @@ export const ChessComGameItem: React.FC<ChessComGameProps> = ({
|
||||
<Icon icon="material-symbols:open-in-new" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box> */}
|
||||
</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">
|
||||
<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,16 +10,15 @@ interface Props {
|
||||
export default function GamePgnInput({ pgn, setPgn }: Props) {
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const fileContent = e.target?.result as string;
|
||||
setPgn(fileContent);
|
||||
};
|
||||
reader.onload = (e) => {
|
||||
const fileContent = e.target?.result as string;
|
||||
setPgn(fileContent);
|
||||
};
|
||||
|
||||
reader.readAsText(file); // Read the file as text
|
||||
}
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -39,12 +38,7 @@ export default function GamePgnInput({ pgn, setPgn }: Props) {
|
||||
startIcon={<Icon icon="material-symbols:upload" />}
|
||||
>
|
||||
Choose PGN File
|
||||
<input
|
||||
type="file"
|
||||
hidden // Hide the default file input
|
||||
accept=".pgn" // Only allow .pgn files
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<input type="file" hidden accept=".pgn" onChange={handleFileChange} />
|
||||
</Button>
|
||||
</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 { getLichessUserRecentGames } from "@/lib/lichess";
|
||||
import {
|
||||
@@ -13,44 +11,13 @@ import {
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { LichessGameItem } from "./lichess-game-item";
|
||||
import { LichessRawGameData, NormalizedLichessGameData } from "@/types/lichess";
|
||||
import { useMemo, useState } from "react";
|
||||
import { GameItem } from "./gameItem";
|
||||
|
||||
interface Props {
|
||||
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) {
|
||||
const [rawStoredValue, setStoredValues] = useLocalStorage<string>(
|
||||
"lichess-username",
|
||||
@@ -181,24 +148,23 @@ export default function LichessInput({ onSelect }: Props) {
|
||||
) : (
|
||||
<List sx={{ width: "100%", maxWidth: 800 }}>
|
||||
{games.map((game) => {
|
||||
const normalizedGame = normalizeLichessData(game);
|
||||
const perspectiveUserColor =
|
||||
normalizedGame.white.username.toLowerCase() ===
|
||||
lichessUsername.toLowerCase()
|
||||
game.white.name.toLowerCase() ===
|
||||
debouncedUsername.toLowerCase()
|
||||
? "white"
|
||||
: "black";
|
||||
|
||||
return (
|
||||
<LichessGameItem
|
||||
<GameItem
|
||||
key={game.id}
|
||||
{...normalizedGame}
|
||||
game={game}
|
||||
perspectiveUserColor={perspectiveUserColor}
|
||||
onClick={() => {
|
||||
const boardOrientation =
|
||||
debouncedUsername.toLowerCase() !==
|
||||
game.black.name.toLowerCase();
|
||||
onSelect(game.pgn, boardOrientation);
|
||||
updateHistory(debouncedUsername);
|
||||
const boardOrientation =
|
||||
debouncedUsername.toLowerCase() !==
|
||||
game.players?.black?.user?.name?.toLowerCase();
|
||||
onSelect(game.pgn, boardOrientation);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,46 +1,20 @@
|
||||
interface ChessComPlayerData {
|
||||
interface ChessComPlayer {
|
||||
username: string;
|
||||
rating: number;
|
||||
result?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface ChessComOpeningData {
|
||||
name: string;
|
||||
eco?: string;
|
||||
}
|
||||
|
||||
export interface ChessComRawGameData {
|
||||
export interface ChessComGame {
|
||||
uuid: string;
|
||||
id: string;
|
||||
url: string;
|
||||
pgn: string;
|
||||
white: ChessComPlayerData;
|
||||
black: ChessComPlayerData;
|
||||
white: ChessComPlayer;
|
||||
black: ChessComPlayer;
|
||||
result: string;
|
||||
time_control: string;
|
||||
end_time: number;
|
||||
opening?: ChessComOpeningData;
|
||||
eco?: 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;
|
||||
rating?: number;
|
||||
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",
|
||||
}
|
||||
|
||||
interface LichessPlayerData {
|
||||
interface LichessPlayer {
|
||||
user: {
|
||||
name: string;
|
||||
title?: string;
|
||||
@@ -25,51 +25,24 @@ interface LichessPlayerData {
|
||||
rating: number;
|
||||
}
|
||||
|
||||
interface LichessClockData {
|
||||
interface LichessClock {
|
||||
initial: number;
|
||||
increment: number;
|
||||
totalTime: number;
|
||||
}
|
||||
|
||||
interface LichessOpeningData {
|
||||
eco: string;
|
||||
name: string;
|
||||
ply: number;
|
||||
}
|
||||
|
||||
export interface LichessRawGameData {
|
||||
export interface LichessGame {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
lastMoveAt: number;
|
||||
status: string;
|
||||
players: {
|
||||
white: LichessPlayerData;
|
||||
black: LichessPlayerData;
|
||||
white: LichessPlayer;
|
||||
black: LichessPlayer;
|
||||
};
|
||||
winner?: "white" | "black";
|
||||
opening?: LichessOpeningData;
|
||||
moves: string;
|
||||
pgn: string;
|
||||
clock: LichessClockData;
|
||||
clock: LichessClock;
|
||||
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