add player badges
All checks were successful
Deploy / deploy (push) Successful in 2m24s

This commit is contained in:
Lee 2024-09-27 23:04:14 +01:00
parent d9b68f0c65
commit 1fa20b6e52
19 changed files with 502 additions and 108 deletions

@ -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),
}, },

@ -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;
};

@ -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>

@ -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>
); );
} }