feat : improve game loading flow

This commit is contained in:
GuillaumeSD
2025-05-09 01:53:34 +02:00
parent 5dc1c4f485
commit 34fdde282c
4 changed files with 137 additions and 114 deletions

24
src/hooks/useDebounce.ts Normal file
View File

@@ -0,0 +1,24 @@
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delayMs: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
if (value === debouncedValue) return;
if (!debouncedValue) {
setDebouncedValue(value);
return;
}
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delayMs);
return () => {
clearTimeout(handler);
};
}, [value, delayMs, debouncedValue]);
return debouncedValue;
}

View File

@@ -1,7 +1,6 @@
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 { capitalize } from "@/lib/helpers";
import { ChessComGame } from "@/types/chessCom";
import { import {
CircularProgress, CircularProgress,
FormControl, FormControl,
@@ -11,42 +10,31 @@ import {
TextField, TextField,
} from "@mui/material"; } from "@mui/material";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { useEffect, useState } from "react";
import { boardOrientationAtom } from "../analysis/states"; import { boardOrientationAtom } from "../analysis/states";
import { useDebounce } from "@/hooks/useDebounce";
import { useQuery } from "@tanstack/react-query";
interface Props { interface Props {
onSelect: (pgn: string) => void; onSelect: (pgn: string) => void;
} }
export default function ChessComInput({ onSelect }: Props) { export default function ChessComInput({ onSelect }: Props) {
const [requestCount, setRequestCount] = useState(0);
const [chessComUsername, setChessComUsername] = useLocalStorage( const [chessComUsername, setChessComUsername] = useLocalStorage(
"chesscom-username", "chesscom-username",
"" ""
); );
const debouncedUsername = useDebounce(chessComUsername, 200);
const setBoardOrientation = useSetAtom(boardOrientationAtom); const setBoardOrientation = useSetAtom(boardOrientationAtom);
const [games, setGames] = useState<ChessComGame[]>([]);
useEffect(() => { const {
if (!chessComUsername) { data: games,
setGames([]); isFetching,
return; isError,
} } = useQuery({
queryKey: ["CCUserGames", debouncedUsername],
const timeout = setTimeout( enabled: !!debouncedUsername,
async () => { queryFn: () => getChessComUserRecentGames(debouncedUsername ?? ""),
const games = await getChessComUserRecentGames(chessComUsername); });
setGames(games);
},
requestCount === 0 ? 0 : 500
);
setRequestCount((prev) => prev + 1);
return () => {
clearTimeout(timeout);
};
}, [chessComUsername]); // eslint-disable-line react-hooks/exhaustive-deps
return ( return (
<> <>
@@ -68,38 +56,48 @@ export default function ChessComInput({ onSelect }: Props) {
minHeight={100} minHeight={100}
size={12} size={12}
> >
{games.map((game) => ( {isFetching ? (
<ListItemButton <CircularProgress />
onClick={() => { ) : isError ? (
setBoardOrientation( <span style={{ color: "salmon" }}>
chessComUsername.toLowerCase() !== User not found. Please check your username.
game.black.username.toLowerCase() </span>
); ) : !games?.length ? (
onSelect(game.pgn); <span style={{ color: "salmon" }}>
}} No games found. Please check your username.
style={{ width: 350, maxWidth: 350 }} </span>
key={game.uuid} ) : (
> games.map((game) => (
<ListItemText <ListItemButton
primary={`${capitalize(game.white.username) || "White"} (${ onClick={() => {
game.white.rating || "?" setBoardOrientation(
}) vs ${capitalize(game.black.username) || "Black"} (${ chessComUsername.toLowerCase() !==
game.black.rating || "?" game.black.username.toLowerCase()
})`} );
secondary={`${capitalize(game.time_class)} played at ${new Date( onSelect(game.pgn);
game.end_time * 1000
)
.toLocaleString()
.slice(0, -3)}`}
slotProps={{
primary: { noWrap: true },
secondary: { noWrap: true },
}} }}
/> style={{ width: 350, maxWidth: 350 }}
</ListItemButton> key={game.uuid}
))} >
<ListItemText
{games.length === 0 && <CircularProgress />} 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)}`}
slotProps={{
primary: { noWrap: true },
secondary: { noWrap: true },
}}
/>
</ListItemButton>
))
)}
</Grid> </Grid>
)} )}
</> </>

View File

@@ -1,7 +1,6 @@
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 { capitalize } from "@/lib/helpers";
import { LichessGame } from "@/types/lichess";
import { import {
CircularProgress, CircularProgress,
FormControl, FormControl,
@@ -10,43 +9,32 @@ import {
ListItemText, ListItemText,
TextField, TextField,
} from "@mui/material"; } from "@mui/material";
import { useEffect, useState } from "react";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { boardOrientationAtom } from "../analysis/states"; import { boardOrientationAtom } from "../analysis/states";
import { useDebounce } from "@/hooks/useDebounce";
import { useQuery } from "@tanstack/react-query";
interface Props { interface Props {
onSelect: (pgn: string) => void; onSelect: (pgn: string) => void;
} }
export default function LichessInput({ onSelect }: Props) { export default function LichessInput({ onSelect }: Props) {
const [requestCount, setRequestCount] = useState(0);
const [lichessUsername, setLichessUsername] = useLocalStorage( const [lichessUsername, setLichessUsername] = useLocalStorage(
"lichess-username", "lichess-username",
"" ""
); );
const debouncedUsername = useDebounce(lichessUsername, 200);
const setBoardOrientation = useSetAtom(boardOrientationAtom); const setBoardOrientation = useSetAtom(boardOrientationAtom);
const [games, setGames] = useState<LichessGame[]>([]);
useEffect(() => { const {
if (!lichessUsername) { data: games,
setGames([]); isFetching,
return; isError,
} } = useQuery({
queryKey: ["LichessUserGames", debouncedUsername],
const timeout = setTimeout( enabled: !!debouncedUsername,
async () => { queryFn: () => getLichessUserRecentGames(debouncedUsername ?? ""),
const games = await getLichessUserRecentGames(lichessUsername); });
setGames(games);
},
requestCount === 0 ? 0 : 500
);
setRequestCount((prev) => prev + 1);
return () => {
clearTimeout(timeout);
};
}, [lichessUsername]); // eslint-disable-line react-hooks/exhaustive-deps
return ( return (
<> <>
@@ -68,40 +56,50 @@ export default function LichessInput({ onSelect }: Props) {
minHeight={100} minHeight={100}
size={12} size={12}
> >
{games.map((game) => ( {isFetching ? (
<ListItemButton <CircularProgress />
onClick={() => { ) : isError ? (
setBoardOrientation( <span style={{ color: "salmon" }}>
lichessUsername.toLowerCase() !== User not found. Please check your username.
game.players.black.user?.name.toLowerCase() </span>
); ) : !games?.length ? (
onSelect(game.pgn); <span style={{ color: "salmon" }}>
}} No games found. Please check your username.
style={{ width: 350, maxWidth: 350 }} </span>
key={game.id} ) : (
> games.map((game) => (
<ListItemText <ListItemButton
primary={`${ onClick={() => {
capitalize(game.players.white.user?.name || "white") || setBoardOrientation(
"White" lichessUsername.toLowerCase() !==
} (${game.players?.white?.rating || "?"}) vs ${ game.players.black.user?.name.toLowerCase()
capitalize(game.players.black.user?.name || "black") || );
"Black" onSelect(game.pgn);
} (${game.players?.black?.rating || "?"})`}
secondary={`${capitalize(game.speed)} played at ${new Date(
game.lastMoveAt
)
.toLocaleString()
.slice(0, -3)}`}
slotProps={{
primary: { noWrap: true },
secondary: { noWrap: true },
}} }}
/> style={{ width: 350, maxWidth: 350 }}
</ListItemButton> key={game.id}
))} >
<ListItemText
{games.length === 0 && <CircularProgress />} 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={`${capitalize(game.speed)} played at ${new Date(
game.lastMoveAt
)
.toLocaleString()
.slice(0, -3)}`}
slotProps={{
primary: { noWrap: true },
secondary: { noWrap: true },
}}
/>
</ListItemButton>
))
)}
</Grid> </Grid>
)} )}
</> </>

View File

@@ -104,7 +104,10 @@ export default function NewGameDialog({ open, onClose, setGame }: Props) {
displayEmpty displayEmpty
input={<OutlinedInput label="Game origin" />} input={<OutlinedInput label="Game origin" />}
value={gameOrigin ?? ""} value={gameOrigin ?? ""}
onChange={(e) => setGameOrigin(e.target.value as GameOrigin)} onChange={(e) => {
setGameOrigin(e.target.value as GameOrigin);
setParsingError("");
}}
> >
{Object.values(GameOrigin).map((origin) => ( {Object.values(GameOrigin).map((origin) => (
<MenuItem key={origin} value={origin}> <MenuItem key={origin} value={origin}>
@@ -128,7 +131,7 @@ export default function NewGameDialog({ open, onClose, setGame }: Props) {
{parsingError && ( {parsingError && (
<FormControl fullWidth> <FormControl fullWidth>
<Typography color="red" textAlign="center" marginTop={1}> <Typography color="salmon" textAlign="center" marginTop={1}>
{parsingError} {parsingError}
</Typography> </Typography>
</FormControl> </FormControl>