feat(ssr): server side render some things to speed up page loads
All checks were successful
deploy / deploy (push) Successful in 1m12s

This commit is contained in:
Lee 2023-11-08 07:41:07 +00:00
parent 3a6312510a
commit c251239e45
11 changed files with 344 additions and 321 deletions

@ -60,41 +60,41 @@ const nextConfig = {
}, },
}; };
module.exports = withBundleAnalyzer(nextConfig); // module.exports = withBundleAnalyzer(nextConfig);
// // Injected content via Sentry wizard below // // Injected content via Sentry wizard below
const { withSentryConfig } = require("@sentry/nextjs"); // const { withSentryConfig } = require("@sentry/nextjs");
module.exports = withSentryConfig( // module.exports = withSentryConfig(
module.exports, // module.exports,
{ // {
// For all available options, see: // // For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options // // https://github.com/getsentry/sentry-webpack-plugin#options
// Suppresses source map uploading logs during build // // Suppresses source map uploading logs during build
silent: true, // silent: true,
org: "sentry", // org: "sentry",
project: "scoresaber-reloaded", // project: "scoresaber-reloaded",
url: "https://sentry.fascinated.cc/", // url: "https://sentry.fascinated.cc/",
}, // },
{ // {
// For all available options, see: // // For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ // // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time) // // Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: false, // widenClientFileUpload: false,
// Transpiles SDK to be compatible with IE11 (increases bundle size) // // Transpiles SDK to be compatible with IE11 (increases bundle size)
transpileClientSDK: false, // transpileClientSDK: false,
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load) // // Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
tunnelRoute: "/monitoring", // tunnelRoute: "/monitoring",
// Hides source maps from generated client bundles // // Hides source maps from generated client bundles
hideSourceMaps: true, // hideSourceMaps: true,
// Automatically tree-shake Sentry logger statements to reduce bundle size // // Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true, // disableLogger: true,
}, // },
); // );

@ -47,6 +47,24 @@ export async function generateMetadata({
}; };
} }
export default function Player({ params: { id, sort, page } }: Props) { /**
return <PlayerPage id={id} sort={sort} page={page} />; * Gets the player's data on the server side.
*
* @param id the player's id
* @returns the player's data
*/
async function getData(id: string) {
const response = await ScoreSaberAPI.fetchPlayerData(id);
return {
data: response,
};
}
export default async function Player({ params: { id, sort, page } }: Props) {
const { data } = await getData(id);
if (!data) {
return <div>Player not found</div>;
}
return <PlayerPage player={data} sort={sort} page={page} />;
} }

@ -1,4 +1,8 @@
import Card from "@/components/Card";
import Container from "@/components/Container";
import Error from "@/components/Error";
import GlobalRanking from "@/components/GlobalRanking"; import GlobalRanking from "@/components/GlobalRanking";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import { Metadata } from "next"; import { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {
@ -9,6 +13,43 @@ type Props = {
params: { page: string; country: string }; params: { page: string; country: string };
}; };
export default function RankingGlobal({ params: { page, country } }: Props) { async function getData(page: number, country: string) {
return <GlobalRanking page={Number(page)} country={country} />; const response = await ScoreSaberAPI.fetchTopPlayers(page, country);
return {
data: response,
};
}
export default async function RankingGlobal({
params: { page, country },
}: Props) {
const { data } = await getData(Number(page), country);
if (!data) {
return (
<main>
<Container>
<Card outerClassName="mt-2" className="mt-2">
<div className="p-3 text-center">
<div role="status">
<div className="flex flex-col items-center justify-center gap-2">
<Error errorMessage="Unable to find this page or country" />
</div>
</div>
</div>
</Card>
</Container>
</main>
);
}
return (
<GlobalRanking
pageInfo={{
page: Number(page),
totalPages: data.pageInfo.totalPages,
}}
players={data.players}
country={country}
/>
);
} }

@ -1,4 +1,8 @@
import Card from "@/components/Card";
import Container from "@/components/Container";
import Error from "@/components/Error";
import GlobalRanking from "@/components/GlobalRanking"; import GlobalRanking from "@/components/GlobalRanking";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import { Metadata } from "next"; import { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {
@ -9,6 +13,40 @@ type Props = {
params: { page: string }; params: { page: string };
}; };
export default function RankingGlobal({ params: { page } }: Props) { async function getData(page: number) {
return <GlobalRanking page={Number(page)} />; const response = await ScoreSaberAPI.fetchTopPlayers(page);
return {
data: response,
};
}
export default async function RankingGlobal({ params: { page } }: Props) {
const { data } = await getData(Number(page));
if (!data) {
return (
<main>
<Container>
<Card outerClassName="mt-2" className="mt-2">
<div className="p-3 text-center">
<div role="status">
<div className="flex flex-col items-center justify-center gap-2">
<Error errorMessage="Unable to find this page" />
</div>
</div>
</div>
</Card>
</Container>
</main>
);
}
return (
<GlobalRanking
pageInfo={{
page: Number(page),
totalPages: data.pageInfo.totalPages,
}}
players={data.players}
/>
);
} }

@ -1,163 +1,70 @@
"use client"; "use client";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player"; import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import { normalizedRegionName } from "@/utils/utils"; import { normalizedRegionName } from "@/utils/utils";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import Card from "./Card"; import Card from "./Card";
import Container from "./Container"; import Container from "./Container";
import CountyFlag from "./CountryFlag"; import CountyFlag from "./CountryFlag";
import Pagination from "./Pagination"; import Pagination from "./Pagination";
import Spinner from "./Spinner";
import PlayerRanking from "./player/PlayerRanking"; import PlayerRanking from "./player/PlayerRanking";
import PlayerRankingMobile from "./player/PlayerRankingMobile";
import { Separator } from "./ui/separator"; import { Separator } from "./ui/separator";
const Error = dynamic(() => import("@/components/Error"));
type PageInfo = {
loading: boolean;
page: number;
totalPages: number;
players: ScoresaberPlayer[];
};
type GlobalRankingProps = { type GlobalRankingProps = {
page: number; players: ScoresaberPlayer[];
country?: string; country?: string;
pageInfo: {
page: number;
totalPages: number;
};
}; };
export default function GlobalRanking({ page, country }: GlobalRankingProps) { export default function GlobalRanking({
const router = useRouter(); players,
const searchQuery = useSearchParams(); country,
const isMobile = searchQuery.get("mobile") == "true"; pageInfo,
}: GlobalRankingProps) {
const [error, setError] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [pageInfo, setPageInfo] = useState<PageInfo>({
loading: true,
page: page,
totalPages: 1,
players: [],
});
const updatePage = useCallback(
(page: any) => {
const windowSize = document.documentElement.clientWidth;
if (windowSize < 768 && !isMobile) {
router.push(`/ranking/global/${page}?mobile=true`);
router.refresh();
return;
}
console.log("Switching page to", page);
ScoreSaberAPI.fetchTopPlayers(page, country).then((response) => {
if (!response) {
setError(true);
setErrorMessage("No players found");
setPageInfo({ ...pageInfo, loading: false });
return;
}
setPageInfo({
...pageInfo,
players: response.players,
totalPages: response.pageInfo.totalPages,
loading: false,
page: page,
});
window.history.pushState(
{},
"",
country
? `/ranking/country/${country}/${page}`
: `/ranking/global/${page}`,
);
});
},
[country, isMobile, pageInfo, router],
);
useEffect(() => {
if (!pageInfo.loading || error) return;
updatePage(pageInfo.page);
}, [error, country, updatePage, pageInfo.page, pageInfo.loading]);
if (pageInfo.loading || error) {
return (
<main>
<Container>
<Card outerClassName="mt-2" className="mt-2">
<div className="p-3 text-center">
<div role="status">
<div className="flex flex-col items-center justify-center gap-2">
{error && <Error errorMessage={errorMessage} />}
{!error && <Spinner />}
</div>
</div>
</div>
</Card>
</Container>
</main>
);
}
const players = pageInfo.players;
return ( return (
<main> <main>
<Container> <Container>
<Card outerClassName="mt-2" className="mt-2"> <Card outerClassName="mt-2" className="mt-2">
{pageInfo.loading ? ( <div className="flex flex-col gap-2">
<div className="flex justify-center"> <div className="flex items-center gap-2 p-2">
<Spinner /> {country && (
</div> <CountyFlag countryCode={country} className="!h-8 !w-8" />
) : (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 p-2">
{country && (
<CountyFlag countryCode={country} className="!h-8 !w-8" />
)}
<p>
You are viewing{" "}
{country
? "scores from " +
normalizedRegionName(country.toUpperCase())
: "Global scores"}
</p>
</div>
<Separator />
{!isMobile && (
<table className="table w-full table-auto border-spacing-2 border-none text-left">
<thead>
<tr>
<th className="px-4 py-2">Rank</th>
<th className="px-4 py-2">Profile</th>
<th className="px-4 py-2">Performance Points</th>
<th className="px-4 py-2">Total Plays</th>
<th className="px-4 py-2">Total Ranked Plays</th>
<th className="px-4 py-2">Avg Ranked Accuracy</th>
</tr>
</thead>
<tbody className="border-none">
{players.map((player) => (
<tr key={player.rank} className="border-b border-border">
<PlayerRanking
showCountryFlag={country ? false : true}
player={player}
/>
</tr>
))}
</tbody>
</table>
)} )}
<p>
You are viewing{" "}
{country
? "scores from " + normalizedRegionName(country.toUpperCase())
: "Global scores"}
</p>
</div>
<Separator />
<table className="table w-full table-auto border-spacing-2 border-none text-left">
<thead>
<tr>
<th className="px-4 py-2">Rank</th>
<th className="px-4 py-2">Profile</th>
<th className="px-4 py-2">Performance Points</th>
<th className="px-4 py-2">Total Plays</th>
<th className="px-4 py-2">Total Ranked Plays</th>
<th className="px-4 py-2">Avg Ranked Accuracy</th>
</tr>
</thead>
<tbody className="border-none">
{players.map((player) => (
<tr key={player.rank} className="border-b border-border">
<PlayerRanking
showCountryFlag={country ? false : true}
player={player}
/>
</tr>
))}
</tbody>
</table>
{/*
{isMobile && ( {isMobile && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{players.map((player) => ( {players.map((player) => (
@ -171,22 +78,18 @@ export default function GlobalRanking({ page, country }: GlobalRankingProps) {
</div> </div>
))} ))}
</div> </div>
)} )} */}
{/* Pagination */} {/* Pagination */}
<div className="flex w-full flex-row justify-center"> <div className="flex w-full flex-row justify-center">
<div className="pt-3"> <div className="pt-3">
<Pagination <Pagination
currentPage={pageInfo.page} currentPage={pageInfo.page}
totalPages={pageInfo.totalPages} totalPages={pageInfo.totalPages}
onPageChange={(page) => { />
updatePage(page);
}}
/>
</div>
</div> </div>
</div> </div>
)} </div>
</Card> </Card>
</Container> </Container>
</main> </main>

@ -2,15 +2,17 @@ import {
ArrowUturnLeftIcon, ArrowUturnLeftIcon,
ArrowUturnRightIcon, ArrowUturnRightIcon,
} from "@heroicons/react/20/solid"; } from "@heroicons/react/20/solid";
import Link from "next/link";
type PaginationProps = { type PaginationProps = {
currentPage: number; currentPage: number;
totalPages: number; totalPages: number;
onPageChange: (pageNumber: number) => void; useHref?: boolean;
onPageChange?: (pageNumber: number) => void;
}; };
export default function Pagination(props: PaginationProps) { export default function Pagination(props: PaginationProps) {
const { currentPage, totalPages, onPageChange } = props; const { currentPage, totalPages, useHref, onPageChange } = props;
// Calculate the range of page numbers to display // Calculate the range of page numbers to display
const rangeStart = Math.max(1, currentPage - 2); const rangeStart = Math.max(1, currentPage - 2);
@ -28,26 +30,48 @@ export default function Pagination(props: PaginationProps) {
<ul className="flex items-center gap-2"> <ul className="flex items-center gap-2">
{currentPage > 1 && ( {currentPage > 1 && (
<li className="rounded-md bg-neutral-700 hover:opacity-80"> <li className="rounded-md bg-neutral-700 hover:opacity-80">
<button {useHref ? (
className="px-3 py-1" <Link href={`?page=${currentPage - 1}`}>
onClick={() => onPageChange(currentPage - 1)} <a
aria-label={`Page ${currentPage - 1} (previous page)`} className="px-3 py-1"
> aria-label={`Page ${currentPage - 1} (previous page)`}
<ArrowUturnLeftIcon width={20} height={20} /> >
</button> <ArrowUturnLeftIcon width={20} height={20} />
</a>
</Link>
) : (
<button
className="px-3 py-1"
onClick={() => onPageChange && onPageChange(currentPage - 1)}
aria-label={`Page ${currentPage - 1} (previous page)`}
>
<ArrowUturnLeftIcon width={20} height={20} />
</button>
)}
</li> </li>
)} )}
{currentPage !== 1 && currentPage - 2 > 1 && ( {currentPage !== 1 && currentPage - 2 > 1 && (
<> <>
<li> <li>
<button {useHref ? (
className="rounded-md bg-neutral-700 px-3 py-1 hover:opacity-80" <Link href={`?page=1`}>
onClick={() => onPageChange(1)} <a
aria-label="Page 1 (first page)" className="rounded-md bg-neutral-700 px-3 py-1 hover:opacity-80"
> aria-label="Page 1 (first page)"
1 >
</button> 1
</a>
</Link>
) : (
<button
className="rounded-md bg-neutral-700 px-3 py-1 hover:opacity-80"
onClick={() => onPageChange && onPageChange(1)}
aria-label="Page 1 (first page)"
>
1
</button>
)}
</li> </li>
<li> <li>
<p>...</p> <p>...</p>
@ -57,17 +81,32 @@ export default function Pagination(props: PaginationProps) {
{pageNumbers.map((pageNumber) => ( {pageNumbers.map((pageNumber) => (
<li key={pageNumber}> <li key={pageNumber}>
<button {useHref ? (
className={`rounded-md px-3 py-1 ${ <Link href={`?page=${pageNumber}`}>
pageNumber === currentPage <a
? "bg-blue-500 text-primary" className={`rounded-md px-3 py-1 ${
: "bg-neutral-700 hover:opacity-80" pageNumber === currentPage
}`} ? "bg-blue-500 text-primary"
onClick={() => onPageChange(pageNumber)} : "bg-neutral-700 hover:opacity-80"
aria-label={`Page ${pageNumber}`} }`}
> aria-label={`Page ${pageNumber}`}
{pageNumber} >
</button> {pageNumber}
</a>
</Link>
) : (
<button
className={`rounded-md px-3 py-1 ${
pageNumber === currentPage
? "bg-blue-500 text-primary"
: "bg-neutral-700 hover:opacity-80"
}`}
onClick={() => onPageChange && onPageChange(pageNumber)}
aria-label={`Page ${pageNumber}`}
>
{pageNumber}
</button>
)}
</li> </li>
))} ))}
@ -78,26 +117,48 @@ export default function Pagination(props: PaginationProps) {
</li> </li>
<li> <li>
<button {useHref ? (
className="rounded-md bg-neutral-700 px-3 py-1 hover:opacity-80" <Link href={`?page=${totalPages}`}>
onClick={() => onPageChange(totalPages)} <a
aria-label={`Page ${totalPages} (last page)`} className="rounded-md bg-neutral-700 px-3 py-1 hover:opacity-80"
> aria-label={`Page ${totalPages} (last page)`}
{totalPages} >
</button> {totalPages}
</a>
</Link>
) : (
<button
className="rounded-md bg-neutral-700 px-3 py-1 hover:opacity-80"
onClick={() => onPageChange && onPageChange(totalPages)}
aria-label={`Page ${totalPages} (last page)`}
>
{totalPages}
</button>
)}
</li> </li>
</> </>
)} )}
{currentPage < totalPages && ( {currentPage < totalPages && (
<li className="rounded-md bg-neutral-700 hover:opacity-80"> <li className="rounded-md bg-neutral-700 hover:opacity-80">
<button {useHref ? (
className="px-3 py-1" <Link href={`?page=${currentPage + 1}`}>
onClick={() => onPageChange(currentPage + 1)} <a
aria-label={`Page ${currentPage + 1} (next page)`} className="px-3 py-1"
> aria-label={`Page ${currentPage + 1} (next page)`}
<ArrowUturnRightIcon width={20} height={20} /> >
</button> <ArrowUturnRightIcon width={20} height={20} />
</a>
</Link>
) : (
<button
className="px-3 py-1"
onClick={() => onPageChange && onPageChange(currentPage + 1)}
aria-label={`Page ${currentPage + 1} (next page)`}
>
<ArrowUturnRightIcon width={20} height={20} />
</button>
)}
</li> </li>
)} )}
</ul> </ul>

@ -1,3 +1,5 @@
"use client";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player"; import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { formatNumber } from "@/utils/numberUtils"; import { formatNumber } from "@/utils/numberUtils";
import { import {

@ -1,3 +1,5 @@
"use client";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player"; import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { useScoresaberScoresStore } from "@/store/scoresaberScoresStore"; import { useScoresaberScoresStore } from "@/store/scoresaberScoresStore";
import { useSettingsStore } from "@/store/settingsStore"; import { useSettingsStore } from "@/store/settingsStore";
@ -11,7 +13,7 @@ import {
XMarkIcon, XMarkIcon,
} from "@heroicons/react/20/solid"; } from "@heroicons/react/20/solid";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { useStore } from "zustand"; import { useStore } from "zustand";
import Avatar from "../Avatar"; import Avatar from "../Avatar";
@ -27,6 +29,7 @@ type PlayerInfoProps = {
}; };
export default function PlayerInfo({ playerData }: PlayerInfoProps) { export default function PlayerInfo({ playerData }: PlayerInfoProps) {
const [mounted, setMounted] = useState(false);
const playerId = playerData.id; const playerId = playerData.id;
const settingsStore = useStore(useSettingsStore, (store) => store); const settingsStore = useStore(useSettingsStore, (store) => store);
const playerScoreStore = useStore(useScoresaberScoresStore, (store) => store); const playerScoreStore = useStore(useScoresaberScoresStore, (store) => store);
@ -36,6 +39,10 @@ export default function PlayerInfo({ playerData }: PlayerInfoProps) {
const toastId: any = useRef(null); const toastId: any = useRef(null);
useEffect(() => {
setMounted(true);
}, []);
async function claimProfile() { async function claimProfile() {
settingsStore?.setProfile(playerData); settingsStore?.setProfile(playerData);
addProfile(false); addProfile(false);
@ -122,32 +129,36 @@ export default function PlayerInfo({ playerData }: PlayerInfoProps) {
{/* Settings Buttons */} {/* Settings Buttons */}
<div className="absolute right-3 top-20 flex flex-col justify-end gap-2 md:relative md:right-0 md:top-0 md:mt-2 md:flex-row md:justify-center"> <div className="absolute right-3 top-20 flex flex-col justify-end gap-2 md:relative md:right-0 md:top-0 md:mt-2 md:flex-row md:justify-center">
{!isOwnProfile && ( {mounted && (
<Button
onClick={claimProfile}
tooltip={<p>Set as your Profile</p>}
icon={<HomeIcon width={24} height={24} />}
/>
)}
{!isOwnProfile && (
<> <>
{!settingsStore?.isFriend(playerId) && ( {!isOwnProfile && (
<Button <Button
onClick={addFriend} onClick={claimProfile}
tooltip={<p>Add as Friend</p>} tooltip={<p>Set as your Profile</p>}
icon={<UserIcon width={24} height={24} />} icon={<HomeIcon width={24} height={24} />}
color="bg-green-500"
/> />
)} )}
{settingsStore.isFriend(playerId) && ( {!isOwnProfile && (
<Button <>
onClick={removeFriend} {!settingsStore?.isFriend(playerId) && (
tooltip={<p>Remove Friend</p>} <Button
icon={<XMarkIcon width={24} height={24} />} onClick={addFriend}
color="bg-red-500" tooltip={<p>Add as Friend</p>}
/> icon={<UserIcon width={24} height={24} />}
color="bg-green-500"
/>
)}
{settingsStore.isFriend(playerId) && (
<Button
onClick={removeFriend}
tooltip={<p>Remove Friend</p>}
icon={<XMarkIcon width={24} height={24} />}
color="bg-red-500"
/>
)}
</>
)} )}
</> </>
)} )}
@ -165,7 +176,10 @@ export default function PlayerInfo({ playerData }: PlayerInfoProps) {
<a <a
className="flex transform-gpu items-center gap-1 transition-all hover:text-blue-500" className="flex transform-gpu items-center gap-1 transition-all hover:text-blue-500"
href={`/ranking/global/${Math.round(playerData.rank / 50)}`} href={`/ranking/global/${Math.max(
Math.round(playerData.rank / 50),
1,
)}`}
> >
<p>#{formatNumber(playerData.rank)}</p> <p>#{formatNumber(playerData.rank)}</p>
</a> </a>
@ -175,8 +189,9 @@ export default function PlayerInfo({ playerData }: PlayerInfoProps) {
<div className="text-gray-300"> <div className="text-gray-300">
<a <a
className="flex transform-gpu items-center gap-1 transition-all hover:text-blue-500" className="flex transform-gpu items-center gap-1 transition-all hover:text-blue-500"
href={`/ranking/country/${playerData.country}/${Math.round( href={`/ranking/country/${playerData.country}/${Math.max(
playerData.countryRank / 50, Math.round(playerData.countryRank / 50),
1,
)}`} )}`}
> >
<CountyFlag <CountyFlag

@ -1,94 +1,36 @@
"use client";
import Card from "@/components/Card"; import Card from "@/components/Card";
import Container from "@/components/Container"; import Container from "@/components/Container";
import Spinner from "@/components/Spinner";
import Scores from "@/components/player/Scores"; import Scores from "@/components/player/Scores";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player"; import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { SortTypes } from "@/types/SortTypes"; import { SortTypes } from "@/types/SortTypes";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import clsx from "clsx"; import clsx from "clsx";
import dynamic from "next/dynamic";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/Tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/Tooltip";
import PlayerChart from "./PlayerChart"; import PlayerChart from "./PlayerChart";
import PlayerInfo from "./PlayerInfo"; import PlayerInfo from "./PlayerInfo";
const Error = dynamic(() => import("@/components/Error"));
type PlayerInfo = { type PlayerInfo = {
loading: boolean; loading: boolean;
player: ScoresaberPlayer | undefined; player: ScoresaberPlayer | undefined;
}; };
type PlayerPageProps = { type PlayerPageProps = {
id: string; player: ScoresaberPlayer;
sort: string; sort: string;
page: string; page: string;
}; };
const DEFAULT_SORT_TYPE = SortTypes.top; const DEFAULT_SORT_TYPE = SortTypes.top;
export default function PlayerPage({ id, sort, page }: PlayerPageProps) { export default function PlayerPage({ player, sort, page }: PlayerPageProps) {
const [mounted, setMounted] = useState(false);
const [error, setError] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [player, setPlayer] = useState<PlayerInfo>({
loading: true,
player: undefined,
});
const sortType = SortTypes[sort] || DEFAULT_SORT_TYPE; const sortType = SortTypes[sort] || DEFAULT_SORT_TYPE;
useEffect(() => { const badges = player.badges;
setMounted(true);
if (error || !player.loading) {
return;
}
if (mounted == true) {
return;
}
ScoreSaberAPI.fetchPlayerData(id).then((playerResponse) => {
if (!playerResponse) {
setError(true);
setErrorMessage("Failed to fetch player. Is the ID correct?");
setPlayer({ ...player, loading: false });
return;
}
setPlayer({ ...player, player: playerResponse, loading: false });
});
}, [error, mounted, id, player]);
if (player.loading || error || !player.player) {
return (
<main>
<Container>
<Card outerClassName="mt-2" className="mt-2">
<div className="p-3 text-center">
<div role="status">
<div className="flex flex-col items-center justify-center gap-2">
{error && <Error errorMessage={errorMessage} />}
{!error && <Spinner />}
</div>
</div>
</div>
</Card>
</Container>
</main>
);
}
const playerData = player.player;
const badges = playerData.badges;
return ( return (
<main> <main>
<Container> <Container>
<PlayerInfo playerData={playerData} /> <PlayerInfo playerData={player} />
{/* Chart */} {/* Chart */}
<Card outerClassName="mt-2"> <Card outerClassName="mt-2">
{/* Badges */} {/* Badges */}
@ -117,14 +59,10 @@ export default function PlayerPage({ id, sort, page }: PlayerPageProps) {
})} })}
</div> </div>
<div className="h-[320px] w-full"> <div className="h-[320px] w-full">
<PlayerChart scoresaber={playerData} /> <PlayerChart scoresaber={player} />
</div> </div>
</Card> </Card>
<Scores <Scores playerData={player} page={Number(page)} sortType={sortType} />
playerData={playerData}
page={Number(page)}
sortType={sortType}
/>
</Container> </Container>
</main> </main>
); );

@ -1,3 +1,5 @@
"use client";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player"; import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore"; import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore";
import { useSettingsStore } from "@/store/settingsStore"; import { useSettingsStore } from "@/store/settingsStore";

@ -23,7 +23,12 @@ export class FetchQueue {
); );
} }
const response = await fetch(url, options); const response = await fetch(url, {
...options,
next: {
revalidate: 300, // 5 minutes
},
});
if (response.status === 429) { if (response.status === 429) {
const hasRetryAfterHeader = response.headers.has("retry-after"); const hasRetryAfterHeader = response.headers.has("retry-after");
let retryAfter = hasRetryAfterHeader let retryAfter = hasRetryAfterHeader