feat : add chessCom games import

This commit is contained in:
GuillaumeSD
2024-02-27 03:32:46 +01:00
parent b2b80b1cc1
commit 13a4bc06b6
13 changed files with 270 additions and 30 deletions

View File

@@ -1,8 +1,8 @@
import { Grid, Slider as MuiSlider, Typography } from "@mui/material"; import { Grid, Slider as MuiSlider, Typography } from "@mui/material";
import { PrimitiveAtom, useAtom } from "jotai";
interface Props { interface Props {
atom: PrimitiveAtom<number>; value: number;
setValue: (value: number) => void;
min: number; min: number;
max: number; max: number;
label: string; label: string;
@@ -14,12 +14,11 @@ export default function Slider({
min, min,
max, max,
label, label,
atom, value,
setValue,
xs, xs,
marksFilter = 1, marksFilter = 1,
}: Props) { }: Props) {
const [value, setValue] = useAtom(atom);
return ( return (
<Grid <Grid
item item

View File

@@ -0,0 +1,29 @@
import { PrimitiveAtom, SetStateAction, useAtom } from "jotai";
import { useEffect, useState } from "react";
export function useAtomLocalStorage<T>(
key: string,
atom: PrimitiveAtom<T>
): [T, (value: SetStateAction<T>) => void] {
const [keyTemp, setKeyTemp] = useState("");
const [storedValue, setStoredValue] = useAtom(atom);
useEffect(() => {
const item = window.localStorage.getItem(key);
if (item) {
setStoredValue(parseJSON<T>(item));
}
setKeyTemp(key);
}, [key, setStoredValue]);
useEffect(() => {
if (keyTemp !== key) return;
window.localStorage.setItem(key, JSON.stringify(storedValue));
}, [key, keyTemp, storedValue]);
return [storedValue, setStoredValue];
}
function parseJSON<T>(value: string): T {
return value === "undefined" ? undefined : JSON.parse(value);
}

View File

@@ -21,7 +21,7 @@ export const formatGameToDatabase = (game: Chess): Omit<Game, "id"> => {
event: headers.Event, event: headers.Event,
site: headers.Site, site: headers.Site,
date: headers.Date, date: headers.Date,
round: headers.Round, round: headers.Round ?? "?",
white: { white: {
name: headers.White, name: headers.White,
rating: headers.WhiteElo ? Number(headers.WhiteElo) : undefined, rating: headers.WhiteElo ? Number(headers.WhiteElo) : undefined,

41
src/lib/chessCom.ts Normal file
View File

@@ -0,0 +1,41 @@
import { ChessComGame } from "@/types/chessCom";
import { getPaddedMonth } from "./helpers";
export const getUserRecentGames = async (
username: string
): Promise<ChessComGame[]> => {
const date = new Date();
const year = date.getUTCFullYear();
const month = date.getUTCMonth() + 1;
const paddedMonth = getPaddedMonth(month);
const res = await fetch(
`https://api.chess.com/pub/player/${username}/games/${year}/${paddedMonth}`
);
if (res.status === 404) return [];
const data = await res.json();
const games: ChessComGame[] = data?.games ?? [];
if (games.length < 20) {
const previousMonth = month === 1 ? 12 : month - 1;
const previousPaddedMonth = getPaddedMonth(previousMonth);
const yearToFetch = previousMonth === 12 ? year - 1 : year;
const resPreviousMonth = await fetch(
`https://api.chess.com/pub/player/${username}/games/${yearToFetch}/${previousPaddedMonth}`
);
const dataPreviousMonth = await resPreviousMonth.json();
games.push(...(dataPreviousMonth?.games ?? []));
}
games.sort((a, b) => {
return b.end_time - a.end_time;
});
return games;
};

7
src/lib/helpers.ts Normal file
View File

@@ -0,0 +1,7 @@
export const getPaddedMonth = (month: number) => {
return month < 10 ? `0${month}` : month;
};
export const capitalize = (s: string) => {
return s.charAt(0).toUpperCase() + s.slice(1);
};

View File

@@ -170,6 +170,16 @@ export default function GameDatabase() {
hideFooter={true} hideFooter={true}
autoHeight={true} autoHeight={true}
localeText={gridLocaleText} localeText={gridLocaleText}
initialState={{
sorting: {
sortModel: [
{
field: "date",
sort: "desc",
},
],
},
}}
/> />
</Grid> </Grid>
</Grid> </Grid>

View File

@@ -1,13 +1,19 @@
import { Checkbox, FormControlLabel, Grid } from "@mui/material"; import { Checkbox, FormControlLabel, Grid } from "@mui/material";
import { useAtom } from "jotai";
import { import {
showBestMoveArrowAtom, showBestMoveArrowAtom,
showPlayerMoveArrowAtom, showPlayerMoveArrowAtom,
} from "../analysis/states"; } from "../analysis/states";
import { useAtomLocalStorage } from "@/hooks/useAtomLocalStorage";
export default function ArrowOptions() { export default function ArrowOptions() {
const [showBestMove, setShowBestMove] = useAtom(showBestMoveArrowAtom); const [showBestMove, setShowBestMove] = useAtomLocalStorage(
const [showPlayerMove, setShowPlayerMove] = useAtom(showPlayerMoveArrowAtom); "show-arrow-best-move",
showBestMoveArrowAtom
);
const [showPlayerMove, setShowPlayerMove] = useAtomLocalStorage(
"show-arrow-player-move",
showPlayerMoveArrowAtom
);
return ( return (
<Grid <Grid

View File

@@ -16,6 +16,7 @@ import {
} from "@mui/material"; } from "@mui/material";
import { engineDepthAtom, engineMultiPvAtom } from "../analysis/states"; import { engineDepthAtom, engineMultiPvAtom } from "../analysis/states";
import ArrowOptions from "./arrowOptions"; import ArrowOptions from "./arrowOptions";
import { useAtomLocalStorage } from "@/hooks/useAtomLocalStorage";
interface Props { interface Props {
open: boolean; open: boolean;
@@ -23,6 +24,15 @@ interface Props {
} }
export default function EngineSettingsDialog({ open, onClose }: Props) { export default function EngineSettingsDialog({ open, onClose }: Props) {
const [depth, setDepth] = useAtomLocalStorage(
"engine-depth",
engineDepthAtom
);
const [multiPv, setMultiPv] = useAtomLocalStorage(
"engine-multi-pv",
engineMultiPvAtom
);
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth> <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle marginY={1} variant="h5"> <DialogTitle marginY={1} variant="h5">
@@ -65,7 +75,8 @@ export default function EngineSettingsDialog({ open, onClose }: Props) {
<Slider <Slider
label="Maximum depth" label="Maximum depth"
atom={engineDepthAtom} value={depth}
setValue={setDepth}
min={10} min={10}
max={30} max={30}
marksFilter={2} marksFilter={2}
@@ -73,7 +84,8 @@ export default function EngineSettingsDialog({ open, onClose }: Props) {
<Slider <Slider
label="Number of lines" label="Number of lines"
atom={engineMultiPvAtom} value={multiPv}
setValue={setMultiPv}
min={1} min={1}
max={6} max={6}
xs={6} xs={6}

View File

@@ -0,0 +1,93 @@
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { getUserRecentGames } from "@/lib/chessCom";
import { capitalize } from "@/lib/helpers";
import { ChessComGame } from "@/types/chessCom";
import {
FormControl,
Grid,
ListItemButton,
ListItemText,
TextField,
} from "@mui/material";
import { useEffect, useState } from "react";
interface Props {
pgn: string;
setPgn: (pgn: string) => void;
}
export default function ChessComInput({ pgn, setPgn }: Props) {
const [requestCount, setRequestCount] = useState(0);
const [chessComUsername, setChessComUsername] = useLocalStorage(
"chesscom-username",
""
);
const [games, setGames] = useState<ChessComGame[]>([]);
useEffect(() => {
if (!chessComUsername) {
setGames([]);
return;
}
const timeout = setTimeout(
async () => {
const games = await getUserRecentGames(chessComUsername);
setGames(games);
},
requestCount === 0 ? 0 : 500
);
setRequestCount((prev) => prev + 1);
return () => {
clearTimeout(timeout);
};
}, [chessComUsername]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<>
<FormControl sx={{ m: 1, width: 300 }}>
<TextField
label="Enter your Chess.com username..."
variant="outlined"
value={chessComUsername ?? ""}
onChange={(e) => setChessComUsername(e.target.value)}
/>
</FormControl>
<Grid
container
item
xs={12}
gap={2}
justifyContent="center"
alignContent="center"
>
{games.map((game) => (
<ListItemButton
onClick={() => setPgn(game.pgn)}
selected={pgn === game.pgn}
style={{ width: 350, maxWidth: 350 }}
key={game.uuid}
>
<ListItemText
primary={`${capitalize(game.white.username) || "White"} (${
game.white.rating || "?"
}) vs ${capitalize(game.black.username) || "Black"} (${
game.black.rating || "?"
})`}
secondary={`${capitalize(game.time_class)} played at ${new Date(
game.end_time * 1000
)
.toLocaleString()
.slice(0, -3)}`}
primaryTypographyProps={{ noWrap: true }}
secondaryTypographyProps={{ noWrap: true }}
/>
</ListItemButton>
))}
</Grid>
</>
);
}

View File

@@ -0,0 +1,20 @@
import { FormControl, TextField } from "@mui/material";
interface Props {
pgn: string;
setPgn: (pgn: string) => void;
}
export default function GamePgnInput({ pgn, setPgn }: Props) {
return (
<FormControl sx={{ m: 1, width: 600 }}>
<TextField
label="Enter PGN here..."
variant="outlined"
multiline
value={pgn}
onChange={(e) => setPgn(e.target.value)}
/>
</FormControl>
);
}

View File

@@ -8,16 +8,18 @@ import {
Dialog, Dialog,
DialogTitle, DialogTitle,
DialogContent, DialogContent,
Box,
FormControl, FormControl,
InputLabel, InputLabel,
OutlinedInput, OutlinedInput,
DialogActions, DialogActions,
TextField,
Typography, Typography,
Grid,
} from "@mui/material"; } from "@mui/material";
import { Chess } from "chess.js"; import { Chess } from "chess.js";
import { useState } from "react"; import { useState } from "react";
import GamePgnInput from "./gamePgnInput";
import ChessComInput from "./chessComInput";
import { useLocalStorage } from "@/hooks/useLocalStorage";
interface Props { interface Props {
open: boolean; open: boolean;
@@ -27,6 +29,10 @@ interface Props {
export default function NewGameDialog({ open, onClose, setGame }: Props) { export default function NewGameDialog({ open, onClose, setGame }: Props) {
const [pgn, setPgn] = useState(""); const [pgn, setPgn] = useState("");
const [gameOrigin, setGameOrigin] = useLocalStorage(
"preferred-game-origin",
GameOrigin.Pgn
);
const [parsingError, setParsingError] = useState(""); const [parsingError, setParsingError] = useState("");
const { addGame } = useGameDatabase(); const { addGame } = useGameDatabase();
@@ -63,11 +69,16 @@ export default function NewGameDialog({ open, onClose, setGame }: Props) {
return ( return (
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth> <Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogTitle marginY={1} variant="h5"> <DialogTitle marginY={1} variant="h5">
Add a game to your database {setGame ? "Load a game" : "Add a game to your database"}
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
<Typography>Only PGN input is supported at the moment</Typography> <Grid
<Box sx={{ display: "flex", flexWrap: "wrap" }} marginTop={4}> container
marginTop={1}
alignItems="center"
justifyContent="start"
rowGap={2}
>
<FormControl sx={{ m: 1, width: 150 }}> <FormControl sx={{ m: 1, width: 150 }}>
<InputLabel id="dialog-select-label">Game origin</InputLabel> <InputLabel id="dialog-select-label">Game origin</InputLabel>
<Select <Select
@@ -75,8 +86,8 @@ export default function NewGameDialog({ open, onClose, setGame }: Props) {
id="dialog-select" id="dialog-select"
displayEmpty displayEmpty
input={<OutlinedInput label="Game origin" />} input={<OutlinedInput label="Game origin" />}
value={GameOrigin.Pgn} value={gameOrigin ?? ""}
disabled={true} onChange={(e) => setGameOrigin(e.target.value as GameOrigin)}
> >
{Object.values(GameOrigin).map((origin) => ( {Object.values(GameOrigin).map((origin) => (
<MenuItem key={origin} value={origin}> <MenuItem key={origin} value={origin}>
@@ -85,15 +96,15 @@ export default function NewGameDialog({ open, onClose, setGame }: Props) {
))} ))}
</Select> </Select>
</FormControl> </FormControl>
<FormControl sx={{ m: 1, width: 600 }}>
<TextField {gameOrigin === GameOrigin.Pgn && (
label="Enter PGN here..." <GamePgnInput pgn={pgn} setPgn={setPgn} />
variant="outlined" )}
multiline
value={pgn} {gameOrigin === GameOrigin.ChessCom && (
onChange={(e) => setPgn(e.target.value)} <ChessComInput pgn={pgn} setPgn={setPgn} />
/> )}
</FormControl>
{parsingError && ( {parsingError && (
<FormControl fullWidth> <FormControl fullWidth>
<Typography color="red" textAlign="center" marginTop={1}> <Typography color="red" textAlign="center" marginTop={1}>
@@ -101,7 +112,7 @@ export default function NewGameDialog({ open, onClose, setGame }: Props) {
</Typography> </Typography>
</FormControl> </FormControl>
)} )}
</Box> </Grid>
</DialogContent> </DialogContent>
<DialogActions sx={{ m: 2 }}> <DialogActions sx={{ m: 2 }}>
<Button <Button
@@ -122,5 +133,4 @@ export default function NewGameDialog({ open, onClose, setGame }: Props) {
const gameOriginLabel: Record<GameOrigin, string> = { const gameOriginLabel: Record<GameOrigin, string> = {
[GameOrigin.Pgn]: "PGN", [GameOrigin.Pgn]: "PGN",
[GameOrigin.ChessCom]: "Chess.com", [GameOrigin.ChessCom]: "Chess.com",
[GameOrigin.Lichess]: "Lichess",
}; };

14
src/types/chessCom.ts Normal file
View File

@@ -0,0 +1,14 @@
export interface ChessComGame {
uuid: string;
white: ChessComUser;
black: ChessComUser;
end_time: number;
pgn: string;
time_class: string;
}
export interface ChessComUser {
username: string;
rating: number;
["@id"]: string;
}

View File

@@ -1,7 +1,6 @@
export enum GameOrigin { export enum GameOrigin {
Pgn = "pgn", Pgn = "pgn",
ChessCom = "chesscom", ChessCom = "chesscom",
Lichess = "lichess",
} }
export enum EngineName { export enum EngineName {