add score searching
All checks were successful
Deploy / deploy (push) Successful in 2m41s

This commit is contained in:
Lee 2024-09-28 14:02:33 +01:00
parent 1733822bab
commit fbb725dc93
5 changed files with 103 additions and 74 deletions

@ -10,6 +10,9 @@ type Props = {
params: Promise<{ params: Promise<{
slug: string[]; slug: string[];
}>; }>;
searchParams: Promise<{
[key: string]: string | undefined;
}>;
}; };
export async function generateMetadata({ params }: Props): Promise<Metadata> { export async function generateMetadata({ params }: Props): Promise<Metadata> {
@ -40,11 +43,13 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
}; };
} }
export default async function Search({ params }: Props) { export default async function Search({ params, searchParams }: Props) {
const { slug } = await params; const { slug } = await params;
const searchParamss = await searchParams;
const id = slug[0]; // The players id const id = slug[0]; // The players id
const sort: ScoreSort = (slug[1] as ScoreSort) || "recent"; // The sorting method const sort: ScoreSort = (slug[1] as ScoreSort) || "recent"; // The sorting method
const page = parseInt(slug[2]) || 1; // The page number const page = parseInt(slug[2]) || 1; // The page number
const search = searchParamss["search"] || ""; // The search query
const response = await scoresaberService.lookupPlayer(id, false); const response = await scoresaberService.lookupPlayer(id, false);
if (response == undefined) { if (response == undefined) {
// Invalid player id // Invalid player id
@ -55,6 +60,7 @@ export default async function Search({ params }: Props) {
playerId: id, playerId: id,
sort, sort,
page, page,
search,
}); });
const { player } = response; const { player } = response;
return ( return (
@ -62,6 +68,7 @@ export default async function Search({ params }: Props) {
<PlayerData <PlayerData
initialPlayerData={player} initialPlayerData={player}
initialScoreData={scores} initialScoreData={scores}
initialSearch={search}
sort={sort} sort={sort}
page={page} page={page}
/> />

@ -17,6 +17,7 @@ const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes
type Props = { type Props = {
initialPlayerData: ScoreSaberPlayer; initialPlayerData: ScoreSaberPlayer;
initialScoreData?: ScoreSaberPlayerScoresPageToken; initialScoreData?: ScoreSaberPlayerScoresPageToken;
initialSearch?: string;
sort: ScoreSort; sort: ScoreSort;
page: number; page: number;
}; };
@ -24,6 +25,7 @@ type Props = {
export default function PlayerData({ export default function PlayerData({
initialPlayerData: initalPlayerData, initialPlayerData: initalPlayerData,
initialScoreData, initialScoreData,
initialSearch,
sort, sort,
page, page,
}: Props) { }: Props) {
@ -50,6 +52,7 @@ export default function PlayerData({
)} )}
<PlayerScores <PlayerScores
initialScoreData={initialScoreData} initialScoreData={initialScoreData}
initialSearch={initialSearch}
player={player} player={player}
sort={sort} sort={sort}
page={page} page={page}

@ -1,8 +1,6 @@
"use client";
import { capitalizeFirstLetter } from "@/common/string-utils"; import { capitalizeFirstLetter } from "@/common/string-utils";
import useWindowDimensions from "@/hooks/use-window-dimensions"; import useWindowDimensions from "@/hooks/use-window-dimensions";
import { ClockIcon, TrophyIcon } from "@heroicons/react/24/solid"; import { ClockIcon, TrophyIcon, XMarkIcon } from "@heroicons/react/24/solid"; // Import XMarkIcon for the clear button
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { motion, useAnimation, Variants } from "framer-motion"; import { motion, useAnimation, Variants } from "framer-motion";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
@ -14,23 +12,21 @@ import ScoreSaberPlayerScoresPageToken from "@/common/model/token/scoresaber/sco
import Score from "@/components/score/score"; import Score from "@/components/score/score";
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player"; import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
import { scoresaberService } from "@/common/service/impl/scoresaber"; import { scoresaberService } from "@/common/service/impl/scoresaber";
import { Input } from "@/components/ui/input";
import { clsx } from "clsx";
const INPUT_DEBOUNCE_DELAY = 250; // milliseconds
type Props = { type Props = {
initialScoreData?: ScoreSaberPlayerScoresPageToken; initialScoreData?: ScoreSaberPlayerScoresPageToken;
initialSearch?: string;
player: ScoreSaberPlayer; player: ScoreSaberPlayer;
sort: ScoreSort; sort: ScoreSort;
page: number; page: number;
}; };
type PageState = { type PageState = {
/**
* The current page
*/
page: number; page: number;
/**
* The current sort
*/
sort: ScoreSort; sort: ScoreSort;
}; };
@ -48,31 +44,14 @@ const scoreSort = [
]; ];
const scoreAnimation: Variants = { const scoreAnimation: Variants = {
hiddenRight: { hiddenRight: { opacity: 0, x: 50 },
opacity: 0, hiddenLeft: { opacity: 0, x: -50 },
x: 50, visible: { opacity: 1, x: 0, transition: { staggerChildren: 0.03 } },
transition: {
delay: 0,
},
},
hiddenLeft: {
opacity: 0,
x: -50,
transition: {
delay: 0,
},
},
visible: {
opacity: 1,
x: 0,
transition: {
staggerChildren: 0.03,
},
},
}; };
export default function PlayerScores({ export default function PlayerScores({
initialScoreData, initialScoreData,
initialSearch,
player, player,
sort, sort,
page, page,
@ -80,15 +59,28 @@ export default function PlayerScores({
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const controls = useAnimation(); const controls = useAnimation();
const [firstLoad, setFirstLoad] = useState(true); const [pageState, setPageState] = useState<PageState>({ page, sort });
const [pageState, setPageState] = useState<PageState>({
page: page,
sort: sort,
});
const [previousPage, setPreviousPage] = useState(page); const [previousPage, setPreviousPage] = useState(page);
const [currentScores, setCurrentScores] = useState< const [currentScores, setCurrentScores] = useState<
ScoreSaberPlayerScoresPageToken | undefined ScoreSaberPlayerScoresPageToken | undefined
>(initialScoreData); >(initialScoreData);
const [searchState, setSearchState] = useState({
query: initialSearch || "",
});
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(
initialSearch || "",
);
const isSearchActive = debouncedSearchTerm.length >= 3;
// Debounce the search query
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchTerm(searchState.query);
}, INPUT_DEBOUNCE_DELAY);
return () => clearTimeout(handler);
}, [searchState.query]);
const { const {
data: scores, data: scores,
@ -96,26 +88,25 @@ export default function PlayerScores({
isLoading, isLoading,
refetch, refetch,
} = useQuery({ } = useQuery({
queryKey: ["playerScores", player.id, pageState], queryKey: ["playerScores", player.id, pageState, debouncedSearchTerm],
queryFn: () => queryFn: () => {
scoresaberService.lookupPlayerScores({ return scoresaberService.lookupPlayerScores({
playerId: player.id, playerId: player.id,
sort: pageState.sort,
page: pageState.page, page: pageState.page,
}), sort: pageState.sort,
staleTime: 30 * 1000, // Cache data for 30 seconds ...(isSearchActive && { search: debouncedSearchTerm }),
});
},
staleTime: 30 * 1000,
}); });
const handleScoreLoad = useCallback(async () => { const handleScoreLoad = useCallback(async () => {
setFirstLoad(false); await controls.start(
if (!firstLoad) { previousPage >= pageState.page ? "hiddenRight" : "hiddenLeft",
await controls.start( );
previousPage >= pageState.page ? "hiddenRight" : "hiddenLeft",
);
}
setCurrentScores(scores); setCurrentScores(scores);
await controls.start("visible"); await controls.start("visible");
}, [scores, controls, previousPage, firstLoad, pageState.page]); }, [scores, controls, previousPage, pageState.page]);
const handleSortChange = (newSort: ScoreSort) => { const handleSortChange = (newSort: ScoreSort) => {
if (newSort !== pageState.sort) { if (newSort !== pageState.sort) {
@ -124,31 +115,44 @@ export default function PlayerScores({
}; };
useEffect(() => { useEffect(() => {
if (scores) { if (scores) handleScoreLoad();
handleScoreLoad(); }, [scores, handleScoreLoad]);
}
}, [scores, isError, handleScoreLoad]);
useEffect(() => { useEffect(() => {
const newUrl = `/player/${player.id}/${pageState.sort}/${pageState.page}`; const newUrl = `/player/${player.id}/${pageState.sort}/${pageState.page}${isSearchActive ? `?search=${debouncedSearchTerm}` : ""}`;
window.history.replaceState( window.history.replaceState(
{ ...window.history.state, as: newUrl, url: newUrl }, { ...window.history.state, as: newUrl, url: newUrl },
"", "",
newUrl, newUrl,
); );
}, [pageState, debouncedSearchTerm, player.id]);
useEffect(() => {
refetch(); refetch();
}, [pageState, refetch, player.id]); }, [pageState, debouncedSearchTerm, refetch]);
const handleSearchChange = (query: string) => {
setSearchState({ query });
};
const clearSearch = () => {
setSearchState({ query: "" });
setDebouncedSearchTerm(""); // Clear the debounced term
};
const invalidSearch =
searchState.query.length >= 1 && searchState.query.length < 3;
return ( return (
<Card className="flex gap-1"> <Card className="flex gap-1">
<div className="flex items-center flex-col w-full gap-2 justify-center relative"> <div className="flex flex-col items-center w-full gap-2 relative">
<div className="flex items-center flex-row gap-2"> <div className="flex gap-2">
{Object.values(scoreSort).map((sortOption, index) => ( {scoreSort.map((sortOption) => (
<Button <Button
key={sortOption.value}
variant={ variant={
sortOption.value === pageState.sort ? "default" : "outline" sortOption.value === pageState.sort ? "default" : "outline"
} }
key={index}
onClick={() => handleSortChange(sortOption.value)} onClick={() => handleSortChange(sortOption.value)}
size="sm" size="sm"
className="flex items-center gap-1" className="flex items-center gap-1"
@ -159,21 +163,36 @@ export default function PlayerScores({
))} ))}
</div> </div>
{/* todo: add search */} <div className="relative w-72 lg:absolute right-0 top-0">
{/*<Input*/} <Input
{/* type="search"*/} type="search"
{/* placeholder="Search..."*/} placeholder="Search..."
{/* className="w-72 flex lg:absolute right-0 top-0"*/} className={clsx(
{/*/>*/} "pr-10", // Add padding right for the clear button
invalidSearch && "border-red-500",
)}
value={searchState.query}
onChange={(e) => handleSearchChange(e.target.value)}
/>
{searchState.query && ( // Show clear button only if there's a query
<button
onClick={clearSearch}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-300 hover:brightness-75 transform-gpu transition-all cursor-default"
aria-label="Clear search"
>
<XMarkIcon className="w-5 h-5" />
</button>
)}
</div>
</div> </div>
{currentScores && ( {currentScores && (
<> <>
<div className="text-center"> <div className="text-center">
{isError && <p>Oopsies! Something went wrong.</p>} {isError ||
{currentScores.playerScores.length === 0 && ( (currentScores.playerScores.length === 0 && (
<p>No scores found. Invalid Page?</p> <p>No scores found. Invalid Page or Search?</p>
)} ))}
</div> </div>
<motion.div <motion.div
@ -197,9 +216,9 @@ export default function PlayerScores({
currentScores.metadata.itemsPerPage, currentScores.metadata.itemsPerPage,
)} )}
loadingPage={isLoading ? pageState.page : undefined} loadingPage={isLoading ? pageState.page : undefined}
onPageChange={(page) => { onPageChange={(newPage) => {
setPreviousPage(pageState.page); setPreviousPage(pageState.page);
setPageState({ page, sort: pageState.sort }); setPageState({ ...pageState, page: newPage });
}} }}
/> />
</> </>

@ -13,7 +13,7 @@ export default function LeaderboardButton({
setIsLeaderboardExpanded, setIsLeaderboardExpanded,
}: Props) { }: Props) {
return ( return (
<div className="pr-2 flex items-center justify-center h-full"> <div className="pr-2 flex items-center justify-center h-full cursor-default">
<Button <Button
className="p-0 hover:bg-transparent" className="p-0 hover:bg-transparent"
variant="ghost" variant="ghost"

@ -24,7 +24,7 @@ type Props = {
export default function ScoreButton({ children, tooltip, onClick }: Props) { export default function ScoreButton({ children, tooltip, onClick }: Props) {
const button = ( const button = (
<button <button
className="bg-accent rounded-md flex justify-center items-center p-1 w-[28px] h-[28px] hover:brightness-75 transform-gpu transition-all" className="bg-accent rounded-md flex justify-center items-center p-1 w-[28px] h-[28px] hover:brightness-75 transform-gpu transition-all cursor-default"
onClick={onClick} onClick={onClick}
> >
{children} {children}