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 { 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<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;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card className="gap-2">
|
||||
<p>Oopsies!</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="gap-2">
|
||||
<div className="grid min-w-full grid-cols-1 divide-y divide-border">
|
||||
{data.playerScores.map((playerScore, index) => {
|
||||
return <Score key={index} playerScore={playerScore} />;
|
||||
})}
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
|
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