diff --git a/src/app/common/leaderboard/sort.ts b/src/app/common/leaderboard/sort.ts index a2bc95c..85798ca 100644 --- a/src/app/common/leaderboard/sort.ts +++ b/src/app/common/leaderboard/sort.ts @@ -1 +1,4 @@ -export type ScoreSort = "top" | "recent"; +export enum ScoreSort { + top = "top", + recent = "recent", +} diff --git a/src/app/common/string-utils.ts b/src/app/common/string-utils.ts new file mode 100644 index 0000000..41e2da4 --- /dev/null +++ b/src/app/common/string-utils.ts @@ -0,0 +1,9 @@ +/** + * Capitalizes the first letter of a string. + * + * @param str the string to capitalize + * @returns the capitalized string + */ +export function capitalizeFirstLetter(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/src/app/components/input/pagination.tsx b/src/app/components/input/pagination.tsx new file mode 100644 index 0000000..1d9f48b --- /dev/null +++ b/src/app/components/input/pagination.tsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from "react"; +import { + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, + Pagination as ShadCnPagination, +} from "../ui/pagination"; + +type Props = { + /** + * If true, the pagination will be rendered as a mobile-friendly pagination. + */ + mobilePagination: boolean; + + /** + * The current page. + */ + page: number; + + /** + * The total number of pages. + */ + totalPages: number; + + /** + * Callback function that is called when the user clicks on a page number. + */ + onPageChange: (page: number) => void; +}; + +export default function Pagination({ mobilePagination, page, totalPages, onPageChange }: Props) { + totalPages = Math.round(totalPages); + const [currentPage, setCurrentPage] = useState(page); + + useEffect(() => { + setCurrentPage(page); + }, [page]); + + const handlePageChange = (newPage: number) => { + if (newPage < 1 || newPage > totalPages || newPage == currentPage) { + return; + } + + setCurrentPage(newPage); + onPageChange(newPage); + }; + + const renderPageNumbers = () => { + const pageNumbers = []; + const maxPagesToShow = mobilePagination ? 3 : 4; + let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2)); + let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1); + + if (endPage - startPage < maxPagesToShow - 1) { + startPage = Math.max(1, endPage - maxPagesToShow + 1); + } + + // Show "Jump to Start" with Ellipsis if currentPage is greater than 3 in desktop view + if (startPage > 1 && !mobilePagination) { + pageNumbers.push( + <> + + handlePageChange(1)}>1 + + + + + + ); + } + + // Generate page numbers between startPage and endPage for desktop view + for (let i = startPage; i <= endPage; i++) { + pageNumbers.push( + + handlePageChange(i)}> + {i} + + + ); + } + + return pageNumbers; + }; + + return ( + + + {/* Previous button for mobile and desktop */} + + handlePageChange(currentPage - 1)} /> + + + {renderPageNumbers()} + + {/* For desktop, show ellipsis and link to the last page */} + {!mobilePagination && currentPage < totalPages && ( + <> + + + + + handlePageChange(totalPages)}>{totalPages} + + + )} + + {/* Next button for mobile and desktop */} + + handlePageChange(currentPage + 1)} /> + + + + ); +} diff --git a/src/app/components/player/player-scores.tsx b/src/app/components/player/player-scores.tsx index ab0d947..356fa74 100644 --- a/src/app/components/player/player-scores.tsx +++ b/src/app/components/player/player-scores.tsx @@ -3,46 +3,96 @@ import { scoresaberLeaderboard } from "@/app/common/leaderboard/impl/scoresaber"; import { ScoreSort } from "@/app/common/leaderboard/sort"; import ScoreSaberPlayer from "@/app/common/leaderboard/types/scoresaber/scoresaber-player"; +import ScoreSaberPlayerScoresPage from "@/app/common/leaderboard/types/scoresaber/scoresaber-player-scores-page"; +import { capitalizeFirstLetter } from "@/app/common/string-utils"; +import useWindowDimensions from "@/app/hooks/use-window-dimensions"; import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; import Card from "../card"; +import Pagination from "../input/pagination"; +import { Button } from "../ui/button"; import Score from "./score"; type Props = { - /** - * The player to fetch scores for. - */ player: ScoreSaberPlayer; - - /** - * The sort to use for fetching scores. - */ sort: ScoreSort; - - /** - * The page to fetch scores for. - */ page: number; }; export default function PlayerScores({ player, sort, page }: Props) { - const { data, isLoading, isError } = useQuery({ - queryKey: ["playerScores", player.id], - queryFn: () => scoresaberLeaderboard.lookupPlayerScores(player.id, sort, page), + const { width } = useWindowDimensions(); + const [currentSort, setCurrentSort] = useState(sort); + const [currentPage, setCurrentPage] = useState(page); + const [previousScores, setPreviousScores] = useState(); + + const { data, isError, refetch } = useQuery({ + queryKey: ["playerScores", player.id, currentSort, currentPage], + queryFn: () => scoresaberLeaderboard.lookupPlayerScores(player.id, currentSort, currentPage), }); - console.log(data); + useEffect(() => { + if (data) { + setPreviousScores(data); + } + }, [data]); - if (data == undefined || isLoading || isError) { + useEffect(() => { + // Update URL and refetch data when currentSort or currentPage changes + const newUrl = `/player/${player.id}/${currentSort}/${currentPage}`; + window.history.replaceState({ ...window.history.state, as: newUrl, url: newUrl }, "", newUrl); + refetch(); + }, [currentSort, currentPage, refetch, player.id]); + + /** + * Updates the current sort and resets the page to 1 + */ + function handleSortChange(newSort: ScoreSort) { + if (newSort !== currentSort) { + setCurrentSort(newSort); + setCurrentPage(1); // Reset the page + } + } + + if (previousScores === undefined) { return null; } + if (isError) { + return ( + +

Oopsies!

+
+ ); + } + return ( - -
- {data.playerScores.map((playerScore, index) => { - return ; - })} + +
+ {Object.keys(ScoreSort).map((sort, index) => ( + + ))}
+ +
+ {previousScores.playerScores.map((playerScore, index) => ( + + ))} +
+ + { + setCurrentPage(newPage); + }} + />
); } diff --git a/src/app/components/ui/pagination.tsx b/src/app/components/ui/pagination.tsx new file mode 100644 index 0000000..4e72d36 --- /dev/null +++ b/src/app/components/ui/pagination.tsx @@ -0,0 +1,121 @@ +import * as React from "react" +import { + ChevronLeftIcon, + ChevronRightIcon, + DotsHorizontalIcon, +} from "@radix-ui/react-icons" + +import { cn } from "@/app/common/utils" +import { ButtonProps, buttonVariants } from "@/app/components/ui/button" + +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( +