style : enhance game loading and choosing UI (#43)
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
import { ChessComGame } from "@/types/chessCom";
|
import { ChessComRawGameData } from "@/types/chessCom";
|
||||||
import { getPaddedNumber } from "./helpers";
|
import { getPaddedNumber } from "./helpers";
|
||||||
|
|
||||||
export const getChessComUserRecentGames = async (
|
export const getChessComUserRecentGames = async (
|
||||||
username: string,
|
username: string,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<ChessComGame[]> => {
|
): Promise<ChessComRawGameData[]> => {
|
||||||
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 +24,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: ChessComGame[] = data?.games ?? [];
|
const games: ChessComRawGameData[] = data?.games ?? [];
|
||||||
|
|
||||||
if (games.length < 50) {
|
if (games.length < 50) {
|
||||||
const previousMonth = month === 1 ? 12 : month - 1;
|
const previousMonth = month === 1 ? 12 : month - 1;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { sortLines } from "./engine/helpers/parseResults";
|
|||||||
import {
|
import {
|
||||||
LichessError,
|
LichessError,
|
||||||
LichessEvalBody,
|
LichessEvalBody,
|
||||||
LichessGame,
|
LichessRawGameData,
|
||||||
LichessResponse,
|
LichessResponse,
|
||||||
} from "@/types/lichess";
|
} from "@/types/lichess";
|
||||||
import { logErrorToSentry } from "./sentry";
|
import { logErrorToSentry } from "./sentry";
|
||||||
@@ -58,7 +58,7 @@ export const getLichessEval = async (
|
|||||||
export const getLichessUserRecentGames = async (
|
export const getLichessUserRecentGames = async (
|
||||||
username: string,
|
username: string,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<LichessGame[]> => {
|
): Promise<LichessRawGameData[]> => {
|
||||||
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,7 +69,7 @@ export const getLichessUserRecentGames = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rawData = await res.text();
|
const rawData = await res.text();
|
||||||
const games: LichessGame[] = rawData
|
const games: LichessRawGameData[] = 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));
|
||||||
|
|||||||
171
src/sections/loadGame/chess-com-game-item.tsx
Normal file
171
src/sections/loadGame/chess-com-game-item.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import {
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
useTheme,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import {
|
||||||
|
DateChip,
|
||||||
|
GameResult,
|
||||||
|
MovesChip,
|
||||||
|
TimeControlChip,
|
||||||
|
} from "./game-item-utils";
|
||||||
|
|
||||||
|
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;
|
||||||
|
onClick?: () => void;
|
||||||
|
perspectiveUserColor: "white" | "black";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChessComGameItem: React.FC<ChessComGameProps> = ({
|
||||||
|
white,
|
||||||
|
black,
|
||||||
|
result,
|
||||||
|
timeControl,
|
||||||
|
date,
|
||||||
|
moves,
|
||||||
|
url,
|
||||||
|
onClick,
|
||||||
|
perspectiveUserColor,
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const formatPlayerName = (player: ChessComPlayer) => {
|
||||||
|
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} />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,24 +1,113 @@
|
|||||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||||
import { getChessComUserRecentGames } from "@/lib/chessCom";
|
import { getChessComUserRecentGames } from "@/lib/chessCom";
|
||||||
import { capitalize } from "@/lib/helpers";
|
|
||||||
import {
|
import {
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
FormControl,
|
FormControl,
|
||||||
Grid2 as Grid,
|
Grid2 as Grid,
|
||||||
ListItemButton,
|
|
||||||
ListItemText,
|
|
||||||
TextField,
|
TextField,
|
||||||
|
List,
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
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";
|
||||||
|
|
||||||
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",
|
||||||
@@ -148,40 +237,31 @@ export default function ChessComInput({ onSelect }: Props) {
|
|||||||
No games found. Please check your username.
|
No games found. Please check your username.
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
games.map((game) => (
|
<List sx={{ width: "100%", maxWidth: 800 }}>
|
||||||
<ListItemButton
|
{games.map((game) => {
|
||||||
onClick={() => {
|
const normalizedGame = normalizeChessComData(game);
|
||||||
updateHistory(debouncedUsername);
|
const perspectiveUserColor =
|
||||||
const boardOrientation =
|
normalizedGame.white.username.toLowerCase() ===
|
||||||
|
chessComUsername.toLowerCase()
|
||||||
|
? "white"
|
||||||
|
: "black";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChessComGameItem
|
||||||
|
key={game.uuid}
|
||||||
|
{...normalizedGame}
|
||||||
|
perspectiveUserColor={perspectiveUserColor}
|
||||||
|
onClick={() => {
|
||||||
|
updateHistory(debouncedUsername);
|
||||||
|
const boardOrientation =
|
||||||
debouncedUsername.toLowerCase() !==
|
debouncedUsername.toLowerCase() !==
|
||||||
game.black?.username?.toLowerCase();
|
game.black?.username?.toLowerCase();
|
||||||
onSelect(game.pgn, boardOrientation);
|
onSelect(game.pgn, boardOrientation);
|
||||||
}}
|
}}
|
||||||
style={{ width: 350, maxWidth: 350 }}
|
/>
|
||||||
key={game.uuid}
|
);
|
||||||
>
|
})}
|
||||||
<ListItemText
|
</List>
|
||||||
primary={`${capitalize(game.white?.username || "white")} (${
|
|
||||||
game.white?.rating || "?"
|
|
||||||
}) vs ${capitalize(game.black?.username || "black")} (${
|
|
||||||
game.black?.rating || "?"
|
|
||||||
})`}
|
|
||||||
secondary={
|
|
||||||
game.end_time
|
|
||||||
? `${capitalize(game.time_class || "game")} played at ${new Date(
|
|
||||||
game.end_time * 1000
|
|
||||||
)
|
|
||||||
.toLocaleString()
|
|
||||||
.slice(0, -3)}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
slotProps={{
|
|
||||||
primary: { noWrap: true },
|
|
||||||
secondary: { noWrap: true },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItemButton>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|||||||
105
src/sections/loadGame/game-item-utils.tsx
Normal file
105
src/sections/loadGame/game-item-utils.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { FormControl, TextField } from "@mui/material";
|
import { FormControl, TextField, Button } from "@mui/material";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pgn: string;
|
pgn: string;
|
||||||
@@ -6,6 +8,20 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function GamePgnInput({ pgn, setPgn }: 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();
|
||||||
|
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const fileContent = e.target?.result as string;
|
||||||
|
setPgn(fileContent);
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file); // Read the file as text
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -14,7 +30,22 @@ export default function GamePgnInput({ pgn, setPgn }: Props) {
|
|||||||
multiline
|
multiline
|
||||||
value={pgn}
|
value={pgn}
|
||||||
onChange={(e) => setPgn(e.target.value)}
|
onChange={(e) => setPgn(e.target.value)}
|
||||||
|
rows={8}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
component="label"
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
179
src/sections/loadGame/lichess-game-item.tsx
Normal file
179
src/sections/loadGame/lichess-game-item.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
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,24 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||||
import { getLichessUserRecentGames } from "@/lib/lichess";
|
import { getLichessUserRecentGames } from "@/lib/lichess";
|
||||||
import { capitalize } from "@/lib/helpers";
|
|
||||||
import {
|
import {
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
FormControl,
|
FormControl,
|
||||||
Grid2 as Grid,
|
Grid2 as Grid,
|
||||||
ListItemButton,
|
|
||||||
ListItemText,
|
|
||||||
TextField,
|
TextField,
|
||||||
|
List,
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
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";
|
||||||
|
|
||||||
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",
|
||||||
@@ -147,42 +179,31 @@ export default function LichessInput({ onSelect }: Props) {
|
|||||||
No games found. Please check your username.
|
No games found. Please check your username.
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
games.map((game) => (
|
<List sx={{ width: "100%", maxWidth: 800 }}>
|
||||||
<ListItemButton
|
{games.map((game) => {
|
||||||
onClick={() => {
|
const normalizedGame = normalizeLichessData(game);
|
||||||
updateHistory(debouncedUsername);
|
const perspectiveUserColor =
|
||||||
|
normalizedGame.white.username.toLowerCase() ===
|
||||||
|
lichessUsername.toLowerCase()
|
||||||
|
? "white"
|
||||||
|
: "black";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LichessGameItem
|
||||||
|
key={game.id}
|
||||||
|
{...normalizedGame}
|
||||||
|
perspectiveUserColor={perspectiveUserColor}
|
||||||
|
onClick={() => {
|
||||||
|
updateHistory(debouncedUsername);
|
||||||
const boardOrientation =
|
const boardOrientation =
|
||||||
debouncedUsername.toLowerCase() !==
|
debouncedUsername.toLowerCase() !==
|
||||||
game.players?.black?.user?.name?.toLowerCase();
|
game.players?.black?.user?.name?.toLowerCase();
|
||||||
onSelect(game.pgn, boardOrientation);
|
onSelect(game.pgn, boardOrientation);
|
||||||
}}
|
}}
|
||||||
style={{ width: 350, maxWidth: 350 }}
|
/>
|
||||||
key={game.id}
|
);
|
||||||
>
|
})}
|
||||||
<ListItemText
|
</List>
|
||||||
primary={`${
|
|
||||||
capitalize(game.players?.white?.user?.name || "white") ||
|
|
||||||
"White"
|
|
||||||
} (${game.players?.white?.rating || "?"}) vs ${
|
|
||||||
capitalize(game.players?.black?.user?.name || "black") ||
|
|
||||||
"Black"
|
|
||||||
} (${game.players?.black?.rating || "?"})`}
|
|
||||||
secondary={
|
|
||||||
game.lastMoveAt
|
|
||||||
? `${capitalize(game.speed || "game")} played at ${new Date(
|
|
||||||
game.lastMoveAt
|
|
||||||
)
|
|
||||||
.toLocaleString()
|
|
||||||
.slice(0, -3)}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
slotProps={{
|
|
||||||
primary: { noWrap: true },
|
|
||||||
secondary: { noWrap: true },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItemButton>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,14 +1,46 @@
|
|||||||
export interface ChessComGame {
|
interface ChessComPlayerData {
|
||||||
uuid: string;
|
|
||||||
white: ChessComUser;
|
|
||||||
black: ChessComUser;
|
|
||||||
end_time: number;
|
|
||||||
pgn: string;
|
|
||||||
time_class: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChessComUser {
|
|
||||||
username: string;
|
username: string;
|
||||||
rating: number;
|
rating: number;
|
||||||
["@id"]: string;
|
result?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChessComOpeningData {
|
||||||
|
name: string;
|
||||||
|
eco?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChessComRawGameData {
|
||||||
|
uuid: string;
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
pgn: string;
|
||||||
|
white: ChessComPlayerData;
|
||||||
|
black: ChessComPlayerData;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,18 +17,59 @@ export enum LichessError {
|
|||||||
NotFound = "No cloud evaluation available for that position",
|
NotFound = "No cloud evaluation available for that position",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LichessGame {
|
interface LichessPlayerData {
|
||||||
id: string;
|
user: {
|
||||||
speed: string;
|
name: string;
|
||||||
lastMoveAt: number;
|
title?: string;
|
||||||
players?: {
|
|
||||||
white?: LichessGameUser;
|
|
||||||
black?: LichessGameUser;
|
|
||||||
};
|
};
|
||||||
pgn: string;
|
rating: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LichessGameUser {
|
interface LichessClockData {
|
||||||
user?: { id: string; name: string };
|
initial: number;
|
||||||
rating?: number;
|
increment: number;
|
||||||
|
totalTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LichessOpeningData {
|
||||||
|
eco: string;
|
||||||
|
name: string;
|
||||||
|
ply: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LichessRawGameData {
|
||||||
|
id: string;
|
||||||
|
createdAt: number;
|
||||||
|
lastMoveAt: number;
|
||||||
|
status: string;
|
||||||
|
players: {
|
||||||
|
white: LichessPlayerData;
|
||||||
|
black: LichessPlayerData;
|
||||||
|
};
|
||||||
|
winner?: "white" | "black";
|
||||||
|
opening?: LichessOpeningData;
|
||||||
|
moves: string;
|
||||||
|
pgn: string;
|
||||||
|
clock: LichessClockData;
|
||||||
|
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