diff --git a/next.config.mjs b/next.config.mjs index 1f4f1fb..4548e39 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -11,6 +11,16 @@ const nextConfig = { experimental: { webpackMemoryOptimizations: true, }, + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'cdn.scoresaber.com', + port: '', + pathname: '/**', + }, + ], + }, env: { NEXT_PUBLIC_BUILD_ID: process.env.GIT_REV || nextBuildId.sync({ dir: __dirname }), NEXT_PUBLIC_BUILD_TIME: new Date().toLocaleDateString("en-US", { diff --git a/src/app/(pages)/player/[...slug]/page.tsx b/src/app/(pages)/player/[...slug]/page.tsx index 730d31b..c7d421c 100644 --- a/src/app/(pages)/player/[...slug]/page.tsx +++ b/src/app/(pages)/player/[...slug]/page.tsx @@ -32,7 +32,7 @@ export async function generateMetadata({ params }: Props): Promise { description: ` PP: ${formatPp(player.pp)}pp 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}!`, }, @@ -58,7 +58,12 @@ export default async function Search({ params }: Props) { return (
- +
); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index aa097db..fba83c7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -12,6 +12,7 @@ import DatabaseLoader from "../components/loaders/database-loader"; import NavBar from "../components/navbar/navbar"; import "./globals.css"; + const siteFont = localFont({ src: "./fonts/JetBrainsMono-Regular.woff2", weight: "100 900", @@ -46,12 +47,14 @@ export const metadata: Metadata = { "Stream enhancement, Professional overlay, Easy to use overlay builder.", openGraph: { 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", locale: "en_US", 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({ @@ -61,13 +64,20 @@ export default function RootLayout({ }>) { return ( - + - +
diff --git a/src/common/leaderboards.ts b/src/common/leaderboards.ts index b6dfac6..cd43216 100644 --- a/src/common/leaderboards.ts +++ b/src/common/leaderboards.ts @@ -1,6 +1,6 @@ -import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token"; import { scoresaberService } from "@/common/service/impl/scoresaber"; import { ScoreSort } from "@/common/service/score-sort"; +import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player"; export const leaderboards = { ScoreSaber: { @@ -8,14 +8,15 @@ export const leaderboards = { search: true, }, queries: { - lookupScores: (player: ScoreSaberPlayerToken, sort: ScoreSort, page: number) => + lookupScores: (player: ScoreSaberPlayer, sort: ScoreSort, page: number) => scoresaberService.lookupPlayerScores({ playerId: player.id, sort: sort, page: page, }), - lookupGlobalPlayers: (page: number) => scoresaberService.lookupPlayers(page), + lookupGlobalPlayers: (page: number) => + scoresaberService.lookupPlayers(page), lookupGlobalPlayersByCountry: (page: number, country: string) => scoresaberService.lookupPlayersByCountry(page, country), }, diff --git a/src/common/model/player/impl/scoresaber-player.ts b/src/common/model/player/impl/scoresaber-player.ts new file mode 100644 index 0000000..881fe66 --- /dev/null +++ b/src/common/model/player/impl/scoresaber-player.ts @@ -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; +}; diff --git a/src/common/model/player/player.ts b/src/common/model/player/player.ts new file mode 100644 index 0000000..afa28b8 --- /dev/null +++ b/src/common/model/player/player.ts @@ -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; + } +} diff --git a/src/common/service/impl/scoresaber.ts b/src/common/service/impl/scoresaber.ts index cc7b903..b83e07d 100644 --- a/src/common/service/impl/scoresaber.ts +++ b/src/common/service/impl/scoresaber.ts @@ -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 { ScoreSort } from "../score-sort"; import Service from "../service"; +import ScoreSaberPlayer, { + getScoreSaberPlayerFromToken, +} from "@/common/model/player/impl/scoresaber-player"; const API_BASE = "https://scoresaber.com/api"; 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 * @returns the players that match the query, or undefined if no players were found */ - async searchPlayers(query: string, useProxy = true): Promise { + async searchPlayers( + query: string, + useProxy = true, + ): Promise { const before = performance.now(); this.log(`Searching for players matching "${query}"...`); const results = await this.fetch( useProxy, - SEARCH_PLAYERS_ENDPOINT.replace(":query", query) + SEARCH_PLAYERS_ENDPOINT.replace(":query", query), ); if (results === undefined) { return undefined; @@ -40,7 +46,9 @@ class ScoreSaberService extends Service { return undefined; } results.players.sort((a, b) => a.rank - b.rank); - this.log(`Found ${results.players.length} players in ${(performance.now() - before).toFixed(0)}ms`); + this.log( + `Found ${results.players.length} players in ${(performance.now() - before).toFixed(0)}ms`, + ); return results; } @@ -51,15 +59,23 @@ class ScoreSaberService extends Service { * @param useProxy whether to use the proxy or not * @returns the player that matches the ID, or undefined */ - async lookupPlayer(playerId: string, useProxy = true): Promise { + async lookupPlayer( + playerId: string, + useProxy = true, + ): Promise { const before = performance.now(); this.log(`Looking up player "${playerId}"...`); - const response = await this.fetch(useProxy, LOOKUP_PLAYER_ENDPOINT.replace(":id", playerId)); - if (response === undefined) { + const token = await this.fetch( + useProxy, + LOOKUP_PLAYER_ENDPOINT.replace(":id", playerId), + ); + if (token === undefined) { return undefined; } - this.log(`Found player "${playerId}" in ${(performance.now() - before).toFixed(0)}ms`); - return response; + this.log( + `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 * @returns the players on the page, or undefined */ - async lookupPlayers(page: number, useProxy = true): Promise { + async lookupPlayers( + page: number, + useProxy = true, + ): Promise { const before = performance.now(); this.log(`Looking up players on page "${page}"...`); const response = await this.fetch( useProxy, - LOOKUP_PLAYERS_ENDPOINT.replace(":page", page.toString()) + LOOKUP_PLAYERS_ENDPOINT.replace(":page", page.toString()), ); if (response === 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; } @@ -94,18 +115,25 @@ class ScoreSaberService extends Service { async lookupPlayersByCountry( page: number, country: string, - useProxy = true + useProxy = true, ): Promise { 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( 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) { 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; } @@ -136,22 +164,22 @@ class ScoreSaberService extends Service { this.log( `Looking up scores for player "${playerId}", sort "${sort}", page "${page}"${ search ? `, search "${search}"` : "" - }...` + }...`, ); const response = await this.fetch( useProxy, LOOKUP_PLAYER_SCORES_ENDPOINT.replace(":id", playerId) .replace(":limit", 8 + "") .replace(":sort", sort) - .replace(":page", page + "") + (search ? `&search=${search}` : "") + .replace(":page", page + "") + (search ? `&search=${search}` : ""), ); if (response === undefined) { return undefined; } this.log( - `Found ${response.playerScores.length} scores for player "${playerId}" in ${(performance.now() - before).toFixed( - 0 - )}ms` + `Found ${response.playerScores.length} scores for player "${playerId}" in ${( + performance.now() - before + ).toFixed(0)}ms`, ); return response; } @@ -168,13 +196,18 @@ class ScoreSaberService extends Service { async lookupLeaderboardScores( leaderboardId: string, page: number, - useProxy = true + useProxy = true, ): Promise { const before = performance.now(); - this.log(`Looking up scores for leaderboard "${leaderboardId}", page "${page}"...`); + this.log( + `Looking up scores for leaderboard "${leaderboardId}", page "${page}"...`, + ); const response = await this.fetch( useProxy, - LOOKUP_LEADERBOARD_SCORES_ENDPOINT.replace(":id", leaderboardId).replace(":page", page.toString()) + LOOKUP_LEADERBOARD_SCORES_ENDPOINT.replace(":id", leaderboardId).replace( + ":page", + page.toString(), + ), ); if (response === undefined) { return undefined; @@ -182,7 +215,7 @@ class ScoreSaberService extends Service { this.log( `Found ${response.scores.length} scores for leaderboard "${leaderboardId}" in ${( performance.now() - before - ).toFixed(0)}ms` + ).toFixed(0)}ms`, ); return response; } diff --git a/src/components/navbar/profile-button.tsx b/src/components/navbar/profile-button.tsx index 3b22f56..f692aae 100644 --- a/src/components/navbar/profile-button.tsx +++ b/src/components/navbar/profile-button.tsx @@ -19,10 +19,16 @@ export default function ProfileButton() { } return ( - + - +

You

diff --git a/src/components/player/player-badges.tsx b/src/components/player/player-badges.tsx new file mode 100644 index 0000000..49cb57c --- /dev/null +++ b/src/components/player/player-badges.tsx @@ -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 ( +
+ {player.badges?.map((badge, index) => { + return ( + {badge.description}

} + > +
+ {badge.description} +
+
+ ); + })} +
+ ); +} diff --git a/src/components/player/player-data.tsx b/src/components/player/player-data.tsx index 4b85791..584e4e5 100644 --- a/src/components/player/player-data.tsx +++ b/src/components/player/player-data.tsx @@ -1,7 +1,6 @@ "use client"; import ScoreSaberPlayerScoresPageToken from "@/common/model/token/scoresaber/score-saber-player-scores-page-token"; -import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token"; import { scoresaberService } from "@/common/service/impl/scoresaber"; import { ScoreSort } from "@/common/service/score-sort"; import { useQuery } from "@tanstack/react-query"; @@ -9,17 +8,25 @@ import Mini from "../ranking/mini"; import PlayerHeader from "./player-header"; import PlayerRankChart from "./player-rank-chart"; 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 type Props = { - initialPlayerData: ScoreSaberPlayerToken; + initialPlayerData: ScoreSaberPlayer; initialScoreData?: ScoreSaberPlayerScoresPageToken; sort: ScoreSort; page: number; }; -export default function PlayerData({ initialPlayerData: initalPlayerData, initialScoreData, sort, page }: Props) { +export default function PlayerData({ + initialPlayerData: initalPlayerData, + initialScoreData, + sort, + page, +}: Props) { let player = initalPlayerData; const { data, isLoading, isError } = useQuery({ queryKey: ["player", player.id], @@ -36,11 +43,17 @@ export default function PlayerData({ initialPlayerData: initalPlayerData, initia
{!player.inactive && ( - <> + + - + )} - +