feat(ssr): server side render some things to speed up page loads
All checks were successful
deploy / deploy (push) Successful in 1m12s
All checks were successful
deploy / deploy (push) Successful in 1m12s
This commit is contained in:
parent
3a6312510a
commit
c251239e45
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user