add leaderboard page
All checks were successful
deploy / deploy (push) Successful in 56s

This commit is contained in:
Lee 2023-10-28 18:00:11 +01:00
parent e3450e23b1
commit b9fd569196
14 changed files with 390 additions and 21 deletions

@ -0,0 +1,18 @@
import Leaderboard from "@/components/leaderboard/Leaderboard";
import { Metadata } from "next";
type Props = {
params: { id: string; page: string };
};
export async function generateMetadata({
params: { id },
}: Props): Promise<Metadata> {
return {
title: `Leaderboard - name`,
};
}
export default function RankingGlobal({ params: { id, page } }: Props) {
return <Leaderboard id={id} page={Number(page)} />;
}

@ -1,4 +1,4 @@
import GlobalRanking from "@/components/player/GlobalRanking"; import GlobalRanking from "@/components/GlobalRanking";
import { Metadata } from "next"; import { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {

@ -1,4 +1,4 @@
import GlobalRanking from "@/components/player/GlobalRanking"; import GlobalRanking from "@/components/GlobalRanking";
import { Metadata } from "next"; import { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {

@ -2,7 +2,7 @@ import Card from "@/components/Card";
import Container from "@/components/Container"; import Container from "@/components/Container";
import UnknownAvatar from "@/components/UnknownAvatar"; import UnknownAvatar from "@/components/UnknownAvatar";
import SearchPlayer from "@/components/player/SearchPlayer"; import SearchPlayer from "@/components/SearchPlayer";
import { Metadata } from "next"; import { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {

@ -15,7 +15,7 @@ export default function Card({
<div className={className}> <div className={className}>
<div <div
className={clsx( className={clsx(
"rounded-md bg-gray-800 p-3 opacity-90", "w-full rounded-md bg-gray-800 p-3 opacity-90",
innerClassName, innerClassName,
)} )}
> >

@ -7,13 +7,13 @@ import dynamic from "next/dynamic";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react"; 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 Spinner from "./Spinner";
import PlayerRanking from "./PlayerRanking"; import PlayerRanking from "./player/PlayerRanking";
import PlayerRankingMobile from "./PlayerRankingMobile"; import PlayerRankingMobile from "./player/PlayerRankingMobile";
const Error = dynamic(() => import("@/components/Error")); const Error = dynamic(() => import("@/components/Error"));

@ -6,7 +6,7 @@ import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import clsx from "clsx"; import clsx from "clsx";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Avatar from "../Avatar"; import Avatar from "./Avatar";
export default function SearchPlayer() { export default function SearchPlayer() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");

@ -0,0 +1,178 @@
"use client";
import { ScoresaberLeaderboardInfo } from "@/schemas/scoresaber/leaderboard";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import Image from "next/image";
import { ScoresaberScore } from "@/schemas/scoresaber/score";
import { formatNumber } from "@/utils/number";
import { scoresaberDifficultyNumberToName } from "@/utils/songUtils";
import { StarIcon } from "@heroicons/react/20/solid";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import Card from "../Card";
import Container from "../Container";
import Pagination from "../Pagination";
import LeaderboardScore from "./LeaderboardScore";
type LeaderboardProps = {
id: string;
page: number;
};
type PageInfo = {
loading: boolean;
page: number;
totalPages: number;
scores: ScoresaberScore[];
};
export default function Leaderboard({ id, page }: LeaderboardProps) {
const [mounted, setMounted] = useState(false);
const [leaderboardData, setLeaderboardData] = useState(
undefined as ScoresaberLeaderboardInfo | undefined,
);
const [leaderboardScoredsData, setLeaderboardScoredsData] =
useState<PageInfo>({
loading: true,
page: page,
totalPages: 1,
scores: [],
});
const fetchLeaderboard = useCallback(async () => {
const leaderboard = await ScoreSaberAPI.fetchLeaderboardInfo(id);
setLeaderboardData(leaderboard);
}, [id]);
const updateScoresPage = useCallback(
async (page: number) => {
const leaderboardScores = await ScoreSaberAPI.fetchLeaderboardScores(
id,
page,
);
if (!leaderboardScores) {
return;
}
setLeaderboardScoredsData({
...leaderboardScoredsData,
scores: leaderboardScores.scores,
totalPages: leaderboardScores.pageInfo.totalPages,
loading: false,
page: page,
});
window.history.pushState({}, "", `/leaderboard/${id}/${page}`);
console.log(`Switched page to ${page}`);
},
[id, leaderboardScoredsData],
);
useEffect(() => {
if (mounted) return;
fetchLeaderboard();
updateScoresPage(1);
setMounted(true);
}, [fetchLeaderboard, mounted, updateScoresPage]);
if (!leaderboardData) {
return null;
}
const leaderboardScores = leaderboardScoredsData.scores;
const {
coverImage,
songName,
songSubName,
levelAuthorName,
stars,
plays,
dailyPlays,
ranked,
difficulties,
} = leaderboardData;
return (
<Container>
<div className="flex flex-col gap-2 md:flex-row">
<Card className="mt-2 flex">
<div className="flex min-w-[300px] flex-wrap justify-between gap-2 md:justify-start">
<div className="flex gap-2">
<Image
src={coverImage}
width={100}
height={100}
alt="Song Cover"
className="rounded-xl"
/>
<div className="flex flex-col">
<p className="text-xl font-bold">{songName}</p>
<p className="text-lg">{songSubName}</p>
<p>Mapped By: {levelAuthorName}</p>
</div>
</div>
<div className="flex flex-col gap-1">
<p>Status: {ranked ? "Ranked" : "Unranked"}</p>
<div className="flex">
<p>Stars:</p>
<StarIcon width={20} height={20} className="ml-1" />
<p>{stars}</p>
</div>
<p>
Plays: {formatNumber(plays)} ({dailyPlays} in the last day)
</p>
</div>
</div>
</Card>
<Card className="mt-2 h-fit">
<div className="flex justify-center gap-2">
{difficulties.map((diff) => {
return (
<div
key={diff.difficulty}
className={`flex transform-gpu flex-row items-center gap-1 rounded-md p-[0.35rem] transition-all hover:opacity-80 ${
Number(id) === diff.leaderboardId
? "bg-blue-500"
: "bg-gray-500"
}`}
>
<Link href={`/leaderboard/${diff.leaderboardId}/1`}>
{scoresaberDifficultyNumberToName(diff.difficulty)}
</Link>
</div>
);
})}
</div>
<div className="grid grid-cols-1 divide-y divide-gray-500">
{leaderboardScores?.map((score, index) => {
return (
<div key={index}>
<LeaderboardScore
score={score}
player={score.leaderboardPlayerInfo}
leaderboard={leaderboardData}
/>
</div>
);
})}
</div>
{/* Pagination */}
<div>
<div className="p-3">
<Pagination
currentPage={leaderboardScoredsData.page}
totalPages={leaderboardScoredsData.totalPages}
onPageChange={(page) => {
updateScoresPage(page);
}}
/>
</div>
</div>
</Card>
</div>
</Container>
);
}

@ -0,0 +1,84 @@
import { ScoresaberLeaderboardInfo } from "@/schemas/scoresaber/leaderboard";
import { ScoresaberScore } from "@/schemas/scoresaber/score";
import { formatNumber } from "@/utils/number";
import { scoresaberDifficultyNumberToName } from "@/utils/songUtils";
import { formatDate, formatTimeAgo } from "@/utils/timeUtils";
import Image from "next/image";
import Link from "next/link";
import ScoreStatLabel from "../player/ScoreStatLabel";
type ScoreProps = {
score: ScoresaberScore;
player: LeaderboardPlayerInfo;
leaderboard: ScoresaberLeaderboardInfo;
};
export default function LeaderboardScore({
score,
player,
leaderboard,
}: ScoreProps) {
const diffName = scoresaberDifficultyNumberToName(
leaderboard.difficulty.difficulty,
);
const accuracy = ((score.baseScore / leaderboard.maxScore) * 100).toFixed(2);
return (
<div className="mb-1 mt-1 grid grid-cols-[0.5fr_3fr_1.3fr] first:pt-0 last:pb-0 md:grid-cols-[1.2fr_6fr_1.3fr]">
<div className="flex flex-col items-center justify-center">
<div className="flex w-fit flex-row items-center justify-center gap-1">
<p>#{formatNumber(score.rank)}</p>
</div>
<p
className="hidden text-sm text-gray-200 md:block"
title={formatDate(score.timeSet)}
>
{formatTimeAgo(score.timeSet)}
</p>
</div>
{/* Song Image */}
<div className="flex w-full items-center gap-2 ">
<div className="flex">
<Image
src={player.profilePicture}
alt={player.name}
className="flex h-fit rounded-md"
width={50}
height={50}
loading="lazy"
/>
</div>
{/* Player Info */}
<Link
href={`/leaderboard/${leaderboard.id}/1`}
className="transform-gpu transition-all hover:opacity-70"
>
<div className="w-fit truncate text-blue-500">
<p className="font-bold">{player.name}</p>
</div>
</Link>
</div>
<div className="flex items-center justify-between p-1 md:items-start md:justify-end">
{/* PP */}
<div className="flex justify-end gap-2">
{score.pp > 0 && (
<ScoreStatLabel
className="bg-blue-500 text-center"
value={formatNumber(score.pp.toFixed(2)) + "pp"}
/>
)}
{/* Percentage score */}
<ScoreStatLabel
value={
!leaderboard.maxScore
? formatNumber(score.baseScore)
: accuracy + "%"
}
/>
</div>
</div>
</div>
);
}

@ -15,7 +15,8 @@ import {
} from "@heroicons/react/20/solid"; } from "@heroicons/react/20/solid";
import clsx from "clsx"; import clsx from "clsx";
import Image from "next/image"; import Image from "next/image";
import HeadsetIcon from "../HeadsetIcon"; import Link from "next/link";
import HeadsetIcon from "../icons/HeadsetIcon";
import ScoreStatLabel from "./ScoreStatLabel"; import ScoreStatLabel from "./ScoreStatLabel";
type ScoreProps = { type ScoreProps = {
@ -76,13 +77,20 @@ export default function Score({ score, player, leaderboard }: ScoreProps) {
</div> </div>
</div> </div>
{/* Song Info */} {/* Song Info */}
<Link
href={`/leaderboard/${leaderboard.id}/1`}
className="transform-gpu transition-all hover:opacity-70"
>
<div className="w-fit truncate text-blue-500"> <div className="w-fit truncate text-blue-500">
<p className="font-bold">{leaderboard.songName}</p> <p className="font-bold">{leaderboard.songName}</p>
<p className="text-blue-300"> <p className="text-blue-300">
{leaderboard.songAuthorName}{" "} {leaderboard.songAuthorName}{" "}
<span className="text-gray-200">{leaderboard.levelAuthorName}</span> <span className="text-gray-200">
{leaderboard.levelAuthorName}
</span>
</p> </p>
</div> </div>
</Link>
</div> </div>
<div className="flex items-center justify-between p-1 md:items-start md:justify-end"> <div className="flex items-center justify-between p-1 md:items-start md:justify-end">

@ -0,0 +1,8 @@
type LeaderboardPlayerInfo = {
id: string;
name: string;
profilePicture: string;
country: string;
permissions: number;
role: string;
};

@ -1,6 +1,6 @@
export type ScoresaberScore = { export type ScoresaberScore = {
id: number; id: number;
leaderboardPlayerInfo: string; leaderboardPlayerInfo: LeaderboardPlayerInfo;
rank: number; rank: number;
baseScore: number; baseScore: number;
modifiedScore: number; modifiedScore: number;

@ -1,5 +1,7 @@
import { ScoresaberLeaderboardInfo } from "@/schemas/scoresaber/leaderboard";
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 { ScoresaberScore } from "@/schemas/scoresaber/score";
import { ssrSettings } from "@/ssrSettings"; import { ssrSettings } from "@/ssrSettings";
import { FetchQueue } from "../fetchWithQueue"; import { FetchQueue } from "../fetchWithQueue";
import { formatString } from "../string"; import { formatString } from "../string";
@ -17,6 +19,10 @@ export const SS_GET_PLAYER_DATA_FULL = SS_API_URL + "/player/{}/full";
export const SS_GET_PLAYERS_URL = SS_API_URL + "/players?page={}"; export const SS_GET_PLAYERS_URL = SS_API_URL + "/players?page={}";
export const SS_GET_PLAYERS_BY_COUNTRY_URL = export const SS_GET_PLAYERS_BY_COUNTRY_URL =
SS_API_URL + "/players?page={}&countries={}"; SS_API_URL + "/players?page={}&countries={}";
export const SS_GET_LEADERBOARD_INFO_URL =
SS_API_URL + "/leaderboard/by-id/{}/info";
export const SS_GET_LEADERBOARD_SCORES_URL =
SS_API_URL + "/leaderboard/by-id/{}/scores?page={}";
const SearchType = { const SearchType = {
RECENT: "recent", RECENT: "recent",
@ -200,10 +206,77 @@ async function fetchTopPlayers(
}; };
} }
/**
* Get the leaderboard info for the given leaderboard id
*
* @param leaderboardId the id of the leaderboard
* @returns the leaderboard info
*/
async function fetchLeaderboardInfo(
leaderboardId: string,
): Promise<ScoresaberLeaderboardInfo | undefined> {
const response = await ScoresaberFetchQueue.fetch(
formatString(SS_GET_LEADERBOARD_INFO_URL, true, leaderboardId),
);
const json = await response.json();
// Check if there was an error fetching the user data
if (json.errorMessage) {
return undefined;
}
return json as ScoresaberLeaderboardInfo;
}
/**
* Get the leaderboard scores from the given page
*
* @param leaderboardId the id of the leaderboard
* @param page the page to get the scores from
* @returns a list of scores
*/
async function fetchLeaderboardScores(
leaderboardId: string,
page: number = 1,
): Promise<
| {
scores: ScoresaberScore[];
pageInfo: {
totalScores: number;
page: number;
totalPages: number;
};
}
| undefined
> {
const response = await ScoresaberFetchQueue.fetch(
formatString(SS_GET_LEADERBOARD_SCORES_URL, true, leaderboardId, page),
);
const json = await response.json();
// Check if there was an error fetching the user data
if (json.errorMessage) {
return undefined;
}
const scores = json.scores as ScoresaberScore[];
const metadata = json.metadata;
return {
scores: scores,
pageInfo: {
totalScores: metadata.total,
page: metadata.page,
totalPages: Math.ceil(metadata.total / metadata.itemsPerPage),
},
};
}
export const ScoreSaberAPI = { export const ScoreSaberAPI = {
searchByName, searchByName,
fetchPlayerData, fetchPlayerData,
fetchScores, fetchScores,
fetchAllScores, fetchAllScores,
fetchTopPlayers, fetchTopPlayers,
fetchLeaderboardInfo,
fetchLeaderboardScores,
}; };