add global and country ranking cards on the player page
All checks were successful
Deploy / deploy (push) Successful in 2m23s
All checks were successful
Deploy / deploy (push) Successful in 2m23s
This commit is contained in:
parent
3709794d11
commit
a7465a6dcd
@ -1,5 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Card from "@/components/card";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return <>home page</>;
|
return (
|
||||||
|
<main className="w-[1600px] h-full px-4">
|
||||||
|
<div className="flex w-full gap-2">
|
||||||
|
<Card className="w-[45%]">
|
||||||
|
<p>hello</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="w-[55%]">
|
||||||
|
<p>hello</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
|
import { formatNumberWithCommas, formatPp } from "@/common/number-utils";
|
||||||
import { scoresaberService } from "@/common/service/impl/scoresaber";
|
import { scoresaberService } from "@/common/service/impl/scoresaber";
|
||||||
import { ScoreSort } from "@/common/service/score-sort";
|
import { ScoreSort } from "@/common/service/score-sort";
|
||||||
import { formatNumberWithCommas } from "@/common/number-utils";
|
|
||||||
import PlayerData from "@/components/player/player-data";
|
import PlayerData from "@/components/player/player-data";
|
||||||
import { format } from "@formkit/tempo";
|
import { format } from "@formkit/tempo";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
@ -12,9 +12,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({ params: { slug } }: Props): Promise<Metadata> {
|
||||||
params: { slug },
|
|
||||||
}: Props): Promise<Metadata> {
|
|
||||||
const id = slug[0]; // The players id
|
const id = slug[0]; // The players id
|
||||||
const player = await scoresaberService.lookupPlayer(id, false);
|
const player = await scoresaberService.lookupPlayer(id, false);
|
||||||
if (player === undefined) {
|
if (player === undefined) {
|
||||||
@ -31,8 +29,8 @@ export async function generateMetadata({
|
|||||||
openGraph: {
|
openGraph: {
|
||||||
title: `ScoreSaber Reloaded - ${player.name}`,
|
title: `ScoreSaber Reloaded - ${player.name}`,
|
||||||
description: `
|
description: `
|
||||||
PP: ${formatNumberWithCommas(player.pp)}pp
|
PP: ${formatPp(player.pp)}pp
|
||||||
Rank: #${formatNumberWithCommas(player.rank)} (#${formatNumberWithCommas(player.countryRank)} ${player.country})
|
Rank: #${formatNumberWithCommas(player.rank)} (#${formatPp(player.countryRank)} ${player.country})
|
||||||
Joined ScoreSaber: ${format(player.firstSeen, { date: "medium", time: "short" })}
|
Joined ScoreSaber: ${format(player.firstSeen, { date: "medium", time: "short" })}
|
||||||
|
|
||||||
View the scores for ${player.name}!`,
|
View the scores for ${player.name}!`,
|
||||||
@ -58,12 +56,7 @@ export default async function Search({ params: { slug } }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full">
|
<div className="flex flex-col h-full w-full">
|
||||||
<PlayerData
|
<PlayerData initialPlayerData={player} initialScoreData={scores} sort={sort} page={page} />
|
||||||
initialPlayerData={player}
|
|
||||||
initialScoreData={scores}
|
|
||||||
sort={sort}
|
|
||||||
page={page}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -72,7 +72,7 @@ export default function RootLayout({
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<main className="flex flex-col min-h-screen gap-2 text-white">
|
<main className="flex flex-col min-h-screen gap-2 text-white">
|
||||||
<NavBar />
|
<NavBar />
|
||||||
<div className="z-[1] m-auto flex flex-col flex-grow items-center md:max-w-[1200px]">
|
<div className="z-[1] m-auto flex flex-col flex-grow items-center md:max-w-[1600px]">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
@ -8,16 +8,16 @@ export const leaderboards = {
|
|||||||
search: true,
|
search: true,
|
||||||
},
|
},
|
||||||
queries: {
|
queries: {
|
||||||
lookupScores: (
|
lookupScores: (player: ScoreSaberPlayerToken, sort: ScoreSort, page: number) =>
|
||||||
player: ScoreSaberPlayerToken,
|
|
||||||
sort: ScoreSort,
|
|
||||||
page: number,
|
|
||||||
) =>
|
|
||||||
scoresaberService.lookupPlayerScores({
|
scoresaberService.lookupPlayerScores({
|
||||||
playerId: player.id,
|
playerId: player.id,
|
||||||
sort: sort,
|
sort: sort,
|
||||||
page: page,
|
page: page,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
lookupGlobalPlayers: (page: number) => scoresaberService.lookupPlayers(page),
|
||||||
|
lookupGlobalPlayersByCountry: (page: number, country: string) =>
|
||||||
|
scoresaberService.lookupPlayersByCountry(page, country),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
|
}
|
@ -7,3 +7,13 @@
|
|||||||
export function formatNumberWithCommas(num: number) {
|
export function formatNumberWithCommas(num: number) {
|
||||||
return num.toLocaleString();
|
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 });
|
||||||
|
}
|
||||||
|
@ -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 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 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 { 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 API_BASE = "https://scoresaber.com/api";
|
||||||
const SEARCH_PLAYERS_ENDPOINT = `${API_BASE}/players?search=:query`;
|
const SEARCH_PLAYERS_ENDPOINT = `${API_BASE}/players?search=:query`;
|
||||||
const LOOKUP_PLAYER_ENDPOINT = `${API_BASE}/player/:id/full`;
|
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_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`;
|
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
|
* @param useProxy whether to use the proxy or not
|
||||||
* @returns the players that match the query, or undefined if no players were found
|
* @returns the players that match the query, or undefined if no players were found
|
||||||
*/
|
*/
|
||||||
async searchPlayers(
|
async searchPlayers(query: string, useProxy = true): Promise<ScoreSaberPlayerSearchToken | undefined> {
|
||||||
query: string,
|
|
||||||
useProxy = true,
|
|
||||||
): Promise<ScoreSaberPlayerSearchToken | undefined> {
|
|
||||||
const before = performance.now();
|
const before = performance.now();
|
||||||
this.log(`Searching for players matching "${query}"...`);
|
this.log(`Searching for players matching "${query}"...`);
|
||||||
const results = await this.fetch<ScoreSaberPlayerSearchToken>(
|
const results = await this.fetch<ScoreSaberPlayerSearchToken>(
|
||||||
useProxy,
|
useProxy,
|
||||||
SEARCH_PLAYERS_ENDPOINT.replace(":query", query),
|
SEARCH_PLAYERS_ENDPOINT.replace(":query", query)
|
||||||
);
|
);
|
||||||
if (results === undefined) {
|
if (results === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -40,9 +40,7 @@ class ScoreSaberService extends Service {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
results.players.sort((a, b) => a.rank - b.rank);
|
results.players.sort((a, b) => a.rank - b.rank);
|
||||||
this.log(
|
this.log(`Found ${results.players.length} players in ${(performance.now() - before).toFixed(0)}ms`);
|
||||||
`Found ${results.players.length} players in ${(performance.now() - before).toFixed(0)}ms`,
|
|
||||||
);
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,22 +51,61 @@ class ScoreSaberService extends Service {
|
|||||||
* @param useProxy whether to use the proxy or not
|
* @param useProxy whether to use the proxy or not
|
||||||
* @returns the player that matches the ID, or undefined
|
* @returns the player that matches the ID, or undefined
|
||||||
*/
|
*/
|
||||||
async lookupPlayer(
|
async lookupPlayer(playerId: string, useProxy = true): Promise<ScoreSaberPlayerToken | undefined> {
|
||||||
playerId: string,
|
|
||||||
useProxy = true,
|
|
||||||
): Promise<ScoreSaberPlayerToken | undefined> {
|
|
||||||
const before = performance.now();
|
const before = performance.now();
|
||||||
this.log(`Looking up player "${playerId}"...`);
|
this.log(`Looking up player "${playerId}"...`);
|
||||||
const response = await this.fetch<ScoreSaberPlayerToken>(
|
const response = await this.fetch<ScoreSaberPlayerToken>(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<ScoreSaberPlayersPageToken | undefined> {
|
||||||
|
const before = performance.now();
|
||||||
|
this.log(`Looking up players on page "${page}"...`);
|
||||||
|
const response = await this.fetch<ScoreSaberPlayersPageToken>(
|
||||||
useProxy,
|
useProxy,
|
||||||
LOOKUP_PLAYER_ENDPOINT.replace(":id", playerId),
|
LOOKUP_PLAYERS_ENDPOINT.replace(":page", page.toString())
|
||||||
);
|
);
|
||||||
if (response === undefined) {
|
if (response === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
this.log(
|
this.log(`Found ${response.players.length} players in ${(performance.now() - before).toFixed(0)}ms`);
|
||||||
`Found player "${playerId}" 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<ScoreSaberPlayersPageToken | undefined> {
|
||||||
|
const before = performance.now();
|
||||||
|
this.log(`Looking up players on page "${page}" for country "${country}"...`);
|
||||||
|
const response = await this.fetch<ScoreSaberPlayersPageToken>(
|
||||||
|
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;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,20 +134,24 @@ class ScoreSaberService extends Service {
|
|||||||
}): Promise<ScoreSaberPlayerScoresPageToken | undefined> {
|
}): Promise<ScoreSaberPlayerScoresPageToken | undefined> {
|
||||||
const before = performance.now();
|
const before = performance.now();
|
||||||
this.log(
|
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<ScoreSaberPlayerScoresPageToken>(
|
const response = await this.fetch<ScoreSaberPlayerScoresPageToken>(
|
||||||
useProxy,
|
useProxy,
|
||||||
LOOKUP_PLAYER_SCORES_ENDPOINT.replace(":id", playerId)
|
LOOKUP_PLAYER_SCORES_ENDPOINT.replace(":id", playerId)
|
||||||
.replace(":limit", 8 + "")
|
.replace(":limit", 8 + "")
|
||||||
.replace(":sort", sort)
|
.replace(":sort", sort)
|
||||||
.replace(":page", page + "") + (search ? `&search=${search}` : ""),
|
.replace(":page", page + "") + (search ? `&search=${search}` : "")
|
||||||
);
|
);
|
||||||
if (response === undefined) {
|
if (response === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
this.log(
|
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;
|
return response;
|
||||||
}
|
}
|
||||||
@ -127,24 +168,21 @@ class ScoreSaberService extends Service {
|
|||||||
async lookupLeaderboardScores(
|
async lookupLeaderboardScores(
|
||||||
leaderboardId: string,
|
leaderboardId: string,
|
||||||
page: number,
|
page: number,
|
||||||
useProxy = true,
|
useProxy = true
|
||||||
): Promise<ScoreSaberLeaderboardScoresPageToken | undefined> {
|
): Promise<ScoreSaberLeaderboardScoresPageToken | undefined> {
|
||||||
const before = performance.now();
|
const before = performance.now();
|
||||||
this.log(
|
this.log(`Looking up scores for leaderboard "${leaderboardId}", page "${page}"...`);
|
||||||
`Looking up scores for leaderboard "${leaderboardId}", page "${page}"...`,
|
|
||||||
);
|
|
||||||
const response = await this.fetch<ScoreSaberLeaderboardScoresPageToken>(
|
const response = await this.fetch<ScoreSaberLeaderboardScoresPageToken>(
|
||||||
useProxy,
|
useProxy,
|
||||||
LOOKUP_LEADERBOARD_SCORES_ENDPOINT.replace(":id", leaderboardId).replace(
|
LOOKUP_LEADERBOARD_SCORES_ENDPOINT.replace(":id", leaderboardId).replace(":page", page.toString())
|
||||||
":page",
|
|
||||||
page.toString(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
if (response === undefined) {
|
if (response === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
this.log(
|
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;
|
return response;
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,17 @@
|
|||||||
type Props = {
|
type Props = {
|
||||||
country: string;
|
code: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CountryFlag({ country, size = 24 }: Props) {
|
export default function CountryFlag({ code, size = 24 }: Props) {
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
alt="Player Country"
|
alt="Player Country"
|
||||||
src={`/assets/flags/${country}.png`}
|
src={`/assets/flags/${code}.png`}
|
||||||
width={size * 2}
|
width={size * 2}
|
||||||
height={size}
|
height={size}
|
||||||
|
className={`w-[${size * 2}px] h-[${size}px] object-contain`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"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 { scoresaberService } from "@/common/service/impl/scoresaber";
|
||||||
import { ScoreSort } from "@/common/service/score-sort";
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
|
import Mini from "../ranking/mini";
|
||||||
import PlayerHeader from "./player-header";
|
import PlayerHeader from "./player-header";
|
||||||
import PlayerRankChart from "./player-rank-chart";
|
|
||||||
import PlayerScores from "./player-scores";
|
import PlayerScores from "./player-scores";
|
||||||
|
|
||||||
const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||||
@ -18,12 +18,7 @@ type Props = {
|
|||||||
page: number;
|
page: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PlayerData({
|
export default function PlayerData({ initialPlayerData: initalPlayerData, initialScoreData, sort, page }: Props) {
|
||||||
initialPlayerData: initalPlayerData,
|
|
||||||
initialScoreData,
|
|
||||||
sort,
|
|
||||||
page,
|
|
||||||
}: Props) {
|
|
||||||
let player = initalPlayerData;
|
let player = initalPlayerData;
|
||||||
const { data, isLoading, isError } = useQuery({
|
const { data, isLoading, isError } = useQuery({
|
||||||
queryKey: ["player", player.id],
|
queryKey: ["player", player.id],
|
||||||
@ -36,19 +31,16 @@ export default function PlayerData({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex gap-2">
|
||||||
|
<article className="flex flex-col gap-2">
|
||||||
<PlayerHeader player={player} />
|
<PlayerHeader player={player} />
|
||||||
{!player.inactive && (
|
{!player.inactive && <>{/* <PlayerRankChart player={player} /> */}</>}
|
||||||
<>
|
<PlayerScores initialScoreData={initialScoreData} player={player} sort={sort} page={page} />
|
||||||
<PlayerRankChart player={player} />
|
</article>
|
||||||
</>
|
<aside className="w-[500px] hidden xl:flex flex-col gap-2">
|
||||||
)}
|
<Mini type="Global" player={player} />
|
||||||
<PlayerScores
|
<Mini type="Country" player={player} />
|
||||||
initialScoreData={initialScoreData}
|
</aside>
|
||||||
player={player}
|
|
||||||
sort={sort}
|
|
||||||
page={page}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token";
|
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 { GlobeAmericasIcon } from "@heroicons/react/24/solid";
|
||||||
import Card from "../card";
|
import Card from "../card";
|
||||||
import CountryFlag from "../country-flag";
|
import CountryFlag from "../country-flag";
|
||||||
@ -20,7 +20,7 @@ const playerData = [
|
|||||||
{
|
{
|
||||||
showWhenInactiveOrBanned: false,
|
showWhenInactiveOrBanned: false,
|
||||||
icon: (player: ScoreSaberPlayerToken) => {
|
icon: (player: ScoreSaberPlayerToken) => {
|
||||||
return <CountryFlag country={player.country.toLowerCase()} size={15} />;
|
return <CountryFlag code={player.country.toLowerCase()} size={15} />;
|
||||||
},
|
},
|
||||||
render: (player: ScoreSaberPlayerToken) => {
|
render: (player: ScoreSaberPlayerToken) => {
|
||||||
return <p>#{formatNumberWithCommas(player.countryRank)}</p>;
|
return <p>#{formatNumberWithCommas(player.countryRank)}</p>;
|
||||||
@ -29,7 +29,7 @@ const playerData = [
|
|||||||
{
|
{
|
||||||
showWhenInactiveOrBanned: true,
|
showWhenInactiveOrBanned: true,
|
||||||
render: (player: ScoreSaberPlayerToken) => {
|
render: (player: ScoreSaberPlayerToken) => {
|
||||||
return <p className="text-pp">{formatNumberWithCommas(player.pp)}pp</p>;
|
return <p className="text-pp">{formatPp(player.pp)}pp</p>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -50,20 +50,13 @@ export default function PlayerHeader({ player }: Props) {
|
|||||||
<p className="font-bold text-2xl">{player.name}</p>
|
<p className="font-bold text-2xl">{player.name}</p>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div>
|
<div>
|
||||||
{player.inactive && (
|
{player.inactive && <p className="text-gray-400">Inactive Account</p>}
|
||||||
<p className="text-gray-400">Inactive Account</p>
|
{player.banned && <p className="text-red-500">Banned Account</p>}
|
||||||
)}
|
|
||||||
{player.banned && (
|
|
||||||
<p className="text-red-500">Banned Account</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{playerData.map((subName, index) => {
|
{playerData.map((subName, index) => {
|
||||||
// Check if the player is inactive or banned and if the data should be shown
|
// Check if the player is inactive or banned and if the data should be shown
|
||||||
if (
|
if (!subName.showWhenInactiveOrBanned && (player.inactive || player.banned)) {
|
||||||
!subName.showWhenInactiveOrBanned &&
|
|
||||||
(player.inactive || player.banned)
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
131
src/components/ranking/mini.tsx
Normal file
131
src/components/ranking/mini.tsx
Normal file
@ -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<ScoreSaberPlayersPageToken | undefined>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const miniVariants: Variants = {
|
||||||
|
Global: {
|
||||||
|
itemsPerPage: 50,
|
||||||
|
icon: () => <GlobeAmericasIcon className="w-6 h-6" />,
|
||||||
|
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 <CountryFlag code={player.country} size={12} />;
|
||||||
|
},
|
||||||
|
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 (
|
||||||
|
<Card className="w-full flex gap-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{icon}
|
||||||
|
<p>{type} Ranking</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{isLoading && <p className="text-gray-400">Loading...</p>}
|
||||||
|
{isError && <p className="text-red-500">Error</p>}
|
||||||
|
{players?.map((player, index) => {
|
||||||
|
const rank = type == "Global" ? player.rank : player.countryRank;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={index}
|
||||||
|
href={`/player/${player.id}`}
|
||||||
|
className="flex justify-between gap-2 bg-accent px-2 py-1.5 cursor-pointer transform-gpu transition-all hover:brightness-75 first:rounded-t last:rounded-b"
|
||||||
|
>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<p className="text-gray-400">#{rank}</p>
|
||||||
|
<Avatar className="w-6 h-6 pointer-events-none">
|
||||||
|
<AvatarImage alt="Profile Picture" src={player.profilePicture} />
|
||||||
|
</Avatar>
|
||||||
|
<p>{player.name}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-pp">{formatPp(player.pp)}pp</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
@ -1,20 +1,17 @@
|
|||||||
import ScoreSaberLeaderboardToken from "@/common/model/token/scoresaber/score-saber-leaderboard-token";
|
import ScoreSaberLeaderboardToken from "@/common/model/token/scoresaber/score-saber-leaderboard-token";
|
||||||
import ScoreSaberScoreToken from "@/common/model/token/scoresaber/score-saber-score-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 StatValue from "@/components/stat-value";
|
||||||
import { XMarkIcon } from "@heroicons/react/24/solid";
|
import { XMarkIcon } from "@heroicons/react/24/solid";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { accuracyToColor } from "@/common/song-utils";
|
|
||||||
|
|
||||||
type Badge = {
|
type Badge = {
|
||||||
name: string;
|
name: string;
|
||||||
color?: (
|
color?: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => string | undefined;
|
||||||
score: ScoreSaberScoreToken,
|
|
||||||
leaderboard: ScoreSaberLeaderboardToken,
|
|
||||||
) => string | undefined;
|
|
||||||
create: (
|
create: (
|
||||||
score: ScoreSaberScoreToken,
|
score: ScoreSaberScoreToken,
|
||||||
leaderboard: ScoreSaberLeaderboardToken,
|
leaderboard: ScoreSaberLeaderboardToken
|
||||||
) => string | React.ReactNode | undefined;
|
) => string | React.ReactNode | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -29,22 +26,16 @@ const badges: Badge[] = [
|
|||||||
if (pp === 0) {
|
if (pp === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return `${score.pp.toFixed(2)}pp`;
|
return `${formatPp(pp)}pp`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Accuracy",
|
name: "Accuracy",
|
||||||
color: (
|
color: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => {
|
||||||
score: ScoreSaberScoreToken,
|
|
||||||
leaderboard: ScoreSaberLeaderboardToken,
|
|
||||||
) => {
|
|
||||||
const acc = (score.baseScore / leaderboard.maxScore) * 100;
|
const acc = (score.baseScore / leaderboard.maxScore) * 100;
|
||||||
return accuracyToColor(acc);
|
return accuracyToColor(acc);
|
||||||
},
|
},
|
||||||
create: (
|
create: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => {
|
||||||
score: ScoreSaberScoreToken,
|
|
||||||
leaderboard: ScoreSaberLeaderboardToken,
|
|
||||||
) => {
|
|
||||||
const acc = (score.baseScore / leaderboard.maxScore) * 100;
|
const acc = (score.baseScore / leaderboard.maxScore) * 100;
|
||||||
return `${acc.toFixed(2)}%`;
|
return `${acc.toFixed(2)}%`;
|
||||||
},
|
},
|
||||||
@ -70,16 +61,8 @@ const badges: Badge[] = [
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>{fullCombo ? <span className="text-green-400">FC</span> : formatNumberWithCommas(score.missedNotes)}</p>
|
||||||
{fullCombo ? (
|
<XMarkIcon className={clsx("w-5 h-5", fullCombo ? "hidden" : "text-red-400")} />
|
||||||
<span className="text-green-400">FC</span>
|
|
||||||
) : (
|
|
||||||
formatNumberWithCommas(score.missedNotes)
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<XMarkIcon
|
|
||||||
className={clsx("w-5 h-5", fullCombo ? "hidden" : "text-red-400")}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"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 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 LeaderboardScores from "@/components/leaderboard/leaderboard-scores";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import ScoreButtons from "./score-buttons";
|
import ScoreButtons from "./score-buttons";
|
||||||
@ -34,7 +34,7 @@ export default function Score({ playerScore }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="pb-2 pt-2">
|
<div className="pb-2 pt-2">
|
||||||
<div
|
<div
|
||||||
className={`grid w-full gap-2 lg:gap-0 first:pt-0 last:pb-0 grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.85fr_4fr_1fr_300px]`}
|
className={`grid w-full gap-2 lg:gap-0 first:pt-0 last:pb-0 grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_1fr_300px]`}
|
||||||
>
|
>
|
||||||
<ScoreRankInfo score={score} />
|
<ScoreRankInfo score={score} />
|
||||||
<ScoreSongInfo leaderboard={leaderboard} beatSaverMap={beatSaverMap} />
|
<ScoreSongInfo leaderboard={leaderboard} beatSaverMap={beatSaverMap} />
|
||||||
|
Reference in New Issue
Block a user