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

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

@ -1,8 +1,6 @@
"use client";
import { capitalizeFirstLetter } from "@/common/string-utils";
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 { motion, useAnimation, Variants } from "framer-motion";
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 ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
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 = {
initialScoreData?: ScoreSaberPlayerScoresPageToken;
initialSearch?: string;
player: ScoreSaberPlayer;
sort: ScoreSort;
page: number;
};
type PageState = {
/**
* The current page
*/
page: number;
/**
* The current sort
*/
sort: ScoreSort;
};
@ -48,31 +44,14 @@ const scoreSort = [
];
const scoreAnimation: Variants = {
hiddenRight: {
opacity: 0,
x: 50,
transition: {
delay: 0,
},
},
hiddenLeft: {
opacity: 0,
x: -50,
transition: {
delay: 0,
},
},
visible: {
opacity: 1,
x: 0,
transition: {
staggerChildren: 0.03,
},
},
hiddenRight: { opacity: 0, x: 50 },
hiddenLeft: { opacity: 0, x: -50 },
visible: { opacity: 1, x: 0, transition: { staggerChildren: 0.03 } },
};
export default function PlayerScores({
initialScoreData,
initialSearch,
player,
sort,
page,
@ -80,15 +59,28 @@ export default function PlayerScores({
const { width } = useWindowDimensions();
const controls = useAnimation();
const [firstLoad, setFirstLoad] = useState(true);
const [pageState, setPageState] = useState<PageState>({
page: page,
sort: sort,
});
const [pageState, setPageState] = useState<PageState>({ page, sort });
const [previousPage, setPreviousPage] = useState(page);
const [currentScores, setCurrentScores] = useState<
ScoreSaberPlayerScoresPageToken | undefined
>(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 {
data: scores,
@ -96,26 +88,25 @@ export default function PlayerScores({
isLoading,
refetch,
} = useQuery({
queryKey: ["playerScores", player.id, pageState],
queryFn: () =>
scoresaberService.lookupPlayerScores({
queryKey: ["playerScores", player.id, pageState, debouncedSearchTerm],
queryFn: () => {
return scoresaberService.lookupPlayerScores({
playerId: player.id,
sort: pageState.sort,
page: pageState.page,
}),
staleTime: 30 * 1000, // Cache data for 30 seconds
sort: pageState.sort,
...(isSearchActive && { search: debouncedSearchTerm }),
});
},
staleTime: 30 * 1000,
});
const handleScoreLoad = useCallback(async () => {
setFirstLoad(false);
if (!firstLoad) {
await controls.start(
previousPage >= pageState.page ? "hiddenRight" : "hiddenLeft",
);
}
await controls.start(
previousPage >= pageState.page ? "hiddenRight" : "hiddenLeft",
);
setCurrentScores(scores);
await controls.start("visible");
}, [scores, controls, previousPage, firstLoad, pageState.page]);
}, [scores, controls, previousPage, pageState.page]);
const handleSortChange = (newSort: ScoreSort) => {
if (newSort !== pageState.sort) {
@ -124,31 +115,44 @@ export default function PlayerScores({
};
useEffect(() => {
if (scores) {
handleScoreLoad();
}
}, [scores, isError, handleScoreLoad]);
if (scores) handleScoreLoad();
}, [scores, handleScoreLoad]);
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.state, as: newUrl, url: newUrl },
"",
newUrl,
);
}, [pageState, debouncedSearchTerm, player.id]);
useEffect(() => {
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 (
<Card className="flex gap-1">
<div className="flex items-center flex-col w-full gap-2 justify-center relative">
<div className="flex items-center flex-row gap-2">
{Object.values(scoreSort).map((sortOption, index) => (
<div className="flex flex-col items-center w-full gap-2 relative">
<div className="flex gap-2">
{scoreSort.map((sortOption) => (
<Button
key={sortOption.value}
variant={
sortOption.value === pageState.sort ? "default" : "outline"
}
key={index}
onClick={() => handleSortChange(sortOption.value)}
size="sm"
className="flex items-center gap-1"
@ -159,21 +163,36 @@ export default function PlayerScores({
))}
</div>
{/* todo: add search */}
{/*<Input*/}
{/* type="search"*/}
{/* placeholder="Search..."*/}
{/* className="w-72 flex lg:absolute right-0 top-0"*/}
{/*/>*/}
<div className="relative w-72 lg:absolute right-0 top-0">
<Input
type="search"
placeholder="Search..."
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>
{currentScores && (
<>
<div className="text-center">
{isError && <p>Oopsies! Something went wrong.</p>}
{currentScores.playerScores.length === 0 && (
<p>No scores found. Invalid Page?</p>
)}
{isError ||
(currentScores.playerScores.length === 0 && (
<p>No scores found. Invalid Page or Search?</p>
))}
</div>
<motion.div
@ -197,9 +216,9 @@ export default function PlayerScores({
currentScores.metadata.itemsPerPage,
)}
loadingPage={isLoading ? pageState.page : undefined}
onPageChange={(page) => {
onPageChange={(newPage) => {
setPreviousPage(pageState.page);
setPageState({ page, sort: pageState.sort });
setPageState({ ...pageState, page: newPage });
}}
/>
</>

@ -13,7 +13,7 @@ export default function LeaderboardButton({
setIsLeaderboardExpanded,
}: Props) {
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
className="p-0 hover:bg-transparent"
variant="ghost"

@ -24,7 +24,7 @@ type Props = {
export default function ScoreButton({ children, tooltip, onClick }: Props) {
const 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}
>
{children}