feat: predictive username search (#44)

This commit is contained in:
supertorpe
2025-06-09 23:32:33 +02:00
committed by GitHub
parent 46a29645e2
commit e237aa4611
2 changed files with 215 additions and 16 deletions

View File

@@ -8,19 +8,85 @@ import {
ListItemButton, ListItemButton,
ListItemText, ListItemText,
TextField, TextField,
Autocomplete,
} from "@mui/material"; } from "@mui/material";
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 { useEffect, useMemo, useState } from "react";
interface Props { interface Props {
onSelect: (pgn: string, boardOrientation?: boolean) => void; onSelect: (pgn: string, boardOrientation?: boolean) => void;
} }
export default function ChessComInput({ onSelect }: Props) { export default function ChessComInput({ onSelect }: Props) {
const [chessComUsername, setChessComUsername] = useLocalStorage( const [rawStoredValue, setStoredValues] = useLocalStorage<string>(
"chesscom-username", "chesscom-username",
"" ""
); );
const storedValues = useMemo(() => {
if (typeof rawStoredValue === "string")
return rawStoredValue
.split(",")
.map((s) => s.trim())
.filter(Boolean);
else return [];
}, [rawStoredValue]);
const [chessComUsername, setChessComUsername] = useState("");
const [hasEdited, setHasEdited] = useState(false);
useEffect(() => {
if (
!hasEdited &&
storedValues &&
storedValues.length > 0 &&
chessComUsername.trim().toLowerCase() !=
storedValues[0].trim().toLowerCase()
) {
setChessComUsername(storedValues[0].trim().toLowerCase());
}
}, [storedValues, hasEdited, chessComUsername]);
const updateHistory = (username: string) => {
const trimmed = username.trim();
if (!trimmed) return;
const lower = trimmed.toLowerCase();
const exists = storedValues.some((u) => u.toLowerCase() === lower);
if (!exists) {
const updated = [trimmed, ...storedValues.filter((u) => u !== trimmed)];
setStoredValues(updated.join(","));
}
};
const deleteUsername = (usernameToDelete: string) => {
const updated = storedValues.filter((u) => u !== usernameToDelete);
setStoredValues(updated.join(","));
};
const handleChange = (_: React.SyntheticEvent, newValue: string | null) => {
const newInputValue = newValue ?? "";
if (
newInputValue.trim().toLowerCase() !=
chessComUsername.trim().toLowerCase()
)
setChessComUsername(newInputValue.trim().toLowerCase());
};
const handleInputChange = (
_: React.SyntheticEvent,
newInputValue: string
) => {
if (
newInputValue.trim().toLowerCase() !=
chessComUsername.trim().toLowerCase()
) {
setChessComUsername(newInputValue.trim().toLowerCase());
if (!hasEdited) setHasEdited(true);
}
};
const debouncedUsername = useDebounce(chessComUsername, 300); const debouncedUsername = useDebounce(chessComUsername, 300);
const { const {
@@ -38,15 +104,48 @@ export default function ChessComInput({ onSelect }: Props) {
return ( return (
<> <>
<FormControl sx={{ m: 1, width: 300 }}> <FormControl sx={{ m: 1, width: 300 }}>
<TextField <Autocomplete
label="Enter your Chess.com username..." freeSolo
variant="outlined" options={storedValues}
value={chessComUsername ?? ""} inputValue={chessComUsername}
onChange={(e) => setChessComUsername(e.target.value)} onInputChange={handleInputChange}
onChange={handleChange}
renderOption={(props, option) => {
const { key, ...rest } = props;
return (
<li
key={key}
{...rest}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
paddingRight: 8,
}}
>
<span>{option}</span>
<Icon
icon="mdi:close"
style={{ cursor: "pointer" }}
onClick={(e) => {
e.stopPropagation();
deleteUsername(option);
}}
/>
</li>
);
}}
renderInput={(params) => (
<TextField
{...params}
label="Enter your Chess.com username..."
variant="outlined"
/>
)}
/> />
</FormControl> </FormControl>
{chessComUsername && ( {debouncedUsername && (
<Grid <Grid
container container
gap={2} gap={2}
@@ -69,8 +168,9 @@ export default function ChessComInput({ onSelect }: Props) {
games.map((game) => ( games.map((game) => (
<ListItemButton <ListItemButton
onClick={() => { onClick={() => {
updateHistory(debouncedUsername);
const boardOrientation = const boardOrientation =
chessComUsername.toLowerCase() !== debouncedUsername.toLowerCase() !==
game.black?.username?.toLowerCase(); game.black?.username?.toLowerCase();
onSelect(game.pgn, boardOrientation); onSelect(game.pgn, boardOrientation);
}} }}

View File

@@ -8,19 +8,84 @@ import {
ListItemButton, ListItemButton,
ListItemText, ListItemText,
TextField, TextField,
Autocomplete,
} from "@mui/material"; } from "@mui/material";
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 { useEffect, useMemo, useState } from "react";
interface Props { interface Props {
onSelect: (pgn: string, boardOrientation?: boolean) => void; onSelect: (pgn: string, boardOrientation?: boolean) => void;
} }
export default function LichessInput({ onSelect }: Props) { export default function LichessInput({ onSelect }: Props) {
const [lichessUsername, setLichessUsername] = useLocalStorage( const [rawStoredValue, setStoredValues] = useLocalStorage<string>(
"lichess-username", "lichess-username",
"" ""
); );
const storedValues = useMemo(() => {
if (typeof rawStoredValue === "string")
return rawStoredValue
.split(",")
.map((s) => s.trim())
.filter(Boolean);
else return [];
}, [rawStoredValue]);
const [lichessUsername, setLichessUsername] = useState("");
const [hasEdited, setHasEdited] = useState(false);
useEffect(() => {
if (
!hasEdited &&
storedValues &&
storedValues.length > 0 &&
lichessUsername.trim().toLowerCase() !=
storedValues[0].trim().toLowerCase()
) {
setLichessUsername(storedValues[0].trim().toLowerCase());
}
}, [storedValues, hasEdited, lichessUsername]);
const updateHistory = (username: string) => {
const trimmed = username.trim();
if (!trimmed) return;
const lower = trimmed.toLowerCase();
const exists = storedValues.some((u) => u.toLowerCase() === lower);
if (!exists) {
const updated = [trimmed, ...storedValues.filter((u) => u !== trimmed)];
setStoredValues(updated.join(","));
}
};
const deleteUsername = (usernameToDelete: string) => {
const updated = storedValues.filter((u) => u !== usernameToDelete);
setStoredValues(updated.join(","));
};
const handleChange = (_: React.SyntheticEvent, newValue: string | null) => {
const newInputValue = newValue ?? "";
if (
newInputValue.trim().toLowerCase() != lichessUsername.trim().toLowerCase()
)
setLichessUsername(newInputValue.trim().toLowerCase());
};
const handleInputChange = (
_: React.SyntheticEvent,
newInputValue: string
) => {
if (
newInputValue.trim().toLowerCase() != lichessUsername.trim().toLowerCase()
) {
setLichessUsername(newInputValue.trim().toLowerCase());
if (!hasEdited) setHasEdited(true);
}
};
const debouncedUsername = useDebounce(lichessUsername, 500); const debouncedUsername = useDebounce(lichessUsername, 500);
const { const {
@@ -38,15 +103,48 @@ export default function LichessInput({ onSelect }: Props) {
return ( return (
<> <>
<FormControl sx={{ m: 1, width: 300 }}> <FormControl sx={{ m: 1, width: 300 }}>
<TextField <Autocomplete
label="Enter your Lichess username..." freeSolo
variant="outlined" options={storedValues}
value={lichessUsername ?? ""} inputValue={lichessUsername}
onChange={(e) => setLichessUsername(e.target.value)} onInputChange={handleInputChange}
onChange={handleChange}
renderOption={(props, option) => {
const { key, ...rest } = props;
return (
<li
key={key}
{...rest}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
paddingRight: 8,
}}
>
<span>{option}</span>
<Icon
icon="mdi:close"
style={{ cursor: "pointer" }}
onClick={(e) => {
e.stopPropagation();
deleteUsername(option);
}}
/>
</li>
);
}}
renderInput={(params) => (
<TextField
{...params}
label="Enter your Lichess username..."
variant="outlined"
/>
)}
/> />
</FormControl> </FormControl>
{lichessUsername && ( {debouncedUsername && (
<Grid <Grid
container container
gap={2} gap={2}
@@ -69,8 +167,9 @@ export default function LichessInput({ onSelect }: Props) {
games.map((game) => ( games.map((game) => (
<ListItemButton <ListItemButton
onClick={() => { onClick={() => {
updateHistory(debouncedUsername);
const boardOrientation = const boardOrientation =
lichessUsername.toLowerCase() !== debouncedUsername.toLowerCase() !==
game.players?.black?.user?.name?.toLowerCase(); game.players?.black?.user?.name?.toLowerCase();
onSelect(game.pgn, boardOrientation); onSelect(game.pgn, boardOrientation);
}} }}