This commit is contained in:
parent
d9b68f0c65
commit
1fa20b6e52
@ -11,6 +11,16 @@ const nextConfig = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
webpackMemoryOptimizations: true,
|
webpackMemoryOptimizations: true,
|
||||||
},
|
},
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'cdn.scoresaber.com',
|
||||||
|
port: '',
|
||||||
|
pathname: '/**',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
env: {
|
env: {
|
||||||
NEXT_PUBLIC_BUILD_ID: process.env.GIT_REV || nextBuildId.sync({ dir: __dirname }),
|
NEXT_PUBLIC_BUILD_ID: process.env.GIT_REV || nextBuildId.sync({ dir: __dirname }),
|
||||||
NEXT_PUBLIC_BUILD_TIME: new Date().toLocaleDateString("en-US", {
|
NEXT_PUBLIC_BUILD_TIME: new Date().toLocaleDateString("en-US", {
|
||||||
|
@ -32,7 +32,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||||||
description: `
|
description: `
|
||||||
PP: ${formatPp(player.pp)}pp
|
PP: ${formatPp(player.pp)}pp
|
||||||
Rank: #${formatNumberWithCommas(player.rank)} (#${formatPp(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.joinedDate, { date: "medium", time: "short" })}
|
||||||
|
|
||||||
View the scores for ${player.name}!`,
|
View the scores for ${player.name}!`,
|
||||||
},
|
},
|
||||||
@ -58,7 +58,12 @@ export default async function Search({ params }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full">
|
<div className="flex flex-col h-full w-full">
|
||||||
<PlayerData initialPlayerData={player} initialScoreData={scores} sort={sort} page={page} />
|
<PlayerData
|
||||||
|
initialPlayerData={player}
|
||||||
|
initialScoreData={scores}
|
||||||
|
sort={sort}
|
||||||
|
page={page}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,7 @@ import DatabaseLoader from "../components/loaders/database-loader";
|
|||||||
import NavBar from "../components/navbar/navbar";
|
import NavBar from "../components/navbar/navbar";
|
||||||
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const siteFont = localFont({
|
const siteFont = localFont({
|
||||||
src: "./fonts/JetBrainsMono-Regular.woff2",
|
src: "./fonts/JetBrainsMono-Regular.woff2",
|
||||||
weight: "100 900",
|
weight: "100 900",
|
||||||
@ -46,12 +47,14 @@ export const metadata: Metadata = {
|
|||||||
"Stream enhancement, Professional overlay, Easy to use overlay builder.",
|
"Stream enhancement, Professional overlay, Easy to use overlay builder.",
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "Scoresaber Reloaded",
|
title: "Scoresaber Reloaded",
|
||||||
description: "Scoresaber Reloaded is a new way to view your scores and get more stats about your and your plays",
|
description:
|
||||||
|
"Scoresaber Reloaded is a new way to view your scores and get more stats about your and your plays",
|
||||||
url: "https://ssr.fascinated.cc",
|
url: "https://ssr.fascinated.cc",
|
||||||
locale: "en_US",
|
locale: "en_US",
|
||||||
type: "website",
|
type: "website",
|
||||||
},
|
},
|
||||||
description: "Scoresaber Reloaded is a new way to view your scores and get more stats about your and your plays",
|
description:
|
||||||
|
"Scoresaber Reloaded is a new way to view your scores and get more stats about your and your plays",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@ -61,13 +64,20 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`${siteFont.className} antialiased w-full h-full relative`}>
|
<body
|
||||||
|
className={`${siteFont.className} antialiased w-full h-full relative`}
|
||||||
|
>
|
||||||
<DatabaseLoader>
|
<DatabaseLoader>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<BackgroundImage />
|
<BackgroundImage />
|
||||||
<PreloadResources />
|
<PreloadResources />
|
||||||
<TooltipProvider delayDuration={100}>
|
<TooltipProvider delayDuration={100}>
|
||||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="dark"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<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">
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
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 ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
|
||||||
|
|
||||||
export const leaderboards = {
|
export const leaderboards = {
|
||||||
ScoreSaber: {
|
ScoreSaber: {
|
||||||
@ -8,14 +8,15 @@ export const leaderboards = {
|
|||||||
search: true,
|
search: true,
|
||||||
},
|
},
|
||||||
queries: {
|
queries: {
|
||||||
lookupScores: (player: ScoreSaberPlayerToken, sort: ScoreSort, page: number) =>
|
lookupScores: (player: ScoreSaberPlayer, 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),
|
lookupGlobalPlayers: (page: number) =>
|
||||||
|
scoresaberService.lookupPlayers(page),
|
||||||
lookupGlobalPlayersByCountry: (page: number, country: string) =>
|
lookupGlobalPlayersByCountry: (page: number, country: string) =>
|
||||||
scoresaberService.lookupPlayersByCountry(page, country),
|
scoresaberService.lookupPlayersByCountry(page, country),
|
||||||
},
|
},
|
||||||
|
159
src/common/model/player/impl/scoresaber-player.ts
Normal file
159
src/common/model/player/impl/scoresaber-player.ts
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import Player from "../player";
|
||||||
|
import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A ScoreSaber player.
|
||||||
|
*/
|
||||||
|
export default interface ScoreSaberPlayer extends Player {
|
||||||
|
/**
|
||||||
|
* The bio of the player.
|
||||||
|
*/
|
||||||
|
bio: ScoreSaberBio;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of pp the player has.
|
||||||
|
*/
|
||||||
|
pp: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The role the player has.
|
||||||
|
*/
|
||||||
|
role: ScoreSaberRole | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The badges the player has.
|
||||||
|
*/
|
||||||
|
badges: ScoreSaberBadge[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rank history for this player.
|
||||||
|
*/
|
||||||
|
rankHistory: number[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The statistics for this player.
|
||||||
|
*/
|
||||||
|
statistics: ScoreSaberPlayerStatistics;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The permissions the player has.
|
||||||
|
*/
|
||||||
|
permissions: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the player is banned or not.
|
||||||
|
*/
|
||||||
|
banned: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the player is inactive or not.
|
||||||
|
*/
|
||||||
|
inactive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScoreSaberPlayerFromToken(
|
||||||
|
token: ScoreSaberPlayerToken,
|
||||||
|
): ScoreSaberPlayer {
|
||||||
|
const bio: ScoreSaberBio = {
|
||||||
|
lines: token.bio?.split("\n") || [],
|
||||||
|
linesStripped: token.bio?.replace(/<[^>]+>/g, "")?.split("\n") || [],
|
||||||
|
};
|
||||||
|
const role = token.role == null ? undefined : (token.role as ScoreSaberRole);
|
||||||
|
const badges: ScoreSaberBadge[] =
|
||||||
|
token.badges?.map((badge) => {
|
||||||
|
return {
|
||||||
|
url: badge.image,
|
||||||
|
description: badge.description,
|
||||||
|
};
|
||||||
|
}) || [];
|
||||||
|
const rankHistory = token.histories.split(",").map((rank) => Number(rank));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: token.id,
|
||||||
|
name: token.name,
|
||||||
|
avatar: token.profilePicture,
|
||||||
|
country: token.country,
|
||||||
|
rank: token.rank,
|
||||||
|
countryRank: token.countryRank,
|
||||||
|
joinedDate: new Date(token.firstSeen),
|
||||||
|
bio: bio,
|
||||||
|
pp: token.pp,
|
||||||
|
role: role,
|
||||||
|
badges: badges,
|
||||||
|
rankHistory: rankHistory,
|
||||||
|
statistics: token.scoreStats,
|
||||||
|
permissions: token.permissions,
|
||||||
|
banned: token.banned,
|
||||||
|
inactive: token.inactive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bio of a player.
|
||||||
|
*/
|
||||||
|
export type ScoreSaberBio = {
|
||||||
|
/**
|
||||||
|
* The lines of the bio including any html tags.
|
||||||
|
*/
|
||||||
|
lines: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lines of the bio stripped of all html tags.
|
||||||
|
*/
|
||||||
|
linesStripped: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ScoreSaber account roles.
|
||||||
|
*/
|
||||||
|
export type ScoreSaberRole = "Admin";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A badge for a player.
|
||||||
|
*/
|
||||||
|
export type ScoreSaberBadge = {
|
||||||
|
/**
|
||||||
|
* The URL to the badge.
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The description of the badge.
|
||||||
|
*/
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The statistics for a player.
|
||||||
|
*/
|
||||||
|
export type ScoreSaberPlayerStatistics = {
|
||||||
|
/**
|
||||||
|
* The total amount of score accumulated over all scores.
|
||||||
|
*/
|
||||||
|
totalScore: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total amount of ranked score accumulated over all scores.
|
||||||
|
*/
|
||||||
|
totalRankedScore: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The average ranked accuracy for all ranked scores.
|
||||||
|
*/
|
||||||
|
averageRankedAccuracy: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total amount of scores set.
|
||||||
|
*/
|
||||||
|
totalPlayCount: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total amount of ranked score set.
|
||||||
|
*/
|
||||||
|
rankedPlayCount: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of times their replays were watched.
|
||||||
|
*/
|
||||||
|
replaysWatched: number;
|
||||||
|
};
|
54
src/common/model/player/player.ts
Normal file
54
src/common/model/player/player.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
export default class Player {
|
||||||
|
/**
|
||||||
|
* The ID of this player.
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of this player.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The avatar url for this player.
|
||||||
|
*/
|
||||||
|
avatar: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The country of this player.
|
||||||
|
*/
|
||||||
|
country: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rank of the player.
|
||||||
|
*/
|
||||||
|
rank: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rank the player has in their country.
|
||||||
|
*/
|
||||||
|
countryRank: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date the player joined the playform.
|
||||||
|
*/
|
||||||
|
joinedDate: Date;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
avatar: string,
|
||||||
|
country: string,
|
||||||
|
rank: number,
|
||||||
|
countryRank: number,
|
||||||
|
joinedDate: Date
|
||||||
|
) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.avatar = avatar;
|
||||||
|
this.country = country;
|
||||||
|
this.rank = rank;
|
||||||
|
this.countryRank = countryRank;
|
||||||
|
this.joinedDate = joinedDate;
|
||||||
|
}
|
||||||
|
}
|
@ -5,6 +5,9 @@ import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-p
|
|||||||
import { ScoreSaberPlayersPageToken } from "@/common/model/token/scoresaber/score-saber-players-page-token";
|
import { ScoreSaberPlayersPageToken } from "@/common/model/token/scoresaber/score-saber-players-page-token";
|
||||||
import { ScoreSort } from "../score-sort";
|
import { ScoreSort } from "../score-sort";
|
||||||
import Service from "../service";
|
import Service from "../service";
|
||||||
|
import ScoreSaberPlayer, {
|
||||||
|
getScoreSaberPlayerFromToken,
|
||||||
|
} from "@/common/model/player/impl/scoresaber-player";
|
||||||
|
|
||||||
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`;
|
||||||
@ -26,12 +29,15 @@ 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(query: string, useProxy = true): Promise<ScoreSaberPlayerSearchToken | undefined> {
|
async searchPlayers(
|
||||||
|
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,7 +46,9 @@ 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(`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;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,15 +59,23 @@ 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(playerId: string, useProxy = true): Promise<ScoreSaberPlayerToken | undefined> {
|
async lookupPlayer(
|
||||||
|
playerId: string,
|
||||||
|
useProxy = true,
|
||||||
|
): Promise<ScoreSaberPlayer | 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>(useProxy, LOOKUP_PLAYER_ENDPOINT.replace(":id", playerId));
|
const token = await this.fetch<ScoreSaberPlayerToken>(
|
||||||
if (response === undefined) {
|
useProxy,
|
||||||
|
LOOKUP_PLAYER_ENDPOINT.replace(":id", playerId),
|
||||||
|
);
|
||||||
|
if (token === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
this.log(`Found player "${playerId}" in ${(performance.now() - before).toFixed(0)}ms`);
|
this.log(
|
||||||
return response;
|
`Found player "${playerId}" in ${(performance.now() - before).toFixed(0)}ms`,
|
||||||
|
);
|
||||||
|
return getScoreSaberPlayerFromToken(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -69,17 +85,22 @@ 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 on the page, or undefined
|
* @returns the players on the page, or undefined
|
||||||
*/
|
*/
|
||||||
async lookupPlayers(page: number, useProxy = true): Promise<ScoreSaberPlayersPageToken | undefined> {
|
async lookupPlayers(
|
||||||
|
page: number,
|
||||||
|
useProxy = true,
|
||||||
|
): Promise<ScoreSaberPlayersPageToken | undefined> {
|
||||||
const before = performance.now();
|
const before = performance.now();
|
||||||
this.log(`Looking up players on page "${page}"...`);
|
this.log(`Looking up players on page "${page}"...`);
|
||||||
const response = await this.fetch<ScoreSaberPlayersPageToken>(
|
const response = await this.fetch<ScoreSaberPlayersPageToken>(
|
||||||
useProxy,
|
useProxy,
|
||||||
LOOKUP_PLAYERS_ENDPOINT.replace(":page", page.toString())
|
LOOKUP_PLAYERS_ENDPOINT.replace(":page", page.toString()),
|
||||||
);
|
);
|
||||||
if (response === undefined) {
|
if (response === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
this.log(`Found ${response.players.length} players in ${(performance.now() - before).toFixed(0)}ms`);
|
this.log(
|
||||||
|
`Found ${response.players.length} players in ${(performance.now() - before).toFixed(0)}ms`,
|
||||||
|
);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,18 +115,25 @@ class ScoreSaberService extends Service {
|
|||||||
async lookupPlayersByCountry(
|
async lookupPlayersByCountry(
|
||||||
page: number,
|
page: number,
|
||||||
country: string,
|
country: string,
|
||||||
useProxy = true
|
useProxy = true,
|
||||||
): Promise<ScoreSaberPlayersPageToken | undefined> {
|
): Promise<ScoreSaberPlayersPageToken | undefined> {
|
||||||
const before = performance.now();
|
const before = performance.now();
|
||||||
this.log(`Looking up players on page "${page}" for country "${country}"...`);
|
this.log(
|
||||||
|
`Looking up players on page "${page}" for country "${country}"...`,
|
||||||
|
);
|
||||||
const response = await this.fetch<ScoreSaberPlayersPageToken>(
|
const response = await this.fetch<ScoreSaberPlayersPageToken>(
|
||||||
useProxy,
|
useProxy,
|
||||||
LOOKUP_PLAYERS_BY_COUNTRY_ENDPOINT.replace(":page", page.toString()).replace(":country", country)
|
LOOKUP_PLAYERS_BY_COUNTRY_ENDPOINT.replace(
|
||||||
|
":page",
|
||||||
|
page.toString(),
|
||||||
|
).replace(":country", country),
|
||||||
);
|
);
|
||||||
if (response === undefined) {
|
if (response === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
this.log(`Found ${response.players.length} players in ${(performance.now() - before).toFixed(0)}ms`);
|
this.log(
|
||||||
|
`Found ${response.players.length} players in ${(performance.now() - before).toFixed(0)}ms`,
|
||||||
|
);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,22 +164,22 @@ class ScoreSaberService extends Service {
|
|||||||
this.log(
|
this.log(
|
||||||
`Looking up scores for player "${playerId}", sort "${sort}", page "${page}"${
|
`Looking up scores for player "${playerId}", sort "${sort}", page "${page}"${
|
||||||
search ? `, search "${search}"` : ""
|
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(
|
`Found ${response.playerScores.length} scores for player "${playerId}" in ${(
|
||||||
0
|
performance.now() - before
|
||||||
)}ms`
|
).toFixed(0)}ms`,
|
||||||
);
|
);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@ -168,13 +196,18 @@ 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(`Looking up scores for leaderboard "${leaderboardId}", page "${page}"...`);
|
this.log(
|
||||||
|
`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(":page", page.toString())
|
LOOKUP_LEADERBOARD_SCORES_ENDPOINT.replace(":id", leaderboardId).replace(
|
||||||
|
":page",
|
||||||
|
page.toString(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (response === undefined) {
|
if (response === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -182,7 +215,7 @@ class ScoreSaberService extends Service {
|
|||||||
this.log(
|
this.log(
|
||||||
`Found ${response.scores.length} scores for leaderboard "${leaderboardId}" in ${(
|
`Found ${response.scores.length} scores for leaderboard "${leaderboardId}" in ${(
|
||||||
performance.now() - before
|
performance.now() - before
|
||||||
).toFixed(0)}ms`
|
).toFixed(0)}ms`,
|
||||||
);
|
);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
@ -19,10 +19,16 @@ export default function ProfileButton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/player/${settings.playerId}`} className="flex items-center gap-2 h-full">
|
<Link
|
||||||
|
href={`/player/${settings.playerId}`}
|
||||||
|
className="flex items-center gap-2 h-full"
|
||||||
|
>
|
||||||
<NavbarButton>
|
<NavbarButton>
|
||||||
<Avatar className="w-6 h-6">
|
<Avatar className="w-6 h-6">
|
||||||
<AvatarImage alt="Profile Picture" src={`https://cdn.scoresaber.com/avatars/${settings.playerId}.jpg`} />
|
<AvatarImage
|
||||||
|
alt="Profile Picture"
|
||||||
|
src={`https://cdn.scoresaber.com/avatars/${settings.playerId}.jpg`}
|
||||||
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<p>You</p>
|
<p>You</p>
|
||||||
</NavbarButton>
|
</NavbarButton>
|
||||||
|
32
src/components/player/player-badges.tsx
Normal file
32
src/components/player/player-badges.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Tooltip from "@/components/tooltip";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
player: ScoreSaberPlayer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PlayerBadges({ player }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2 w-full items-center justify-center">
|
||||||
|
{player.badges?.map((badge, index) => {
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
side={"bottom"}
|
||||||
|
key={index}
|
||||||
|
display={<p>{badge.description}</p>}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Image
|
||||||
|
src={badge.url}
|
||||||
|
alt={badge.description}
|
||||||
|
width={80}
|
||||||
|
height={30}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
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 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 { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@ -9,17 +8,25 @@ import Mini from "../ranking/mini";
|
|||||||
import PlayerHeader from "./player-header";
|
import PlayerHeader from "./player-header";
|
||||||
import PlayerRankChart from "./player-rank-chart";
|
import PlayerRankChart from "./player-rank-chart";
|
||||||
import PlayerScores from "./player-scores";
|
import PlayerScores from "./player-scores";
|
||||||
|
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
|
||||||
|
import Card from "@/components/card";
|
||||||
|
import PlayerBadges from "@/components/player/player-badges";
|
||||||
|
|
||||||
const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
initialPlayerData: ScoreSaberPlayerToken;
|
initialPlayerData: ScoreSaberPlayer;
|
||||||
initialScoreData?: ScoreSaberPlayerScoresPageToken;
|
initialScoreData?: ScoreSaberPlayerScoresPageToken;
|
||||||
sort: ScoreSort;
|
sort: ScoreSort;
|
||||||
page: number;
|
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;
|
let player = initalPlayerData;
|
||||||
const { data, isLoading, isError } = useQuery({
|
const { data, isLoading, isError } = useQuery({
|
||||||
queryKey: ["player", player.id],
|
queryKey: ["player", player.id],
|
||||||
@ -36,11 +43,17 @@ export default function PlayerData({ initialPlayerData: initalPlayerData, initia
|
|||||||
<article className="flex flex-col gap-2">
|
<article className="flex flex-col gap-2">
|
||||||
<PlayerHeader player={player} />
|
<PlayerHeader player={player} />
|
||||||
{!player.inactive && (
|
{!player.inactive && (
|
||||||
<>
|
<Card className="gap-1">
|
||||||
|
<PlayerBadges player={player} />
|
||||||
<PlayerRankChart player={player} />
|
<PlayerRankChart player={player} />
|
||||||
</>
|
</Card>
|
||||||
)}
|
)}
|
||||||
<PlayerScores initialScoreData={initialScoreData} player={player} sort={sort} page={page} />
|
<PlayerScores
|
||||||
|
initialScoreData={initialScoreData}
|
||||||
|
player={player}
|
||||||
|
sort={sort}
|
||||||
|
page={page}
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
<aside className="w-[550px] hidden xl:flex flex-col gap-2">
|
<aside className="w-[550px] hidden xl:flex flex-col gap-2">
|
||||||
<Mini type="Global" player={player} />
|
<Mini type="Global" player={player} />
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token";
|
|
||||||
import { formatNumberWithCommas, formatPp } 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";
|
||||||
@ -6,6 +5,7 @@ import CountryFlag from "../country-flag";
|
|||||||
import { Avatar, AvatarImage } from "../ui/avatar";
|
import { Avatar, AvatarImage } from "../ui/avatar";
|
||||||
import ClaimProfile from "./claim-profile";
|
import ClaimProfile from "./claim-profile";
|
||||||
import PlayerStats from "./player-stats";
|
import PlayerStats from "./player-stats";
|
||||||
|
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
|
||||||
|
|
||||||
const playerData = [
|
const playerData = [
|
||||||
{
|
{
|
||||||
@ -13,29 +13,29 @@ const playerData = [
|
|||||||
icon: () => {
|
icon: () => {
|
||||||
return <GlobeAmericasIcon className="h-5 w-5" />;
|
return <GlobeAmericasIcon className="h-5 w-5" />;
|
||||||
},
|
},
|
||||||
render: (player: ScoreSaberPlayerToken) => {
|
render: (player: ScoreSaberPlayer) => {
|
||||||
return <p>#{formatNumberWithCommas(player.rank)}</p>;
|
return <p>#{formatNumberWithCommas(player.rank)}</p>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
showWhenInactiveOrBanned: false,
|
showWhenInactiveOrBanned: false,
|
||||||
icon: (player: ScoreSaberPlayerToken) => {
|
icon: (player: ScoreSaberPlayer) => {
|
||||||
return <CountryFlag code={player.country} size={15} />;
|
return <CountryFlag code={player.country} size={15} />;
|
||||||
},
|
},
|
||||||
render: (player: ScoreSaberPlayerToken) => {
|
render: (player: ScoreSaberPlayer) => {
|
||||||
return <p>#{formatNumberWithCommas(player.countryRank)}</p>;
|
return <p>#{formatNumberWithCommas(player.countryRank)}</p>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
showWhenInactiveOrBanned: true,
|
showWhenInactiveOrBanned: true,
|
||||||
render: (player: ScoreSaberPlayerToken) => {
|
render: (player: ScoreSaberPlayer) => {
|
||||||
return <p className="text-pp">{formatPp(player.pp)}pp</p>;
|
return <p className="text-pp">{formatPp(player.pp)}pp</p>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
player: ScoreSaberPlayerToken;
|
player: ScoreSaberPlayer;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PlayerHeader({ player }: Props) {
|
export default function PlayerHeader({ player }: Props) {
|
||||||
@ -43,20 +43,27 @@ export default function PlayerHeader({ player }: Props) {
|
|||||||
<Card>
|
<Card>
|
||||||
<div className="flex gap-3 flex-col items-center text-center lg:flex-row lg:items-start lg:text-start relative select-none">
|
<div className="flex gap-3 flex-col items-center text-center lg:flex-row lg:items-start lg:text-start relative select-none">
|
||||||
<Avatar className="w-32 h-32 pointer-events-none">
|
<Avatar className="w-32 h-32 pointer-events-none">
|
||||||
<AvatarImage alt="Profile Picture" src={player.profilePicture} />
|
<AvatarImage alt="Profile Picture" src={player.avatar} />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="w-full flex gap-2 flex-col justify-center items-center lg:justify-start lg:items-start">
|
<div className="w-full flex gap-2 flex-col justify-center items-center lg:justify-start lg:items-start">
|
||||||
<div>
|
<div>
|
||||||
<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 && <p className="text-gray-400">Inactive Account</p>}
|
{player.inactive && (
|
||||||
{player.banned && <p className="text-red-500">Banned Account</p>}
|
<p className="text-gray-400">Inactive 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 (!subName.showWhenInactiveOrBanned && (player.inactive || player.banned)) {
|
if (
|
||||||
|
!subName.showWhenInactiveOrBanned &&
|
||||||
|
(player.inactive || player.banned)
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,13 +1,29 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token";
|
|
||||||
import { formatNumberWithCommas } from "@/common/number-utils";
|
import { formatNumberWithCommas } from "@/common/number-utils";
|
||||||
import { CategoryScale, Chart, Legend, LinearScale, LineElement, PointElement, Title, Tooltip } from "chart.js";
|
import {
|
||||||
|
CategoryScale,
|
||||||
|
Chart,
|
||||||
|
Legend,
|
||||||
|
LinearScale,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from "chart.js";
|
||||||
import { Line } from "react-chartjs-2";
|
import { Line } from "react-chartjs-2";
|
||||||
import Card from "../card";
|
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
|
||||||
|
|
||||||
Chart.register(LinearScale, CategoryScale, PointElement, LineElement, Title, Tooltip, Legend);
|
Chart.register(
|
||||||
|
LinearScale,
|
||||||
|
CategoryScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
);
|
||||||
|
|
||||||
export const options: any = {
|
export const options: any = {
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
@ -69,17 +85,12 @@ export const options: any = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
player: ScoreSaberPlayerToken;
|
player: ScoreSaberPlayer;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PlayerRankChart({ player }: Props) {
|
export default function PlayerRankChart({ player }: Props) {
|
||||||
const playerRankHistory = player.histories.split(",").map((value) => {
|
|
||||||
return parseInt(value);
|
|
||||||
});
|
|
||||||
playerRankHistory.push(player.rank);
|
|
||||||
|
|
||||||
const labels = [];
|
const labels = [];
|
||||||
for (let i = playerRankHistory.length; i > 0; i--) {
|
for (let i = player.rankHistory.length; i > 0; i--) {
|
||||||
let label = `${i} days ago`;
|
let label = `${i} days ago`;
|
||||||
if (i === 1) {
|
if (i === 1) {
|
||||||
label = "now";
|
label = "now";
|
||||||
@ -95,7 +106,7 @@ export default function PlayerRankChart({ player }: Props) {
|
|||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
lineTension: 0.5,
|
lineTension: 0.5,
|
||||||
data: playerRankHistory,
|
data: player.rankHistory,
|
||||||
label: "Rank",
|
label: "Rank",
|
||||||
borderColor: "#606fff",
|
borderColor: "#606fff",
|
||||||
fill: false,
|
fill: false,
|
||||||
@ -105,8 +116,8 @@ export default function PlayerRankChart({ player }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-96">
|
<div className="h-96">
|
||||||
<Line className="w-fit" options={options} data={data} />
|
<Line className="w-fit" options={options} data={data} />
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -11,13 +11,13 @@ import Pagination from "../input/pagination";
|
|||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { leaderboards } from "@/common/leaderboards";
|
import { leaderboards } from "@/common/leaderboards";
|
||||||
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 ScoreSaberPlayerScoresPageToken from "@/common/model/token/scoresaber/score-saber-player-scores-page-token";
|
||||||
import Score from "@/components/score/score";
|
import Score from "@/components/score/score";
|
||||||
|
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
initialScoreData?: ScoreSaberPlayerScoresPageToken;
|
initialScoreData?: ScoreSaberPlayerScoresPageToken;
|
||||||
player: ScoreSaberPlayerToken;
|
player: ScoreSaberPlayer;
|
||||||
sort: ScoreSort;
|
sort: ScoreSort;
|
||||||
page: number;
|
page: number;
|
||||||
};
|
};
|
||||||
|
@ -1,59 +1,57 @@
|
|||||||
import { formatNumberWithCommas } from "@/common/number-utils";
|
import { formatNumberWithCommas } from "@/common/number-utils";
|
||||||
import StatValue from "@/components/stat-value";
|
import StatValue from "@/components/stat-value";
|
||||||
import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token";
|
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
|
||||||
|
|
||||||
type Badge = {
|
type Badge = {
|
||||||
name: string;
|
name: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
create: (
|
create: (player: ScoreSaberPlayer) => string | React.ReactNode | undefined;
|
||||||
player: ScoreSaberPlayerToken,
|
|
||||||
) => string | React.ReactNode | undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const badges: Badge[] = [
|
const badges: Badge[] = [
|
||||||
{
|
{
|
||||||
name: "Ranked Play Count",
|
name: "Ranked Play Count",
|
||||||
color: "bg-pp",
|
color: "bg-pp",
|
||||||
create: (player: ScoreSaberPlayerToken) => {
|
create: (player: ScoreSaberPlayer) => {
|
||||||
return formatNumberWithCommas(player.scoreStats.rankedPlayCount);
|
return formatNumberWithCommas(player.statistics.rankedPlayCount);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Total Ranked Score",
|
name: "Total Ranked Score",
|
||||||
color: "bg-pp",
|
color: "bg-pp",
|
||||||
create: (player: ScoreSaberPlayerToken) => {
|
create: (player: ScoreSaberPlayer) => {
|
||||||
return formatNumberWithCommas(player.scoreStats.totalRankedScore);
|
return formatNumberWithCommas(player.statistics.totalRankedScore);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Average Ranked Accuracy",
|
name: "Average Ranked Accuracy",
|
||||||
color: "bg-pp",
|
color: "bg-pp",
|
||||||
create: (player: ScoreSaberPlayerToken) => {
|
create: (player: ScoreSaberPlayer) => {
|
||||||
return player.scoreStats.averageRankedAccuracy.toFixed(2) + "%";
|
return player.statistics.averageRankedAccuracy.toFixed(2) + "%";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Total Play Count",
|
name: "Total Play Count",
|
||||||
create: (player: ScoreSaberPlayerToken) => {
|
create: (player: ScoreSaberPlayer) => {
|
||||||
return formatNumberWithCommas(player.scoreStats.totalPlayCount);
|
return formatNumberWithCommas(player.statistics.totalPlayCount);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Total Score",
|
name: "Total Score",
|
||||||
create: (player: ScoreSaberPlayerToken) => {
|
create: (player: ScoreSaberPlayer) => {
|
||||||
return formatNumberWithCommas(player.scoreStats.totalScore);
|
return formatNumberWithCommas(player.statistics.totalScore);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Total Replays Watched",
|
name: "Total Replays Watched",
|
||||||
create: (player: ScoreSaberPlayerToken) => {
|
create: (player: ScoreSaberPlayer) => {
|
||||||
return formatNumberWithCommas(player.scoreStats.replaysWatched);
|
return formatNumberWithCommas(player.statistics.replaysWatched);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
player: ScoreSaberPlayerToken;
|
player: ScoreSaberPlayer;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PlayerStats({ player }: Props) {
|
export default function PlayerStats({ player }: Props) {
|
||||||
|
@ -9,21 +9,25 @@ import { ReactElement } from "react";
|
|||||||
import Card from "../card";
|
import Card from "../card";
|
||||||
import CountryFlag from "../country-flag";
|
import CountryFlag from "../country-flag";
|
||||||
import { Avatar, AvatarImage } from "../ui/avatar";
|
import { Avatar, AvatarImage } from "../ui/avatar";
|
||||||
|
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
|
||||||
|
|
||||||
const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||||
const PLAYER_NAME_MAX_LENGTH = 14;
|
const PLAYER_NAME_MAX_LENGTH = 14;
|
||||||
|
|
||||||
type MiniProps = {
|
type MiniProps = {
|
||||||
type: "Global" | "Country";
|
type: "Global" | "Country";
|
||||||
player: ScoreSaberPlayerToken;
|
player: ScoreSaberPlayer;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Variants = {
|
type Variants = {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
itemsPerPage: number;
|
itemsPerPage: number;
|
||||||
icon: (player: ScoreSaberPlayerToken) => ReactElement;
|
icon: (player: ScoreSaberPlayer) => ReactElement;
|
||||||
getPage: (player: ScoreSaberPlayerToken, itemsPerPage: number) => number;
|
getPage: (player: ScoreSaberPlayer, itemsPerPage: number) => number;
|
||||||
query: (page: number, country: string) => Promise<ScoreSaberPlayersPageToken | undefined>;
|
query: (
|
||||||
|
page: number,
|
||||||
|
country: string,
|
||||||
|
) => Promise<ScoreSaberPlayersPageToken | undefined>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -31,7 +35,7 @@ const miniVariants: Variants = {
|
|||||||
Global: {
|
Global: {
|
||||||
itemsPerPage: 50,
|
itemsPerPage: 50,
|
||||||
icon: () => <GlobeAmericasIcon className="w-6 h-6" />,
|
icon: () => <GlobeAmericasIcon className="w-6 h-6" />,
|
||||||
getPage: (player: ScoreSaberPlayerToken, itemsPerPage: number) => {
|
getPage: (player: ScoreSaberPlayer, itemsPerPage: number) => {
|
||||||
return Math.floor((player.rank - 1) / itemsPerPage) + 1;
|
return Math.floor((player.rank - 1) / itemsPerPage) + 1;
|
||||||
},
|
},
|
||||||
query: (page: number) => {
|
query: (page: number) => {
|
||||||
@ -40,14 +44,17 @@ const miniVariants: Variants = {
|
|||||||
},
|
},
|
||||||
Country: {
|
Country: {
|
||||||
itemsPerPage: 50,
|
itemsPerPage: 50,
|
||||||
icon: (player: ScoreSaberPlayerToken) => {
|
icon: (player: ScoreSaberPlayer) => {
|
||||||
return <CountryFlag code={player.country} size={12} />;
|
return <CountryFlag code={player.country} size={12} />;
|
||||||
},
|
},
|
||||||
getPage: (player: ScoreSaberPlayerToken, itemsPerPage: number) => {
|
getPage: (player: ScoreSaberPlayer, itemsPerPage: number) => {
|
||||||
return Math.floor((player.countryRank - 1) / itemsPerPage) + 1;
|
return Math.floor((player.countryRank - 1) / itemsPerPage) + 1;
|
||||||
},
|
},
|
||||||
query: (page: number, country: string) => {
|
query: (page: number, country: string) => {
|
||||||
return leaderboards.ScoreSaber.queries.lookupGlobalPlayersByCountry(page, country);
|
return leaderboards.ScoreSaber.queries.lookupGlobalPlayersByCountry(
|
||||||
|
page,
|
||||||
|
country,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -107,7 +114,8 @@ export default function Mini({ type, player }: MiniProps) {
|
|||||||
{isLoading && <p className="text-gray-400">Loading...</p>}
|
{isLoading && <p className="text-gray-400">Loading...</p>}
|
||||||
{isError && <p className="text-red-500">Error</p>}
|
{isError && <p className="text-red-500">Error</p>}
|
||||||
{players?.map((playerRanking, index) => {
|
{players?.map((playerRanking, index) => {
|
||||||
const rank = type == "Global" ? playerRanking.rank : playerRanking.countryRank;
|
const rank =
|
||||||
|
type == "Global" ? playerRanking.rank : playerRanking.countryRank;
|
||||||
const playerName =
|
const playerName =
|
||||||
playerRanking.name.length > PLAYER_NAME_MAX_LENGTH
|
playerRanking.name.length > PLAYER_NAME_MAX_LENGTH
|
||||||
? playerRanking.name.substring(0, PLAYER_NAME_MAX_LENGTH) + "..."
|
? playerRanking.name.substring(0, PLAYER_NAME_MAX_LENGTH) + "..."
|
||||||
@ -122,9 +130,18 @@ export default function Mini({ type, player }: MiniProps) {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<p className="text-gray-400">#{formatNumberWithCommas(rank)}</p>
|
<p className="text-gray-400">#{formatNumberWithCommas(rank)}</p>
|
||||||
<Avatar className="w-6 h-6 pointer-events-none">
|
<Avatar className="w-6 h-6 pointer-events-none">
|
||||||
<AvatarImage alt="Profile Picture" src={playerRanking.profilePicture} />
|
<AvatarImage
|
||||||
|
alt="Profile Picture"
|
||||||
|
src={playerRanking.profilePicture}
|
||||||
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<p className={playerRanking.id === player.id ? "text-gray-400" : ""}>{playerName}</p>
|
<p
|
||||||
|
className={
|
||||||
|
playerRanking.id === player.id ? "text-gray-400" : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{playerName}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-pp">{formatPp(playerRanking.pp)}pp</p>
|
<p className="text-pp">{formatPp(playerRanking.pp)}pp</p>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -14,9 +14,13 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
|
export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
|
||||||
const diff = getDifficultyFromScoreSaberDifficulty(leaderboard.difficulty.difficulty);
|
const diff = getDifficultyFromScoreSaberDifficulty(
|
||||||
|
leaderboard.difficulty.difficulty,
|
||||||
|
);
|
||||||
const mappersProfile =
|
const mappersProfile =
|
||||||
beatSaverMap != undefined ? `https://beatsaver.com/profile/${beatSaverMap?.fullData.uploader.id}` : undefined;
|
beatSaverMap != undefined
|
||||||
|
? `https://beatsaver.com/profile/${beatSaverMap?.fullData.uploader.id}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3 items-center">
|
<div className="flex gap-3 items-center">
|
||||||
@ -68,7 +72,13 @@ export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-400">{leaderboard.songAuthorName}</p>
|
<p className="text-sm text-gray-400">{leaderboard.songAuthorName}</p>
|
||||||
<FallbackLink href={mappersProfile}>
|
<FallbackLink href={mappersProfile}>
|
||||||
<p className={clsx("text-sm", mappersProfile && "hover:brightness-75 transform-gpu transition-all")}>
|
<p
|
||||||
|
className={clsx(
|
||||||
|
"text-sm",
|
||||||
|
mappersProfile &&
|
||||||
|
"hover:brightness-75 transform-gpu transition-all",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{leaderboard.levelAuthorName}
|
{leaderboard.levelAuthorName}
|
||||||
</p>
|
</p>
|
||||||
</FallbackLink>
|
</FallbackLink>
|
||||||
|
@ -26,7 +26,9 @@ export default function ScoreRankInfo({ score }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className="text-sm cursor-default">{timeAgo(new Date(score.timeSet))}</p>
|
<p className="text-sm cursor-default">
|
||||||
|
{timeAgo(new Date(score.timeSet))}
|
||||||
|
</p>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -8,10 +8,13 @@ import clsx from "clsx";
|
|||||||
|
|
||||||
type Badge = {
|
type Badge = {
|
||||||
name: string;
|
name: string;
|
||||||
color?: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => string | undefined;
|
color?: (
|
||||||
|
score: ScoreSaberScoreToken,
|
||||||
|
leaderboard: ScoreSaberLeaderboardToken,
|
||||||
|
) => string | undefined;
|
||||||
create: (
|
create: (
|
||||||
score: ScoreSaberScoreToken,
|
score: ScoreSaberScoreToken,
|
||||||
leaderboard: ScoreSaberLeaderboardToken
|
leaderboard: ScoreSaberLeaderboardToken,
|
||||||
) => string | React.ReactNode | undefined;
|
) => string | React.ReactNode | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -31,11 +34,17 @@ const badges: Badge[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Accuracy",
|
name: "Accuracy",
|
||||||
color: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => {
|
color: (
|
||||||
|
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: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => {
|
create: (
|
||||||
|
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)}%`;
|
||||||
},
|
},
|
||||||
@ -61,8 +70,16 @@ const badges: Badge[] = [
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p>{fullCombo ? <span className="text-green-400">FC</span> : formatNumberWithCommas(score.missedNotes)}</p>
|
<p>
|
||||||
<XMarkIcon className={clsx("w-5 h-5", fullCombo ? "hidden" : "text-red-400")} />
|
{fullCombo ? (
|
||||||
|
<span className="text-green-400">FC</span>
|
||||||
|
) : (
|
||||||
|
formatNumberWithCommas(score.missedNotes)
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<XMarkIcon
|
||||||
|
className={clsx("w-5 h-5", fullCombo ? "hidden" : "text-red-400")}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -1,4 +1,8 @@
|
|||||||
import { Tooltip as ShadCnTooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
import {
|
||||||
|
Tooltip as ShadCnTooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "./ui/tooltip";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
@ -10,13 +14,18 @@ type Props = {
|
|||||||
* What will be displayed in the tooltip
|
* What will be displayed in the tooltip
|
||||||
*/
|
*/
|
||||||
display: React.ReactNode;
|
display: React.ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the tooltip will be displayed
|
||||||
|
*/
|
||||||
|
side?: "top" | "bottom" | "left" | "right";
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Tooltip({ children, display }: Props) {
|
export default function Tooltip({ children, display, side = "top" }: Props) {
|
||||||
return (
|
return (
|
||||||
<ShadCnTooltip>
|
<ShadCnTooltip>
|
||||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||||
<TooltipContent>{display}</TooltipContent>
|
<TooltipContent side={side}>{display}</TooltipContent>
|
||||||
</ShadCnTooltip>
|
</ShadCnTooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user