This commit is contained in:
parent
188513de70
commit
6571bfd648
@ -1 +1,4 @@
|
|||||||
export type ScoreSort = "top" | "recent";
|
export enum ScoreSort {
|
||||||
|
top = "top",
|
||||||
|
recent = "recent",
|
||||||
|
}
|
||||||
|
9
src/app/common/string-utils.ts
Normal file
9
src/app/common/string-utils.ts
Normal file
@ -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);
|
||||||
|
}
|
118
src/app/components/input/pagination.tsx
Normal file
118
src/app/components/input/pagination.tsx
Normal file
@ -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(
|
||||||
|
<>
|
||||||
|
<PaginationItem key="start">
|
||||||
|
<PaginationLink onClick={() => handlePageChange(1)}>1</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem key="ellipsis-start">
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate page numbers between startPage and endPage for desktop view
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
pageNumbers.push(
|
||||||
|
<PaginationItem key={i}>
|
||||||
|
<PaginationLink isActive={i === currentPage} onClick={() => handlePageChange(i)}>
|
||||||
|
{i}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageNumbers;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ShadCnPagination className="select-none">
|
||||||
|
<PaginationContent>
|
||||||
|
{/* Previous button for mobile and desktop */}
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious onClick={() => handlePageChange(currentPage - 1)} />
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{renderPageNumbers()}
|
||||||
|
|
||||||
|
{/* For desktop, show ellipsis and link to the last page */}
|
||||||
|
{!mobilePagination && currentPage < totalPages && (
|
||||||
|
<>
|
||||||
|
<PaginationItem key="ellipsis-end">
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem key="end">
|
||||||
|
<PaginationLink onClick={() => handlePageChange(totalPages)}>{totalPages}</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Next button for mobile and desktop */}
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext onClick={() => handlePageChange(currentPage + 1)} />
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</ShadCnPagination>
|
||||||
|
);
|
||||||
|
}
|
@ -3,46 +3,96 @@
|
|||||||
import { scoresaberLeaderboard } from "@/app/common/leaderboard/impl/scoresaber";
|
import { scoresaberLeaderboard } from "@/app/common/leaderboard/impl/scoresaber";
|
||||||
import { ScoreSort } from "@/app/common/leaderboard/sort";
|
import { ScoreSort } from "@/app/common/leaderboard/sort";
|
||||||
import ScoreSaberPlayer from "@/app/common/leaderboard/types/scoresaber/scoresaber-player";
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import Card from "../card";
|
import Card from "../card";
|
||||||
|
import Pagination from "../input/pagination";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
import Score from "./score";
|
import Score from "./score";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
|
||||||
* The player to fetch scores for.
|
|
||||||
*/
|
|
||||||
player: ScoreSaberPlayer;
|
player: ScoreSaberPlayer;
|
||||||
|
|
||||||
/**
|
|
||||||
* The sort to use for fetching scores.
|
|
||||||
*/
|
|
||||||
sort: ScoreSort;
|
sort: ScoreSort;
|
||||||
|
|
||||||
/**
|
|
||||||
* The page to fetch scores for.
|
|
||||||
*/
|
|
||||||
page: number;
|
page: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PlayerScores({ player, sort, page }: Props) {
|
export default function PlayerScores({ player, sort, page }: Props) {
|
||||||
const { data, isLoading, isError } = useQuery({
|
const { width } = useWindowDimensions();
|
||||||
queryKey: ["playerScores", player.id],
|
const [currentSort, setCurrentSort] = useState(sort);
|
||||||
queryFn: () => scoresaberLeaderboard.lookupPlayerScores(player.id, sort, page),
|
const [currentPage, setCurrentPage] = useState(page);
|
||||||
|
const [previousScores, setPreviousScores] = useState<ScoreSaberPlayerScoresPage | undefined>();
|
||||||
|
|
||||||
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<Card className="gap-2">
|
<Card className="gap-2">
|
||||||
<div className="grid min-w-full grid-cols-1 divide-y divide-border">
|
<p>Oopsies!</p>
|
||||||
{data.playerScores.map((playerScore, index) => {
|
</Card>
|
||||||
return <Score key={index} playerScore={playerScore} />;
|
);
|
||||||
})}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="flex gap-4">
|
||||||
|
<div className="flex items-center flex-row w-full gap-2 justify-center">
|
||||||
|
{Object.keys(ScoreSort).map((sort, index) => (
|
||||||
|
<Button
|
||||||
|
variant={sort == currentSort ? "default" : "outline"}
|
||||||
|
key={index}
|
||||||
|
onClick={() => handleSortChange(sort as ScoreSort)}
|
||||||
|
>
|
||||||
|
{capitalizeFirstLetter(sort)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid min-w-full grid-cols-1 divide-y divide-border">
|
||||||
|
{previousScores.playerScores.map((playerScore, index) => (
|
||||||
|
<Score key={index} playerScore={playerScore} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
mobilePagination={width < 768}
|
||||||
|
page={currentPage}
|
||||||
|
totalPages={Math.ceil(previousScores.metadata.total / previousScores.metadata.itemsPerPage)}
|
||||||
|
onPageChange={(newPage) => {
|
||||||
|
setCurrentPage(newPage);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
121
src/app/components/ui/pagination.tsx
Normal file
121
src/app/components/ui/pagination.tsx
Normal file
@ -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">) => (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
className={cn("mx-auto flex w-full justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
Pagination.displayName = "Pagination"
|
||||||
|
|
||||||
|
const PaginationContent = React.forwardRef<
|
||||||
|
HTMLUListElement,
|
||||||
|
React.ComponentProps<"ul">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ul
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-row items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
PaginationContent.displayName = "PaginationContent"
|
||||||
|
|
||||||
|
const PaginationItem = React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
React.ComponentProps<"li">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<li ref={ref} className={cn("", className)} {...props} />
|
||||||
|
))
|
||||||
|
PaginationItem.displayName = "PaginationItem"
|
||||||
|
|
||||||
|
type PaginationLinkProps = {
|
||||||
|
isActive?: boolean
|
||||||
|
} & Pick<ButtonProps, "size"> &
|
||||||
|
React.ComponentProps<"a">
|
||||||
|
|
||||||
|
const PaginationLink = ({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) => (
|
||||||
|
<a
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: isActive ? "outline" : "ghost",
|
||||||
|
size,
|
||||||
|
}),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
PaginationLink.displayName = "PaginationLink"
|
||||||
|
|
||||||
|
const PaginationPrevious = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 pl-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="h-4 w-4" />
|
||||||
|
<span>Previous</span>
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
PaginationPrevious.displayName = "PaginationPrevious"
|
||||||
|
|
||||||
|
const PaginationNext = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to next page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 pr-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span>Next</span>
|
||||||
|
<ChevronRightIcon className="h-4 w-4" />
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
PaginationNext.displayName = "PaginationNext"
|
||||||
|
|
||||||
|
const PaginationEllipsis = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) => (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<DotsHorizontalIcon className="h-4 w-4" />
|
||||||
|
<span className="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
PaginationEllipsis.displayName = "PaginationEllipsis"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationPrevious,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationEllipsis,
|
||||||
|
}
|
24
src/app/hooks/use-window-dimensions.ts
Normal file
24
src/app/hooks/use-window-dimensions.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
function getWindowDimensions() {
|
||||||
|
const { innerWidth: width, innerHeight: height } = window;
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useWindowDimensions() {
|
||||||
|
const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleResize() {
|
||||||
|
setWindowDimensions(getWindowDimensions());
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return windowDimensions;
|
||||||
|
}
|
Reference in New Issue
Block a user