+
{children}
diff --git a/src/common/leaderboards.ts b/src/common/leaderboards.ts
index 4e9a725..b6dfac6 100644
--- a/src/common/leaderboards.ts
+++ b/src/common/leaderboards.ts
@@ -8,16 +8,16 @@ export const leaderboards = {
search: true,
},
queries: {
- lookupScores: (
- player: ScoreSaberPlayerToken,
- sort: ScoreSort,
- page: number,
- ) =>
+ lookupScores: (player: ScoreSaberPlayerToken, sort: ScoreSort, page: number) =>
scoresaberService.lookupPlayerScores({
playerId: player.id,
sort: sort,
page: page,
}),
+
+ lookupGlobalPlayers: (page: number) => scoresaberService.lookupPlayers(page),
+ lookupGlobalPlayersByCountry: (page: number, country: string) =>
+ scoresaberService.lookupPlayersByCountry(page, country),
},
},
};
diff --git a/src/common/model/token/scoresaber/score-saber-players-page-token.ts b/src/common/model/token/scoresaber/score-saber-players-page-token.ts
new file mode 100644
index 0000000..aeadad5
--- /dev/null
+++ b/src/common/model/token/scoresaber/score-saber-players-page-token.ts
@@ -0,0 +1,14 @@
+import ScoreSaberMetadataToken from "./score-saber-metadata-token";
+import ScoreSaberPlayerToken from "./score-saber-player-token";
+
+export interface ScoreSaberPlayersPageToken {
+ /**
+ * The players that were found
+ */
+ players: ScoreSaberPlayerToken[];
+
+ /**
+ * The metadata for the page.
+ */
+ metadata: ScoreSaberMetadataToken;
+}
diff --git a/src/common/number-utils.ts b/src/common/number-utils.ts
index 4e0a71c..4974c9b 100644
--- a/src/common/number-utils.ts
+++ b/src/common/number-utils.ts
@@ -7,3 +7,13 @@
export function formatNumberWithCommas(num: number) {
return num.toLocaleString();
}
+
+/**
+ * Formats the pp value
+ *
+ * @param num the pp to format
+ * @returns the formatted pp
+ */
+export function formatPp(num: number) {
+ return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+}
diff --git a/src/common/service/impl/scoresaber.ts b/src/common/service/impl/scoresaber.ts
index 3ae0e47..cc7b903 100644
--- a/src/common/service/impl/scoresaber.ts
+++ b/src/common/service/impl/scoresaber.ts
@@ -1,13 +1,16 @@
-import Service from "../service";
-import { ScoreSort } from "../score-sort";
import ScoreSaberLeaderboardScoresPageToken from "@/common/model/token/scoresaber/score-saber-leaderboard-scores-page-token";
-import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token";
import ScoreSaberPlayerScoresPageToken from "@/common/model/token/scoresaber/score-saber-player-scores-page-token";
import { ScoreSaberPlayerSearchToken } from "@/common/model/token/scoresaber/score-saber-player-search-token";
+import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token";
+import { ScoreSaberPlayersPageToken } from "@/common/model/token/scoresaber/score-saber-players-page-token";
+import { ScoreSort } from "../score-sort";
+import Service from "../service";
const API_BASE = "https://scoresaber.com/api";
const SEARCH_PLAYERS_ENDPOINT = `${API_BASE}/players?search=:query`;
const LOOKUP_PLAYER_ENDPOINT = `${API_BASE}/player/:id/full`;
+const LOOKUP_PLAYERS_ENDPOINT = `${API_BASE}/players?page=:page`;
+const LOOKUP_PLAYERS_BY_COUNTRY_ENDPOINT = `${API_BASE}/players?page=:page&countries=:country`;
const LOOKUP_PLAYER_SCORES_ENDPOINT = `${API_BASE}/player/:id/scores?limit=:limit&sort=:sort&page=:page`;
const LOOKUP_LEADERBOARD_SCORES_ENDPOINT = `${API_BASE}/leaderboard/by-id/:id/scores?page=:page`;
@@ -23,15 +26,12 @@ class ScoreSaberService extends Service {
* @param useProxy whether to use the proxy or not
* @returns the players that match the query, or undefined if no players were found
*/
- async searchPlayers(
- query: string,
- useProxy = true,
- ): Promise
{
+ async searchPlayers(query: string, useProxy = true): Promise {
const before = performance.now();
this.log(`Searching for players matching "${query}"...`);
const results = await this.fetch(
useProxy,
- SEARCH_PLAYERS_ENDPOINT.replace(":query", query),
+ SEARCH_PLAYERS_ENDPOINT.replace(":query", query)
);
if (results === undefined) {
return undefined;
@@ -40,9 +40,7 @@ class ScoreSaberService extends Service {
return undefined;
}
results.players.sort((a, b) => a.rank - b.rank);
- this.log(
- `Found ${results.players.length} players in ${(performance.now() - before).toFixed(0)}ms`,
- );
+ this.log(`Found ${results.players.length} players in ${(performance.now() - before).toFixed(0)}ms`);
return results;
}
@@ -53,22 +51,61 @@ class ScoreSaberService extends Service {
* @param useProxy whether to use the proxy or not
* @returns the player that matches the ID, or undefined
*/
- async lookupPlayer(
- playerId: string,
- useProxy = true,
- ): Promise {
+ async lookupPlayer(playerId: string, useProxy = true): Promise {
const before = performance.now();
this.log(`Looking up player "${playerId}"...`);
- const response = await this.fetch(
+ const response = await this.fetch(useProxy, LOOKUP_PLAYER_ENDPOINT.replace(":id", playerId));
+ if (response === undefined) {
+ return undefined;
+ }
+ this.log(`Found player "${playerId}" in ${(performance.now() - before).toFixed(0)}ms`);
+ return response;
+ }
+
+ /**
+ * Lookup players on a specific page
+ *
+ * @param page the page to get players for
+ * @param useProxy whether to use the proxy or not
+ * @returns the players on the page, or undefined
+ */
+ async lookupPlayers(page: number, useProxy = true): Promise {
+ const before = performance.now();
+ this.log(`Looking up players on page "${page}"...`);
+ const response = await this.fetch(
useProxy,
- LOOKUP_PLAYER_ENDPOINT.replace(":id", playerId),
+ LOOKUP_PLAYERS_ENDPOINT.replace(":page", page.toString())
);
if (response === undefined) {
return undefined;
}
- this.log(
- `Found player "${playerId}" in ${(performance.now() - before).toFixed(0)}ms`,
+ this.log(`Found ${response.players.length} players in ${(performance.now() - before).toFixed(0)}ms`);
+ return response;
+ }
+
+ /**
+ * Lookup players on a specific page and country
+ *
+ * @param page the page to get players for
+ * @param country the country to get players for
+ * @param useProxy whether to use the proxy or not
+ * @returns the players on the page, or undefined
+ */
+ async lookupPlayersByCountry(
+ page: number,
+ country: string,
+ useProxy = true
+ ): Promise {
+ const before = performance.now();
+ this.log(`Looking up players on page "${page}" for country "${country}"...`);
+ const response = await this.fetch(
+ useProxy,
+ LOOKUP_PLAYERS_BY_COUNTRY_ENDPOINT.replace(":page", page.toString()).replace(":country", country)
);
+ if (response === undefined) {
+ return undefined;
+ }
+ this.log(`Found ${response.players.length} players in ${(performance.now() - before).toFixed(0)}ms`);
return response;
}
@@ -97,20 +134,24 @@ class ScoreSaberService extends Service {
}): Promise {
const before = performance.now();
this.log(
- `Looking up scores for player "${playerId}", sort "${sort}", page "${page}"${search ? `, search "${search}"` : ""}...`,
+ `Looking up scores for player "${playerId}", sort "${sort}", page "${page}"${
+ search ? `, search "${search}"` : ""
+ }...`
);
const response = await this.fetch(
useProxy,
LOOKUP_PLAYER_SCORES_ENDPOINT.replace(":id", playerId)
.replace(":limit", 8 + "")
.replace(":sort", sort)
- .replace(":page", page + "") + (search ? `&search=${search}` : ""),
+ .replace(":page", page + "") + (search ? `&search=${search}` : "")
);
if (response === undefined) {
return undefined;
}
this.log(
- `Found ${response.playerScores.length} scores for player "${playerId}" in ${(performance.now() - before).toFixed(0)}ms`,
+ `Found ${response.playerScores.length} scores for player "${playerId}" in ${(performance.now() - before).toFixed(
+ 0
+ )}ms`
);
return response;
}
@@ -127,24 +168,21 @@ class ScoreSaberService extends Service {
async lookupLeaderboardScores(
leaderboardId: string,
page: number,
- useProxy = true,
+ useProxy = true
): Promise {
const before = performance.now();
- this.log(
- `Looking up scores for leaderboard "${leaderboardId}", page "${page}"...`,
- );
+ this.log(`Looking up scores for leaderboard "${leaderboardId}", page "${page}"...`);
const response = await this.fetch(
useProxy,
- LOOKUP_LEADERBOARD_SCORES_ENDPOINT.replace(":id", leaderboardId).replace(
- ":page",
- page.toString(),
- ),
+ LOOKUP_LEADERBOARD_SCORES_ENDPOINT.replace(":id", leaderboardId).replace(":page", page.toString())
);
if (response === undefined) {
return undefined;
}
this.log(
- `Found ${response.scores.length} scores for leaderboard "${leaderboardId}" in ${(performance.now() - before).toFixed(0)}ms`,
+ `Found ${response.scores.length} scores for leaderboard "${leaderboardId}" in ${(
+ performance.now() - before
+ ).toFixed(0)}ms`
);
return response;
}
diff --git a/src/components/country-flag.tsx b/src/components/country-flag.tsx
index 52c0f59..0cfbd96 100644
--- a/src/components/country-flag.tsx
+++ b/src/components/country-flag.tsx
@@ -1,16 +1,17 @@
type Props = {
- country: string;
+ code: string;
size?: number;
};
-export default function CountryFlag({ country, size = 24 }: Props) {
+export default function CountryFlag({ code, size = 24 }: Props) {
return (
// eslint-disable-next-line @next/next/no-img-element
);
}
diff --git a/src/components/player/player-data.tsx b/src/components/player/player-data.tsx
index 23207ac..ef4054d 100644
--- a/src/components/player/player-data.tsx
+++ b/src/components/player/player-data.tsx
@@ -1,12 +1,12 @@
"use client";
+import ScoreSaberPlayerScoresPageToken from "@/common/model/token/scoresaber/score-saber-player-scores-page-token";
+import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token";
import { scoresaberService } from "@/common/service/impl/scoresaber";
import { ScoreSort } from "@/common/service/score-sort";
-import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token";
-import ScoreSaberPlayerScoresPageToken from "@/common/model/token/scoresaber/score-saber-player-scores-page-token";
import { useQuery } from "@tanstack/react-query";
+import Mini from "../ranking/mini";
import PlayerHeader from "./player-header";
-import PlayerRankChart from "./player-rank-chart";
import PlayerScores from "./player-scores";
const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes
@@ -18,12 +18,7 @@ type Props = {
page: number;
};
-export default function PlayerData({
- initialPlayerData: initalPlayerData,
- initialScoreData,
- sort,
- page,
-}: Props) {
+export default function PlayerData({ initialPlayerData: initalPlayerData, initialScoreData, sort, page }: Props) {
let player = initalPlayerData;
const { data, isLoading, isError } = useQuery({
queryKey: ["player", player.id],
@@ -36,19 +31,16 @@ export default function PlayerData({
}
return (
-
-
- {!player.inactive && (
- <>
-
- >
- )}
-
+
+
+
+ {!player.inactive && <>{/* */}>}
+
+
+
);
}
diff --git a/src/components/player/player-header.tsx b/src/components/player/player-header.tsx
index 23e6527..a633010 100644
--- a/src/components/player/player-header.tsx
+++ b/src/components/player/player-header.tsx
@@ -1,5 +1,5 @@
import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token";
-import { formatNumberWithCommas } from "@/common/number-utils";
+import { formatNumberWithCommas, formatPp } from "@/common/number-utils";
import { GlobeAmericasIcon } from "@heroicons/react/24/solid";
import Card from "../card";
import CountryFlag from "../country-flag";
@@ -20,7 +20,7 @@ const playerData = [
{
showWhenInactiveOrBanned: false,
icon: (player: ScoreSaberPlayerToken) => {
- return
;
+ return
;
},
render: (player: ScoreSaberPlayerToken) => {
return
#{formatNumberWithCommas(player.countryRank)}
;
@@ -29,7 +29,7 @@ const playerData = [
{
showWhenInactiveOrBanned: true,
render: (player: ScoreSaberPlayerToken) => {
- return
{formatNumberWithCommas(player.pp)}pp
;
+ return
{formatPp(player.pp)}pp
;
},
},
];
@@ -50,20 +50,13 @@ export default function PlayerHeader({ player }: Props) {
{player.name}
- {player.inactive && (
-
Inactive Account
- )}
- {player.banned && (
-
Banned Account
- )}
+ {player.inactive &&
Inactive Account
}
+ {player.banned &&
Banned Account
}
{playerData.map((subName, index) => {
// Check if the player is inactive or banned and if the data should be shown
- if (
- !subName.showWhenInactiveOrBanned &&
- (player.inactive || player.banned)
- ) {
+ if (!subName.showWhenInactiveOrBanned && (player.inactive || player.banned)) {
return null;
}
diff --git a/src/components/ranking/mini.tsx b/src/components/ranking/mini.tsx
new file mode 100644
index 0000000..f0ce189
--- /dev/null
+++ b/src/components/ranking/mini.tsx
@@ -0,0 +1,131 @@
+import { leaderboards } from "@/common/leaderboards";
+import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token";
+import { ScoreSaberPlayersPageToken } from "@/common/model/token/scoresaber/score-saber-players-page-token";
+import { formatPp } from "@/common/number-utils";
+import { GlobeAmericasIcon } from "@heroicons/react/24/solid";
+import { useQuery } from "@tanstack/react-query";
+import Link from "next/link";
+import { ReactElement } from "react";
+import Card from "../card";
+import CountryFlag from "../country-flag";
+import { Avatar, AvatarImage } from "../ui/avatar";
+
+const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes
+
+type MiniProps = {
+ type: "Global" | "Country";
+ player: ScoreSaberPlayerToken;
+};
+
+type Variants = {
+ [key: string]: {
+ itemsPerPage: number;
+ icon: (player: ScoreSaberPlayerToken) => ReactElement;
+ getPage: (player: ScoreSaberPlayerToken, itemsPerPage: number) => number;
+ query: (page: number, country: string) => Promise
;
+ };
+};
+
+const miniVariants: Variants = {
+ Global: {
+ itemsPerPage: 50,
+ icon: () => ,
+ getPage: (player: ScoreSaberPlayerToken, itemsPerPage: number) => {
+ return Math.floor((player.rank - 1) / itemsPerPage) + 1;
+ },
+ query: (page: number) => {
+ return leaderboards.ScoreSaber.queries.lookupGlobalPlayers(page);
+ },
+ },
+ Country: {
+ itemsPerPage: 50,
+ icon: (player: ScoreSaberPlayerToken) => {
+ return ;
+ },
+ getPage: (player: ScoreSaberPlayerToken, itemsPerPage: number) => {
+ return Math.floor((player.countryRank - 1) / itemsPerPage) + 1;
+ },
+ query: (page: number, country: string) => {
+ return leaderboards.ScoreSaber.queries.lookupGlobalPlayersByCountry(page, country);
+ },
+ },
+};
+
+export default function Mini({ type, player }: MiniProps) {
+ const variant = miniVariants[type];
+ const icon = variant.icon(player);
+
+ const itemsPerPage = variant.itemsPerPage;
+ const page = variant.getPage(player, itemsPerPage);
+ const rankWithinPage = player.rank % itemsPerPage;
+
+ const { data, isLoading, isError } = useQuery({
+ queryKey: ["player-" + type, player.id, type, page],
+ queryFn: async () => {
+ // Determine pages to search based on player's rank within the page
+ const pagesToSearch = [page];
+ if (rankWithinPage < 5 && page > 0) {
+ // Player is near the start of the page, so search the previous page too
+ pagesToSearch.push(page - 1);
+ }
+ if (rankWithinPage > itemsPerPage - 5) {
+ // Player is near the end of the page, so search the next page too
+ pagesToSearch.push(page + 1);
+ }
+
+ // Fetch players from the determined pages
+ const players: ScoreSaberPlayerToken[] = [];
+ for (const p of pagesToSearch) {
+ const response = await variant.query(p, player.country);
+ if (response === undefined) {
+ return undefined;
+ }
+
+ players.push(...response.players);
+ }
+
+ return players;
+ },
+ refetchInterval: REFRESH_INTERVAL,
+ });
+
+ let players = data; // So we can update it later
+ if (players && (!isLoading || !isError)) {
+ // Find the player's position and show 3 players above and 1 below
+ const playerPosition = players.findIndex((p) => p.id === player.id);
+ players = players.slice(playerPosition - 3, playerPosition + 2);
+ }
+
+ return (
+
+
+ {icon}
+
{type} Ranking
+
+
+ {isLoading &&
Loading...
}
+ {isError &&
Error
}
+ {players?.map((player, index) => {
+ const rank = type == "Global" ? player.rank : player.countryRank;
+
+ return (
+
+
+
#{rank}
+
+
+
+
{player.name}
+
+
{formatPp(player.pp)}pp
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/score/score-stats.tsx b/src/components/score/score-stats.tsx
index 07c8bfe..a482662 100644
--- a/src/components/score/score-stats.tsx
+++ b/src/components/score/score-stats.tsx
@@ -1,20 +1,17 @@
import ScoreSaberLeaderboardToken from "@/common/model/token/scoresaber/score-saber-leaderboard-token";
import ScoreSaberScoreToken from "@/common/model/token/scoresaber/score-saber-score-token";
-import { formatNumberWithCommas } from "@/common/number-utils";
+import { formatNumberWithCommas, formatPp } from "@/common/number-utils";
+import { accuracyToColor } from "@/common/song-utils";
import StatValue from "@/components/stat-value";
import { XMarkIcon } from "@heroicons/react/24/solid";
import clsx from "clsx";
-import { accuracyToColor } from "@/common/song-utils";
type Badge = {
name: string;
- color?: (
- score: ScoreSaberScoreToken,
- leaderboard: ScoreSaberLeaderboardToken,
- ) => string | undefined;
+ color?: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => string | undefined;
create: (
score: ScoreSaberScoreToken,
- leaderboard: ScoreSaberLeaderboardToken,
+ leaderboard: ScoreSaberLeaderboardToken
) => string | React.ReactNode | undefined;
};
@@ -29,22 +26,16 @@ const badges: Badge[] = [
if (pp === 0) {
return undefined;
}
- return `${score.pp.toFixed(2)}pp`;
+ return `${formatPp(pp)}pp`;
},
},
{
name: "Accuracy",
- color: (
- score: ScoreSaberScoreToken,
- leaderboard: ScoreSaberLeaderboardToken,
- ) => {
+ color: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => {
const acc = (score.baseScore / leaderboard.maxScore) * 100;
return accuracyToColor(acc);
},
- create: (
- score: ScoreSaberScoreToken,
- leaderboard: ScoreSaberLeaderboardToken,
- ) => {
+ create: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => {
const acc = (score.baseScore / leaderboard.maxScore) * 100;
return `${acc.toFixed(2)}%`;
},
@@ -70,16 +61,8 @@ const badges: Badge[] = [
return (
<>
-
- {fullCombo ? (
- FC
- ) : (
- formatNumberWithCommas(score.missedNotes)
- )}
-
-
+ {fullCombo ? FC : formatNumberWithCommas(score.missedNotes)}
+
>
);
},
diff --git a/src/components/score/score.tsx b/src/components/score/score.tsx
index ccce690..47ae761 100644
--- a/src/components/score/score.tsx
+++ b/src/components/score/score.tsx
@@ -1,8 +1,8 @@
"use client";
-import { beatsaverService } from "@/common/service/impl/beatsaver";
-import ScoreSaberPlayerScoreToken from "@/common/model/token/scoresaber/score-saber-player-score-token";
import BeatSaverMap from "@/common/database/types/beatsaver-map";
+import ScoreSaberPlayerScoreToken from "@/common/model/token/scoresaber/score-saber-player-score-token";
+import { beatsaverService } from "@/common/service/impl/beatsaver";
import LeaderboardScores from "@/components/leaderboard/leaderboard-scores";
import { useCallback, useEffect, useState } from "react";
import ScoreButtons from "./score-buttons";
@@ -34,7 +34,7 @@ export default function Score({ playerScore }: Props) {
return (