diff --git a/src/app/leaderboard/[id]/[page]/page.tsx b/src/app/leaderboard/[id]/[page]/page.tsx new file mode 100644 index 0000000..4666c92 --- /dev/null +++ b/src/app/leaderboard/[id]/[page]/page.tsx @@ -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 { + return { + title: `Leaderboard - name`, + }; +} + +export default function RankingGlobal({ params: { id, page } }: Props) { + return ; +} diff --git a/src/app/ranking/country/[country]/[page]/page.tsx b/src/app/ranking/country/[country]/[page]/page.tsx index 4bb615d..ef5dabd 100644 --- a/src/app/ranking/country/[country]/[page]/page.tsx +++ b/src/app/ranking/country/[country]/[page]/page.tsx @@ -1,4 +1,4 @@ -import GlobalRanking from "@/components/player/GlobalRanking"; +import GlobalRanking from "@/components/GlobalRanking"; import { Metadata } from "next"; export const metadata: Metadata = { diff --git a/src/app/ranking/global/[page]/page.tsx b/src/app/ranking/global/[page]/page.tsx index 80d57fd..86e7cd1 100644 --- a/src/app/ranking/global/[page]/page.tsx +++ b/src/app/ranking/global/[page]/page.tsx @@ -1,4 +1,4 @@ -import GlobalRanking from "@/components/player/GlobalRanking"; +import GlobalRanking from "@/components/GlobalRanking"; import { Metadata } from "next"; export const metadata: Metadata = { diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 2441330..7dead25 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -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 = { diff --git a/src/components/Card.tsx b/src/components/Card.tsx index 53593e5..7515f66 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -15,7 +15,7 @@ export default function Card({
diff --git a/src/components/player/GlobalRanking.tsx b/src/components/GlobalRanking.tsx similarity index 95% rename from src/components/player/GlobalRanking.tsx rename to src/components/GlobalRanking.tsx index 7b60166..649018e 100644 --- a/src/components/player/GlobalRanking.tsx +++ b/src/components/GlobalRanking.tsx @@ -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")); diff --git a/src/components/player/SearchPlayer.tsx b/src/components/SearchPlayer.tsx similarity index 98% rename from src/components/player/SearchPlayer.tsx rename to src/components/SearchPlayer.tsx index a2dbd59..9b95079 100644 --- a/src/components/player/SearchPlayer.tsx +++ b/src/components/SearchPlayer.tsx @@ -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(""); diff --git a/src/components/HeadsetIcon.tsx b/src/components/icons/HeadsetIcon.tsx similarity index 100% rename from src/components/HeadsetIcon.tsx rename to src/components/icons/HeadsetIcon.tsx diff --git a/src/components/leaderboard/Leaderboard.tsx b/src/components/leaderboard/Leaderboard.tsx new file mode 100644 index 0000000..8fec42a --- /dev/null +++ b/src/components/leaderboard/Leaderboard.tsx @@ -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({ + 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 ( + +
+ +
+
+ Song Cover +
+

{songName}

+

{songSubName}

+

Mapped By: {levelAuthorName}

+
+
+
+

Status: {ranked ? "Ranked" : "Unranked"}

+
+

Stars:

+ +

{stars}

+
+

+ Plays: {formatNumber(plays)} ({dailyPlays} in the last day) +

+
+
+
+ +
+ {difficulties.map((diff) => { + return ( +
+ + {scoresaberDifficultyNumberToName(diff.difficulty)} + +
+ ); + })} +
+
+ {leaderboardScores?.map((score, index) => { + return ( +
+ +
+ ); + })} +
+ + {/* Pagination */} +
+
+ { + updateScoresPage(page); + }} + /> +
+
+
+
+
+ ); +} diff --git a/src/components/leaderboard/LeaderboardScore.tsx b/src/components/leaderboard/LeaderboardScore.tsx new file mode 100644 index 0000000..5dd47b5 --- /dev/null +++ b/src/components/leaderboard/LeaderboardScore.tsx @@ -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 ( +
+
+
+

#{formatNumber(score.rank)}

+
+

+ {formatTimeAgo(score.timeSet)} +

+
+ {/* Song Image */} +
+
+ {player.name} +
+ {/* Player Info */} + +
+

{player.name}

+
+ +
+ +
+ {/* PP */} +
+ {score.pp > 0 && ( + + )} + + {/* Percentage score */} + +
+
+
+ ); +} diff --git a/src/components/player/Score.tsx b/src/components/player/Score.tsx index 1d4f8bc..b6f4b0b 100644 --- a/src/components/player/Score.tsx +++ b/src/components/player/Score.tsx @@ -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) {
{/* Song Info */} -
-

{leaderboard.songName}

-

- {leaderboard.songAuthorName}{" "} - {leaderboard.levelAuthorName} -

-
+ +
+

{leaderboard.songName}

+

+ {leaderboard.songAuthorName}{" "} + + {leaderboard.levelAuthorName} + +

+
+
diff --git a/src/schemas/scoresaber/leaderboardPlayerInfo.ts b/src/schemas/scoresaber/leaderboardPlayerInfo.ts new file mode 100644 index 0000000..55e2baa --- /dev/null +++ b/src/schemas/scoresaber/leaderboardPlayerInfo.ts @@ -0,0 +1,8 @@ +type LeaderboardPlayerInfo = { + id: string; + name: string; + profilePicture: string; + country: string; + permissions: number; + role: string; +}; diff --git a/src/schemas/scoresaber/score.ts b/src/schemas/scoresaber/score.ts index 2afe79f..d9b3191 100644 --- a/src/schemas/scoresaber/score.ts +++ b/src/schemas/scoresaber/score.ts @@ -1,6 +1,6 @@ export type ScoresaberScore = { id: number; - leaderboardPlayerInfo: string; + leaderboardPlayerInfo: LeaderboardPlayerInfo; rank: number; baseScore: number; modifiedScore: number; diff --git a/src/utils/scoresaber/api.ts b/src/utils/scoresaber/api.ts index 8a98dda..796b54e 100644 --- a/src/utils/scoresaber/api.ts +++ b/src/utils/scoresaber/api.ts @@ -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 { + 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, };