From 839f87a2e8f465bd1095c0b7c8d520579ad10bd0 Mon Sep 17 00:00:00 2001 From: Ali Raza Khalid Date: Tue, 10 Jun 2025 03:58:31 +0500 Subject: [PATCH] style : enhance game loading and choosing UI (#43) --- src/lib/chessCom.ts | 6 +- src/lib/lichess.ts | 6 +- src/sections/loadGame/chess-com-game-item.tsx | 171 +++++++++++++++++ src/sections/loadGame/chessComInput.tsx | 148 +++++++++++---- src/sections/loadGame/game-item-utils.tsx | 105 ++++++++++ src/sections/loadGame/gamePgnInput.tsx | 33 +++- src/sections/loadGame/lichess-game-item.tsx | 179 ++++++++++++++++++ src/sections/loadGame/lichessInput.tsx | 91 +++++---- src/types/chessCom.ts | 54 ++++-- src/types/lichess.ts | 63 ++++-- 10 files changed, 758 insertions(+), 98 deletions(-) create mode 100644 src/sections/loadGame/chess-com-game-item.tsx create mode 100644 src/sections/loadGame/game-item-utils.tsx create mode 100644 src/sections/loadGame/lichess-game-item.tsx diff --git a/src/lib/chessCom.ts b/src/lib/chessCom.ts index 79035cc..3485955 100644 --- a/src/lib/chessCom.ts +++ b/src/lib/chessCom.ts @@ -1,10 +1,10 @@ -import { ChessComGame } from "@/types/chessCom"; +import { ChessComRawGameData } from "@/types/chessCom"; import { getPaddedNumber } from "./helpers"; export const getChessComUserRecentGames = async ( username: string, signal?: AbortSignal -): Promise => { +): Promise => { const date = new Date(); const year = date.getUTCFullYear(); const month = date.getUTCMonth() + 1; @@ -24,7 +24,7 @@ export const getChessComUserRecentGames = async ( throw new Error("Error fetching games from Chess.com"); } - const games: ChessComGame[] = data?.games ?? []; + const games: ChessComRawGameData[] = data?.games ?? []; if (games.length < 50) { const previousMonth = month === 1 ? 12 : month - 1; diff --git a/src/lib/lichess.ts b/src/lib/lichess.ts index 07a26d1..f42ba6d 100644 --- a/src/lib/lichess.ts +++ b/src/lib/lichess.ts @@ -3,7 +3,7 @@ import { sortLines } from "./engine/helpers/parseResults"; import { LichessError, LichessEvalBody, - LichessGame, + LichessRawGameData, LichessResponse, } from "@/types/lichess"; import { logErrorToSentry } from "./sentry"; @@ -58,7 +58,7 @@ export const getLichessEval = async ( export const getLichessUserRecentGames = async ( username: string, signal?: AbortSignal -): Promise => { +): Promise => { 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,7 +69,7 @@ export const getLichessUserRecentGames = async ( } const rawData = await res.text(); - const games: LichessGame[] = rawData + const games: LichessRawGameData[] = rawData .split("\n") .filter((game) => game.length > 0) .map((game) => JSON.parse(game)); diff --git a/src/sections/loadGame/chess-com-game-item.tsx b/src/sections/loadGame/chess-com-game-item.tsx new file mode 100644 index 0000000..ae17918 --- /dev/null +++ b/src/sections/loadGame/chess-com-game-item.tsx @@ -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 = ({ + 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 ( + + + + {formatPlayerName(white)} ({white.rating}) + + + + vs + + + + {formatPlayerName(black)} ({black.rating}) + + + + + } + secondary={ + + + {moves && moves > 0 && } + + + } + sx={{ mr: 2 }} + /> + + + + { + 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", + }} + > + + + + + + ); +}; diff --git a/src/sections/loadGame/chessComInput.tsx b/src/sections/loadGame/chessComInput.tsx index 084a98f..2300dbc 100644 --- a/src/sections/loadGame/chessComInput.tsx +++ b/src/sections/loadGame/chessComInput.tsx @@ -1,24 +1,113 @@ import { useLocalStorage } from "@/hooks/useLocalStorage"; import { getChessComUserRecentGames } from "@/lib/chessCom"; -import { capitalize } from "@/lib/helpers"; import { CircularProgress, FormControl, Grid2 as Grid, - ListItemButton, - ListItemText, TextField, + List, Autocomplete, } from "@mui/material"; 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"; 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( "chesscom-username", @@ -148,40 +237,31 @@ export default function ChessComInput({ onSelect }: Props) { No games found. Please check your username. ) : ( - games.map((game) => ( - { - updateHistory(debouncedUsername); - const boardOrientation = + + {games.map((game) => { + const normalizedGame = normalizeChessComData(game); + const perspectiveUserColor = + normalizedGame.white.username.toLowerCase() === + chessComUsername.toLowerCase() + ? "white" + : "black"; + + return ( + { + updateHistory(debouncedUsername); + const boardOrientation = debouncedUsername.toLowerCase() !== game.black?.username?.toLowerCase(); onSelect(game.pgn, boardOrientation); - }} - style={{ width: 350, maxWidth: 350 }} - key={game.uuid} - > - - - )) + }} + /> + ); + })} + )} )} diff --git a/src/sections/loadGame/game-item-utils.tsx b/src/sections/loadGame/game-item-utils.tsx new file mode 100644 index 0000000..f3b2235 --- /dev/null +++ b/src/sections/loadGame/game-item-utils.tsx @@ -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 = ; + 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 = ; + } else { + // perspectiveUserColor is black + color = theme.palette.error.main; // Confident red + bgColor = `${theme.palette.error.main}1A`; // 10% opacity + icon = ; // 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 = ; + } else { + // perspectiveUserColor is white + color = theme.palette.error.main; // Confident red + bgColor = `${theme.palette.error.main}1A`; // 10% opacity + icon = ; // 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 = ; + label = "Draw"; + } + return ( + + + + ); +}; + +export const TimeControlChip: React.FC<{ timeControl: string }> = ({ + timeControl, +}) => { + return ( + + } + label={timeControl} + size="small" + /> + + ); +}; + +export const MovesChip: React.FC<{ moves: number }> = ({ moves }) => { + return ( + + } + label={`${Math.round(moves / 2)} moves`} + size="small" + /> + + ); +}; + +export const DateChip: React.FC<{ date: string }> = ({ date }) => { + return ( + + } + label={date} + size="small" + /> + + ); +}; diff --git a/src/sections/loadGame/gamePgnInput.tsx b/src/sections/loadGame/gamePgnInput.tsx index 97b7391..253eb5b 100644 --- a/src/sections/loadGame/gamePgnInput.tsx +++ b/src/sections/loadGame/gamePgnInput.tsx @@ -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 { pgn: string; @@ -6,6 +8,20 @@ interface Props { } export default function GamePgnInput({ pgn, setPgn }: Props) { + const handleFileChange = (event: React.ChangeEvent) => { + 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 ( setPgn(e.target.value)} + rows={8} + sx={{ mb: 2 }} /> + ); } diff --git a/src/sections/loadGame/lichess-game-item.tsx b/src/sections/loadGame/lichess-game-item.tsx new file mode 100644 index 0000000..072d033 --- /dev/null +++ b/src/sections/loadGame/lichess-game-item.tsx @@ -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 = ({ + 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 ( + + + + {formatPlayerName(white)} ({white.rating}) + + + + vs + + + + {formatPlayerName(black)} ({black.rating}) + + + + + } + secondary={ + + + {moves && moves > 0 && } + + + } + label="Lichess" + size="small" + /> + + } + sx={{ mr: 2 }} + /> + + + + { + 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", + }} + > + + + + + + ); +}; diff --git a/src/sections/loadGame/lichessInput.tsx b/src/sections/loadGame/lichessInput.tsx index cfba30e..512a174 100644 --- a/src/sections/loadGame/lichessInput.tsx +++ b/src/sections/loadGame/lichessInput.tsx @@ -1,24 +1,56 @@ +"use client"; + import { useLocalStorage } from "@/hooks/useLocalStorage"; import { getLichessUserRecentGames } from "@/lib/lichess"; -import { capitalize } from "@/lib/helpers"; import { CircularProgress, FormControl, Grid2 as Grid, - ListItemButton, - ListItemText, TextField, + List, Autocomplete, } from "@mui/material"; 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"; 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( "lichess-username", @@ -147,42 +179,31 @@ export default function LichessInput({ onSelect }: Props) { No games found. Please check your username. ) : ( - games.map((game) => ( - { - updateHistory(debouncedUsername); + + {games.map((game) => { + const normalizedGame = normalizeLichessData(game); + const perspectiveUserColor = + normalizedGame.white.username.toLowerCase() === + lichessUsername.toLowerCase() + ? "white" + : "black"; + + return ( + { + updateHistory(debouncedUsername); const boardOrientation = debouncedUsername.toLowerCase() !== game.players?.black?.user?.name?.toLowerCase(); onSelect(game.pgn, boardOrientation); - }} - style={{ width: 350, maxWidth: 350 }} - key={game.id} - > - - - )) + }} + /> + ); + })} + )} )} diff --git a/src/types/chessCom.ts b/src/types/chessCom.ts index 9936eae..d5549eb 100644 --- a/src/types/chessCom.ts +++ b/src/types/chessCom.ts @@ -1,14 +1,46 @@ -export interface ChessComGame { - uuid: string; - white: ChessComUser; - black: ChessComUser; - end_time: number; - pgn: string; - time_class: string; -} - -export interface ChessComUser { +interface ChessComPlayerData { username: string; 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; } diff --git a/src/types/lichess.ts b/src/types/lichess.ts index ad8c845..d7d8f59 100644 --- a/src/types/lichess.ts +++ b/src/types/lichess.ts @@ -17,18 +17,59 @@ export enum LichessError { NotFound = "No cloud evaluation available for that position", } -export interface LichessGame { - id: string; - speed: string; - lastMoveAt: number; - players?: { - white?: LichessGameUser; - black?: LichessGameUser; +interface LichessPlayerData { + user: { + name: string; + title?: string; }; - pgn: string; + rating: number; } -export interface LichessGameUser { - user?: { id: string; name: string }; - rating?: number; +interface LichessClockData { + initial: 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; }