This commit is contained in:
parent
e3450e23b1
commit
b9fd569196
18
src/app/leaderboard/[id]/[page]/page.tsx
Normal file
18
src/app/leaderboard/[id]/[page]/page.tsx
Normal file
@ -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";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import GlobalRanking from "@/components/player/GlobalRanking";
|
||||
import GlobalRanking from "@/components/GlobalRanking";
|
||||
import { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
@ -2,7 +2,7 @@ import Card from "@/components/Card";
|
||||
import Container from "@/components/Container";
|
||||
import UnknownAvatar from "@/components/UnknownAvatar";
|
||||
|
||||
import SearchPlayer from "@/components/player/SearchPlayer";
|
||||
import SearchPlayer from "@/components/SearchPlayer";
|
||||
import { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
@ -15,7 +15,7 @@ export default function Card({
|
||||
<div className={className}>
|
||||
<div
|
||||
className={clsx(
|
||||
"rounded-md bg-gray-800 p-3 opacity-90",
|
||||
"w-full rounded-md bg-gray-800 p-3 opacity-90",
|
||||
innerClassName,
|
||||
)}
|
||||
>
|
||||
|
@ -7,13 +7,13 @@ 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 "./PlayerRanking";
|
||||
import PlayerRankingMobile from "./PlayerRankingMobile";
|
||||
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";
|
||||
|
||||
const Error = dynamic(() => import("@/components/Error"));
|
||||
|
@ -6,7 +6,7 @@ import { ScoreSaberAPI } from "@/utils/scoresaber/api";
|
||||
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useState } from "react";
|
||||
import Avatar from "../Avatar";
|
||||
import Avatar from "./Avatar";
|
||||
|
||||
export default function SearchPlayer() {
|
||||
const [search, setSearch] = useState("");
|
178
src/components/leaderboard/Leaderboard.tsx
Normal file
178
src/components/leaderboard/Leaderboard.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
84
src/components/leaderboard/LeaderboardScore.tsx
Normal file
84
src/components/leaderboard/LeaderboardScore.tsx
Normal file
@ -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";
|
||||
import clsx from "clsx";
|
||||
import Image from "next/image";
|
||||
import HeadsetIcon from "../HeadsetIcon";
|
||||
import Link from "next/link";
|
||||
import HeadsetIcon from "../icons/HeadsetIcon";
|
||||
import ScoreStatLabel from "./ScoreStatLabel";
|
||||
|
||||
type ScoreProps = {
|
||||
@ -76,13 +77,20 @@ export default function Score({ score, player, leaderboard }: ScoreProps) {
|
||||
</div>
|
||||
</div>
|
||||
{/* Song Info */}
|
||||
<div className="w-fit truncate text-blue-500">
|
||||
<p className="font-bold">{leaderboard.songName}</p>
|
||||
<p className="text-blue-300">
|
||||
{leaderboard.songAuthorName}{" "}
|
||||
<span className="text-gray-200">{leaderboard.levelAuthorName}</span>
|
||||
</p>
|
||||
</div>
|
||||
<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">{leaderboard.songName}</p>
|
||||
<p className="text-blue-300">
|
||||
{leaderboard.songAuthorName}{" "}
|
||||
<span className="text-gray-200">
|
||||
{leaderboard.levelAuthorName}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-1 md:items-start md:justify-end">
|
||||
|
8
src/schemas/scoresaber/leaderboardPlayerInfo.ts
Normal file
8
src/schemas/scoresaber/leaderboardPlayerInfo.ts
Normal file
@ -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 = {
|
||||
id: number;
|
||||
leaderboardPlayerInfo: string;
|
||||
leaderboardPlayerInfo: LeaderboardPlayerInfo;
|
||||
rank: number;
|
||||
baseScore: number;
|
||||
modifiedScore: number;
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { ScoresaberLeaderboardInfo } from "@/schemas/scoresaber/leaderboard";
|
||||
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
|
||||
import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore";
|
||||
import { ScoresaberScore } from "@/schemas/scoresaber/score";
|
||||
import { ssrSettings } from "@/ssrSettings";
|
||||
import { FetchQueue } from "../fetchWithQueue";
|
||||
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_BY_COUNTRY_URL =
|
||||
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 = {
|
||||
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 = {
|
||||
searchByName,
|
||||
fetchPlayerData,
|
||||
fetchScores,
|
||||
fetchAllScores,
|
||||
fetchTopPlayers,
|
||||
fetchLeaderboardInfo,
|
||||
fetchLeaderboardScores,
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user