feat : add chessCom games import
This commit is contained in:
@@ -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
|
||||||
|
|||||||
29
src/hooks/useAtomLocalStorage.ts
Normal file
29
src/hooks/useAtomLocalStorage.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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
41
src/lib/chessCom.ts
Normal 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
7
src/lib/helpers.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
93
src/sections/loadGame/chessComInput.tsx
Normal file
93
src/sections/loadGame/chessComInput.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/sections/loadGame/gamePgnInput.tsx
Normal file
20
src/sections/loadGame/gamePgnInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
14
src/types/chessCom.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user