import { capitalizeFirstLetter } from "@/common/string-utils"; import useWindowDimensions from "@/hooks/use-window-dimensions"; import { ClockIcon, TrophyIcon, XMarkIcon } from "@heroicons/react/24/solid"; import { useQuery } from "@tanstack/react-query"; import { motion, useAnimation, Variants } from "framer-motion"; import { useCallback, useEffect, useRef, useState } from "react"; import Card from "../card"; import Pagination from "../input/pagination"; import { Button } from "../ui/button"; import { ScoreSort } from "@/common/model/score/score-sort"; import ScoreSaberPlayerScoresPageToken from "@/common/model/token/scoresaber/score-saber-player-scores-page-token"; 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"; import { useDebounce } from "@uidotdev/usehooks"; type Props = { initialScoreData?: ScoreSaberPlayerScoresPageToken; initialSearch?: string; player: ScoreSaberPlayer; sort: ScoreSort; page: number; }; type PageState = { page: number; sort: ScoreSort; }; const scoreSort = [ { name: "Top", value: ScoreSort.top, icon: , }, { name: "Recent", value: ScoreSort.recent, icon: , }, ]; const scoreAnimation: Variants = { 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 }: Props) { const { width } = useWindowDimensions(); const controls = useAnimation(); const [pageState, setPageState] = useState({ page, sort }); const [previousPage, setPreviousPage] = useState(page); const [currentScores, setCurrentScores] = useState(initialScoreData); const [searchTerm, setSearchTerm] = useState(initialSearch || ""); const debouncedSearchTerm = useDebounce(searchTerm, 250); const [shouldFetch, setShouldFetch] = useState(false); // New state to control fetching const topOfScoresRef = useRef(null); const isSearchActive = debouncedSearchTerm.length >= 3; const { data: scores, isError, isLoading, } = useQuery({ queryKey: ["playerScores", player.id, pageState, debouncedSearchTerm], queryFn: () => { return scoresaberService.lookupPlayerScores({ playerId: player.id, page: pageState.page, sort: pageState.sort, ...(isSearchActive && { search: debouncedSearchTerm }), }); }, staleTime: 30 * 1000, // 30 seconds enabled: shouldFetch && (debouncedSearchTerm.length >= 3 || debouncedSearchTerm.length === 0), // Only enable if we set shouldFetch to true }); /** * Starts the animation for the scores. */ const handleScoreAnimation = useCallback(async () => { await controls.start(previousPage >= pageState.page ? "hiddenRight" : "hiddenLeft"); setCurrentScores(scores); await controls.start("visible"); }, [scores, controls, previousPage, pageState.page]); /** * Change the score sort. * * @param newSort the new sort */ const handleSortChange = (newSort: ScoreSort) => { if (newSort !== pageState.sort) { setPageState({ page: 1, sort: newSort }); setShouldFetch(true); // Set to true to trigger fetch } }; /** * Change the score search term. * * @param query the new search term */ const handleSearchChange = (query: string) => { setSearchTerm(query); if (query.length >= 3) { setShouldFetch(true); // Set to true to trigger fetch } else { setShouldFetch(false); // Disable fetch if the search query is less than 3 characters } }; /** * Clears the score search term. */ const clearSearch = () => { setSearchTerm(""); }; /** * Handle score animation. */ useEffect(() => { if (scores) handleScoreAnimation(); }, [scores, handleScoreAnimation]); /** * Handle updating the URL when the page number, * sort, or search term changes. */ useEffect(() => { 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, isSearchActive]); /** * Handle scrolling to the top of the * scores when new scores are loaded. */ useEffect(() => { if (topOfScoresRef.current && shouldFetch) { const topOfScoresPosition = topOfScoresRef.current.getBoundingClientRect().top + window.scrollY; window.scrollTo({ top: topOfScoresPosition - 55, // Navbar height (plus some padding) behavior: "smooth", }); } }, [pageState, topOfScoresRef, shouldFetch]); const invalidSearch = searchTerm.length >= 1 && searchTerm.length < 3; return ( {/* Where to scroll to when new scores are loaded */} {scoreSort.map(sortOption => ( handleSortChange(sortOption.value)} size="sm" className="flex items-center gap-1" > {sortOption.icon} {`${capitalizeFirstLetter(sortOption.name)} Scores`} ))} handleSearchChange(e.target.value)} /> {searchTerm && ( // Show clear button only if there's a query )} {currentScores && ( <> {isError || (currentScores.playerScores.length === 0 && No scores found. Invalid Page or Search?)} {currentScores.playerScores.map((playerScore, index) => ( ))} { setPreviousPage(pageState.page); setPageState({ ...pageState, page: newPage }); setShouldFetch(true); // Set to true to trigger fetch on page change }} /> > )} ); }
No scores found. Invalid Page or Search?