add loading indicator to the pagination
All checks were successful
Deploy SSR / deploy (push) Successful in 1m37s

This commit is contained in:
Lee 2024-09-12 11:35:37 +01:00
parent 99174e6299
commit f0dfbe78ea
5 changed files with 106 additions and 21 deletions

@ -0,0 +1,47 @@
import { validateUrl } from "@/common/utils";
import ky from "ky";
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const url = request.nextUrl.searchParams.get("url");
if (url == null) {
return NextResponse.json({ error: "Missing URL. ?url=" }, { status: 400 });
}
if (!validateUrl(url)) {
return NextResponse.json({ error: "Invalid URL" }, { status: 400 });
}
try {
const response = await ky.get(url, {
next: {
revalidate: 30, // 30 seconds
},
});
const { status, headers } = response;
if (
!headers.has("content-type") ||
(headers.has("content-type") && !headers.get("content-type")?.includes("application/json"))
) {
return NextResponse.json({
error: "We only support proxying JSON responses",
});
}
const body = await response.json();
return NextResponse.json(body, {
status: status,
});
} catch (err) {
console.error(`Error fetching data from ${url}:`, err);
return NextResponse.json(
{ error: "Failed to proxy this request." },
{
status: 500,
headers: {
"Access-Control-Allow-Origin": "*",
},
}
);
}
}

@ -28,6 +28,7 @@ export default class DataFetcher {
*/ */
private buildRequestUrl(useProxy: boolean, url: string): string { private buildRequestUrl(useProxy: boolean, url: string): string {
return (useProxy ? "https://proxy.fascinated.cc/" : "") + url; return (useProxy ? "https://proxy.fascinated.cc/" : "") + url;
// return (useProxy ? config.siteUrl + "/api/proxy?url=" : "") + url;
} }
/** /**

@ -1,6 +1,21 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
}
/**
* Validates if the url is valid
*
* @param url the url to validate
* @returns true if the url is valid, false otherwise
*/
export function validateUrl(url: string) {
try {
new URL(url);
return true;
} catch {
return false;
}
} }

@ -1,3 +1,5 @@
import { ArrowPathIcon } from "@heroicons/react/24/solid";
import clsx from "clsx";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
PaginationContent, PaginationContent,
@ -9,6 +11,19 @@ import {
Pagination as ShadCnPagination, Pagination as ShadCnPagination,
} from "../ui/pagination"; } from "../ui/pagination";
type PaginationItemWrapperProps = {
isLoadingPage: boolean;
children: React.ReactNode;
};
function PaginationItemWrapper({ isLoadingPage, children }: PaginationItemWrapperProps) {
return (
<PaginationItem className={clsx(isLoadingPage ? "cursor-not-allowed" : "cursor-pointer")}>
{children}
</PaginationItem>
);
}
type Props = { type Props = {
/** /**
* If true, the pagination will be rendered as a mobile-friendly pagination. * If true, the pagination will be rendered as a mobile-friendly pagination.
@ -25,14 +40,20 @@ type Props = {
*/ */
totalPages: number; totalPages: number;
/**
* The page to show a loading icon on.
*/
loadingPage: number | undefined;
/** /**
* Callback function that is called when the user clicks on a page number. * Callback function that is called when the user clicks on a page number.
*/ */
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
}; };
export default function Pagination({ mobilePagination, page, totalPages, onPageChange }: Props) { export default function Pagination({ mobilePagination, page, totalPages, loadingPage, onPageChange }: Props) {
totalPages = Math.round(totalPages); totalPages = Math.round(totalPages);
const isLoading = loadingPage !== undefined;
const [currentPage, setCurrentPage] = useState(page); const [currentPage, setCurrentPage] = useState(page);
useEffect(() => { useEffect(() => {
@ -40,7 +61,7 @@ export default function Pagination({ mobilePagination, page, totalPages, onPageC
}, [page]); }, [page]);
const handlePageChange = (newPage: number) => { const handlePageChange = (newPage: number) => {
if (newPage < 1 || newPage > totalPages || newPage == currentPage) { if (newPage < 1 || newPage > totalPages || newPage == currentPage || isLoading) {
return; return;
} }
@ -62,12 +83,12 @@ export default function Pagination({ mobilePagination, page, totalPages, onPageC
if (startPage > 1 && !mobilePagination) { if (startPage > 1 && !mobilePagination) {
pageNumbers.push( pageNumbers.push(
<> <>
<PaginationItem key="start" className="cursor-pointer"> <PaginationItemWrapper key="start" isLoadingPage={isLoading}>
<PaginationLink onClick={() => handlePageChange(1)}>1</PaginationLink> <PaginationLink onClick={() => handlePageChange(1)}>1</PaginationLink>
</PaginationItem> </PaginationItemWrapper>
<PaginationItem key="ellipsis-start" className="cursor-pointer"> <PaginationItemWrapper key="ellipsis-start" isLoadingPage={isLoading}>
<PaginationEllipsis /> <PaginationEllipsis />
</PaginationItem> </PaginationItemWrapper>
</> </>
); );
} }
@ -75,11 +96,11 @@ export default function Pagination({ mobilePagination, page, totalPages, onPageC
// Generate page numbers between startPage and endPage for desktop view // Generate page numbers between startPage and endPage for desktop view
for (let i = startPage; i <= endPage; i++) { for (let i = startPage; i <= endPage; i++) {
pageNumbers.push( pageNumbers.push(
<PaginationItem key={i} className="cursor-pointer"> <PaginationItemWrapper key={i} isLoadingPage={isLoading}>
<PaginationLink isActive={i === currentPage} onClick={() => handlePageChange(i)}> <PaginationLink isActive={i === currentPage} onClick={() => handlePageChange(i)}>
{i} {loadingPage === i ? <ArrowPathIcon className="w-4 h-4 animate-spin" /> : i}
</PaginationLink> </PaginationLink>
</PaginationItem> </PaginationItemWrapper>
); );
} }
@ -90,28 +111,28 @@ export default function Pagination({ mobilePagination, page, totalPages, onPageC
<ShadCnPagination className="select-none"> <ShadCnPagination className="select-none">
<PaginationContent> <PaginationContent>
{/* Previous button for mobile and desktop */} {/* Previous button for mobile and desktop */}
<PaginationItem className="cursor-pointer"> <PaginationItemWrapper isLoadingPage={isLoading}>
<PaginationPrevious onClick={() => handlePageChange(currentPage - 1)} /> <PaginationPrevious onClick={() => handlePageChange(currentPage - 1)} />
</PaginationItem> </PaginationItemWrapper>
{renderPageNumbers()} {renderPageNumbers()}
{/* For desktop, show ellipsis and link to the last page */} {/* For desktop, show ellipsis and link to the last page */}
{!mobilePagination && currentPage < totalPages && ( {!mobilePagination && currentPage < totalPages && (
<> <>
<PaginationItem key="ellipsis-end"> <PaginationItemWrapper key="ellipsis-end" isLoadingPage={isLoading}>
<PaginationEllipsis className="cursor-default" /> <PaginationEllipsis className="cursor-default" />
</PaginationItem> </PaginationItemWrapper>
<PaginationItem key="end" className="cursor-pointer"> <PaginationItemWrapper key="end" isLoadingPage={isLoading}>
<PaginationLink onClick={() => handlePageChange(totalPages)}>{totalPages}</PaginationLink> <PaginationLink onClick={() => handlePageChange(totalPages)}>{totalPages}</PaginationLink>
</PaginationItem> </PaginationItemWrapper>
</> </>
)} )}
{/* Next button for mobile and desktop */} {/* Next button for mobile and desktop */}
<PaginationItem className="cursor-pointer"> <PaginationItemWrapper isLoadingPage={isLoading}>
<PaginationNext onClick={() => handlePageChange(currentPage + 1)} /> <PaginationNext onClick={() => handlePageChange(currentPage + 1)} />
</PaginationItem> </PaginationItemWrapper>
</PaginationContent> </PaginationContent>
</ShadCnPagination> </ShadCnPagination>
); );

@ -25,7 +25,7 @@ export default function PlayerScores({ player, sort, page }: Props) {
const [currentPage, setCurrentPage] = useState(page); const [currentPage, setCurrentPage] = useState(page);
const [previousScores, setPreviousScores] = useState<ScoreSaberPlayerScoresPage | undefined>(); const [previousScores, setPreviousScores] = useState<ScoreSaberPlayerScoresPage | undefined>();
const { data, isError, refetch } = useQuery({ const { data, isError, isLoading, refetch } = useQuery({
queryKey: ["playerScores", player.id, currentSort, currentPage], queryKey: ["playerScores", player.id, currentSort, currentPage],
queryFn: () => scoresaberFetcher.lookupPlayerScores(player.id, currentSort, currentPage), queryFn: () => scoresaberFetcher.lookupPlayerScores(player.id, currentSort, currentPage),
}); });
@ -89,6 +89,7 @@ export default function PlayerScores({ player, sort, page }: Props) {
mobilePagination={width < 768} mobilePagination={width < 768}
page={currentPage} page={currentPage}
totalPages={Math.ceil(previousScores.metadata.total / previousScores.metadata.itemsPerPage)} totalPages={Math.ceil(previousScores.metadata.total / previousScores.metadata.itemsPerPage)}
loadingPage={isLoading ? currentPage : undefined}
onPageChange={(newPage) => { onPageChange={(newPage) => {
setCurrentPage(newPage); setCurrentPage(newPage);
}} }}