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
const { withSentryConfig } = require("@sentry/nextjs");
// const { withSentryConfig } = require("@sentry/nextjs");
module.exports = withSentryConfig(
module.exports,
{
// For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options
// module.exports = withSentryConfig(
// module.exports,
// {
// // For all available options, see:
// // https://github.com/getsentry/sentry-webpack-plugin#options
// Suppresses source map uploading logs during build
silent: true,
org: "sentry",
project: "scoresaber-reloaded",
url: "https://sentry.fascinated.cc/",
},
{
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// // Suppresses source map uploading logs during build
// silent: true,
// org: "sentry",
// project: "scoresaber-reloaded",
// url: "https://sentry.fascinated.cc/",
// },
// {
// // For all available options, see:
// // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: false,
// // Upload a larger set of source maps for prettier stack traces (increases build time)
// widenClientFileUpload: false,
// Transpiles SDK to be compatible with IE11 (increases bundle size)
transpileClientSDK: false,
// // Transpiles SDK to be compatible with IE11 (increases bundle size)
// transpileClientSDK: false,
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
tunnelRoute: "/monitoring",
// // Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
// tunnelRoute: "/monitoring",
// Hides source maps from generated client bundles
hideSourceMaps: true,
// // Hides source maps from generated client bundles
// hideSourceMaps: true,
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
},
);
// // Automatically tree-shake Sentry logger statements to reduce bundle size
// 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 { ScoreSaberAPI } from "@/utils/scoresaber/api";
import { Metadata } from "next";
export const metadata: Metadata = {
@ -9,6 +13,43 @@ type Props = {
params: { page: string; country: string };
};
export default function RankingGlobal({ params: { page, country } }: Props) {
return <GlobalRanking page={Number(page)} country={country} />;
async function getData(page: number, country: string) {
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 { ScoreSaberAPI } from "@/utils/scoresaber/api";
import { Metadata } from "next";
export const metadata: Metadata = {
@ -9,6 +13,40 @@ type Props = {
params: { page: string };
};
export default function RankingGlobal({ params: { page } }: Props) {
return <GlobalRanking page={Number(page)} />;
async function getData(page: number) {
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";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
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 Container from "./Container";
import CountyFlag from "./CountryFlag";
import Pagination from "./Pagination";
import Spinner from "./Spinner";
import PlayerRanking from "./player/PlayerRanking";
import PlayerRankingMobile from "./player/PlayerRankingMobile";
import { Separator } from "./ui/separator";
const Error = dynamic(() => import("@/components/Error"));
type PageInfo = {
loading: boolean;
page: number;
totalPages: number;
players: ScoresaberPlayer[];
};
type GlobalRankingProps = {
page: number;
players: ScoresaberPlayer[];
country?: string;
pageInfo: {
page: number;
totalPages: number;
};
};
export default function GlobalRanking({ page, country }: GlobalRankingProps) {
const router = useRouter();
const searchQuery = useSearchParams();
const isMobile = searchQuery.get("mobile") == "true";
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;
export default function GlobalRanking({
players,
country,
pageInfo,
}: GlobalRankingProps) {
return (
<main>
<Container>
<Card outerClassName="mt-2" className="mt-2">
{pageInfo.loading ? (
<div className="flex justify-center">
<Spinner />
</div>
) : (
<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>
<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 />
<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 && (
<div className="flex flex-col gap-2">
{players.map((player) => (
@ -171,22 +78,18 @@ export default function GlobalRanking({ page, country }: GlobalRankingProps) {
</div>
))}
</div>
)}
)} */}
{/* Pagination */}
<div className="flex w-full flex-row justify-center">
<div className="pt-3">
<Pagination
currentPage={pageInfo.page}
totalPages={pageInfo.totalPages}
onPageChange={(page) => {
updatePage(page);
}}
/>
</div>
{/* Pagination */}
<div className="flex w-full flex-row justify-center">
<div className="pt-3">
<Pagination
currentPage={pageInfo.page}
totalPages={pageInfo.totalPages}
/>
</div>
</div>
)}
</div>
</Card>
</Container>
</main>

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

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

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

@ -1,94 +1,36 @@
"use client";
import Card from "@/components/Card";
import Container from "@/components/Container";
import Spinner from "@/components/Spinner";
import Scores from "@/components/player/Scores";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { SortTypes } from "@/types/SortTypes";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import clsx from "clsx";
import dynamic from "next/dynamic";
import Image from "next/image";
import { useEffect, useState } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/Tooltip";
import PlayerChart from "./PlayerChart";
import PlayerInfo from "./PlayerInfo";
const Error = dynamic(() => import("@/components/Error"));
type PlayerInfo = {
loading: boolean;
player: ScoresaberPlayer | undefined;
};
type PlayerPageProps = {
id: string;
player: ScoresaberPlayer;
sort: string;
page: string;
};
const DEFAULT_SORT_TYPE = SortTypes.top;
export default function PlayerPage({ id, 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,
});
export default function PlayerPage({ player, sort, page }: PlayerPageProps) {
const sortType = SortTypes[sort] || DEFAULT_SORT_TYPE;
useEffect(() => {
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;
const badges = player.badges;
return (
<main>
<Container>
<PlayerInfo playerData={playerData} />
<PlayerInfo playerData={player} />
{/* Chart */}
<Card outerClassName="mt-2">
{/* Badges */}
@ -117,14 +59,10 @@ export default function PlayerPage({ id, sort, page }: PlayerPageProps) {
})}
</div>
<div className="h-[320px] w-full">
<PlayerChart scoresaber={playerData} />
<PlayerChart scoresaber={player} />
</div>
</Card>
<Scores
playerData={playerData}
page={Number(page)}
sortType={sortType}
/>
<Scores playerData={player} page={Number(page)} sortType={sortType} />
</Container>
</main>
);

@ -1,3 +1,5 @@
"use client";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore";
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) {
const hasRetryAfterHeader = response.headers.has("retry-after");
let retryAfter = hasRetryAfterHeader