diff --git a/src/app/(pages)/player/[...slug]/page.tsx b/src/app/(pages)/player/[...slug]/page.tsx index 05449fd..8837dbe 100644 --- a/src/app/(pages)/player/[...slug]/page.tsx +++ b/src/app/(pages)/player/[...slug]/page.tsx @@ -1,4 +1,5 @@ import { scoresaberLeaderboard } from "@/app/common/leaderboard/impl/scoresaber"; +import { ScoreSort } from "@/app/common/leaderboard/sort"; import { formatNumberWithCommas } from "@/app/common/number-utils"; import PlayerData from "@/app/components/player/player-data"; import { format } from "@formkit/tempo"; @@ -39,8 +40,8 @@ export async function generateMetadata({ params: { slug } }: Props): Promise - + ); } diff --git a/src/app/common/database/database.ts b/src/app/common/database/database.ts index 278bdec..2a65882 100644 --- a/src/app/common/database/database.ts +++ b/src/app/common/database/database.ts @@ -5,6 +5,9 @@ import Settings from "./types/settings"; const SETTINGS_ID = "SSR"; // DO NOT CHANGE export default class Database extends Dexie { + /** + * The settings for the website. + */ settings!: EntityTable; constructor() { @@ -31,6 +34,9 @@ export default class Database extends Dexie { }); } + /** + * Populates the default settings + */ async populateDefaults() { await this.settings.add({ id: SETTINGS_ID, // Fixed ID for the single settings object diff --git a/src/app/common/leaderboard/impl/scoresaber.ts b/src/app/common/leaderboard/impl/scoresaber.ts index 0e91e8e..00e964d 100644 --- a/src/app/common/leaderboard/impl/scoresaber.ts +++ b/src/app/common/leaderboard/impl/scoresaber.ts @@ -1,10 +1,13 @@ import Leaderboard from "../leaderboard"; +import { ScoreSort } from "../sort"; import ScoreSaberPlayer from "../types/scoresaber/scoresaber-player"; +import ScoreSaberPlayerScoresPage from "../types/scoresaber/scoresaber-player-scores-page"; import { ScoreSaberPlayerSearch } from "../types/scoresaber/scoresaber-player-search"; const API_BASE = "https://scoresaber.com/api"; -const SEARCH_PLAYERS_ENDPOINT = `${API_BASE}/players?search={query}`; -const LOOKUP_PLAYER_ENDPOINT = `${API_BASE}/player/{playerId}/full`; +const SEARCH_PLAYERS_ENDPOINT = `${API_BASE}/players?search=:query`; +const LOOKUP_PLAYER_ENDPOINT = `${API_BASE}/player/:id/full`; +const LOOKUP_PLAYER_SCORES_ENDPOINT = `${API_BASE}/player/:id/scores?limit=:limit&sort=:sort&page=:page`; class ScoreSaberLeaderboard extends Leaderboard { constructor() { @@ -20,19 +23,15 @@ class ScoreSaberLeaderboard extends Leaderboard { */ async searchPlayers(query: string, useProxy = true): Promise { this.log(`Searching for players matching "${query}"...`); - try { - const results = await this.fetch( - useProxy, - SEARCH_PLAYERS_ENDPOINT.replace("{query}", query) - ); - if (results.players.length === 0) { - return undefined; - } - results.players.sort((a, b) => a.rank - b.rank); - return results; - } catch { + const results = await this.fetch( + useProxy, + SEARCH_PLAYERS_ENDPOINT.replace(":query", query) + ); + if (results.players.length === 0) { return undefined; } + results.players.sort((a, b) => a.rank - b.rank); + return results; } /** @@ -44,11 +43,32 @@ class ScoreSaberLeaderboard extends Leaderboard { */ async lookupPlayer(playerId: string, useProxy = true): Promise { this.log(`Looking up player "${playerId}"...`); - try { - return await this.fetch(useProxy, LOOKUP_PLAYER_ENDPOINT.replace("{playerId}", playerId)); - } catch { - return undefined; - } + return await this.fetch(useProxy, LOOKUP_PLAYER_ENDPOINT.replace(":id", playerId)); + } + + /** + * Looks up a page of scores for a player + * + * @param playerId the ID of the player to look up + * @param sort the sort to use + * @param page the page to get scores for + * @param useProxy whether to use the proxy or not + * @returns the scores of the player, or undefined + */ + async lookupPlayerScores( + playerId: string, + sort: ScoreSort, + page: number, + useProxy = true + ): Promise { + this.log(`Looking up scores for player "${playerId}", sort "${sort}", page "${page}"...`); + return await this.fetch( + useProxy, + LOOKUP_PLAYER_SCORES_ENDPOINT.replace(":id", playerId) + .replace(":limit", 8 + "") + .replace(":sort", sort) + .replace(":page", page.toString()) + ); } } diff --git a/src/app/common/leaderboard/leaderboard.ts b/src/app/common/leaderboard/leaderboard.ts index 828cc5f..9b9ae08 100644 --- a/src/app/common/leaderboard/leaderboard.ts +++ b/src/app/common/leaderboard/leaderboard.ts @@ -38,12 +38,17 @@ export default class Leaderboard { * @returns the fetched data */ public async fetch(useProxy: boolean, url: string): Promise { - return await ky - .get(this.buildRequestUrl(useProxy, url), { - next: { - revalidate: 60, // 1 minute - }, - }) - .json(); + try { + return await ky + .get(this.buildRequestUrl(useProxy, url), { + next: { + revalidate: 60, // 1 minute + }, + }) + .json(); + } catch (error) { + console.error(error); + throw error; + } } } diff --git a/src/app/common/leaderboard/types/scoresaber/scoresaber-difficulty.ts b/src/app/common/leaderboard/types/scoresaber/scoresaber-difficulty.ts new file mode 100644 index 0000000..d5ffce1 --- /dev/null +++ b/src/app/common/leaderboard/types/scoresaber/scoresaber-difficulty.ts @@ -0,0 +1,6 @@ +export default interface ScoreSaberDifficulty { + leaderboardId: number; + difficulty: number; + gameMode: string; + difficultyRaw: string; +} diff --git a/src/app/common/leaderboard/types/scoresaber/scoresaber-leaderboard-player-info.ts b/src/app/common/leaderboard/types/scoresaber/scoresaber-leaderboard-player-info.ts new file mode 100644 index 0000000..81554ee --- /dev/null +++ b/src/app/common/leaderboard/types/scoresaber/scoresaber-leaderboard-player-info.ts @@ -0,0 +1,8 @@ +export default interface ScoreSaberLeaderboardPlayerInfo { + id: string; + name: string; + profilePicture: string; + country: string; + permissions: number; + role: string; +} diff --git a/src/app/common/leaderboard/types/scoresaber/scoresaber-leaderboard.ts b/src/app/common/leaderboard/types/scoresaber/scoresaber-leaderboard.ts new file mode 100644 index 0000000..9eb89be --- /dev/null +++ b/src/app/common/leaderboard/types/scoresaber/scoresaber-leaderboard.ts @@ -0,0 +1,26 @@ +import ScoreSaberDifficulty from "./scoresaber-difficulty"; + +export default interface ScoreSaberLeaderboard { + id: number; + songHash: string; + songName: string; + songSubName: string; + songAuthorName: string; + levelAuthorName: string; + difficulty: ScoreSaberDifficulty; + maxScore: number; + createdDate: string; + rankedDate: string; + qualifiedDate: string; + lovedDate: string; + ranked: boolean; + qualified: boolean; + loved: boolean; + maxPP: number; + stars: number; + positiveModifiers: boolean; + plays: boolean; + dailyPlays: boolean; + coverImage: string; + difficulties: ScoreSaberDifficulty[]; +} diff --git a/src/app/common/leaderboard/types/scoresaber/scoresaber-player-score.ts b/src/app/common/leaderboard/types/scoresaber/scoresaber-player-score.ts new file mode 100644 index 0000000..29d9952 --- /dev/null +++ b/src/app/common/leaderboard/types/scoresaber/scoresaber-player-score.ts @@ -0,0 +1,14 @@ +import ScoreSaberLeaderboard from "./scoresaber-leaderboard"; +import ScoreSaberScore from "./scoresaber-score"; + +export default interface ScoreSaberPlayerScore { + /** + * The score of the player score. + */ + score: ScoreSaberScore; + + /** + * The leaderboard the score was set on. + */ + leaderboard: ScoreSaberLeaderboard; +} diff --git a/src/app/common/leaderboard/types/scoresaber/scoresaber-player-scores-page.ts b/src/app/common/leaderboard/types/scoresaber/scoresaber-player-scores-page.ts new file mode 100644 index 0000000..3b41ec0 --- /dev/null +++ b/src/app/common/leaderboard/types/scoresaber/scoresaber-player-scores-page.ts @@ -0,0 +1,14 @@ +import ScoreSaberMetadata from "./scoresaber-metadata"; +import ScoreSaberPlayerScore from "./scoresaber-player-score"; + +export default interface ScoreSaberPlayerScoresPage { + /** + * The scores on this page. + */ + playerScores: ScoreSaberPlayerScore[]; + + /** + * The metadata for the page. + */ + metadata: ScoreSaberMetadata; +} diff --git a/src/app/common/leaderboard/types/scoresaber/scoresaber-score.ts b/src/app/common/leaderboard/types/scoresaber/scoresaber-score.ts new file mode 100644 index 0000000..75dd416 --- /dev/null +++ b/src/app/common/leaderboard/types/scoresaber/scoresaber-score.ts @@ -0,0 +1,25 @@ +import ScoreSaberLeaderboard from "./scoresaber-leaderboard"; +import ScoreSaberLeaderboardPlayerInfo from "./scoresaber-leaderboard-player-info"; + +export default interface ScoreSaberScore { + id: string; + leaderboardPlayerInfo: ScoreSaberLeaderboardPlayerInfo; + rank: number; + baseScore: number; + modifiedScore: number; + pp: number; + weight: number; + modifiers: string; + multiplier: number; + badCuts: number; + missedNotes: number; + maxCombo: number; + fullCombo: boolean; + hmd: number; + hasReplay: boolean; + timeSet: string; + deviceHmd: string; + deviceControllerLeft: string; + deviceControllerRight: string; + leaderboard: ScoreSaberLeaderboard; +} diff --git a/src/app/common/time-utils.ts b/src/app/common/time-utils.ts new file mode 100644 index 0000000..79caceb --- /dev/null +++ b/src/app/common/time-utils.ts @@ -0,0 +1,26 @@ +/** + * This function returns the time ago of the input date + * + * @param input Date | number + * @returns the format of the time ago + */ +export function timeAgo(input: Date | number) { + const date = input instanceof Date ? input : new Date(input); + const formatter = new Intl.RelativeTimeFormat("en"); + const ranges: { [key: string]: number } = { + years: 3600 * 24 * 365, + months: 3600 * 24 * 30, + weeks: 3600 * 24 * 7, + days: 3600 * 24, + hours: 3600, + minutes: 60, + seconds: 1, + }; + const secondsElapsed = (date.getTime() - Date.now()) / 1000; + for (let key in ranges) { + if (ranges[key] < Math.abs(secondsElapsed)) { + const delta = secondsElapsed / ranges[key]; + return formatter.format(Math.round(delta), key); + } + } +} diff --git a/src/app/components/background-image.tsx b/src/app/components/background-image.tsx index 3e517ca..046e529 100644 --- a/src/app/components/background-image.tsx +++ b/src/app/components/background-image.tsx @@ -31,7 +31,7 @@ export default function BackgroundImage() { src={getImageUrl(backgroundImage)} alt="Background image" fetchPriority="high" - className={`absolute -z-50 object-cover w-screen h-screen blur-sm brightness-[33%] pointer-events-none select-none`} + className={`fixed -z-50 object-cover w-screen h-screen blur-sm brightness-[33%] pointer-events-none select-none`} /> ); } diff --git a/src/app/components/player/player-data.tsx b/src/app/components/player/player-data.tsx index 11c2e92..d9dbf1e 100644 --- a/src/app/components/player/player-data.tsx +++ b/src/app/components/player/player-data.tsx @@ -1,18 +1,22 @@ "use client"; import { scoresaberLeaderboard } from "@/app/common/leaderboard/impl/scoresaber"; +import { ScoreSort } from "@/app/common/leaderboard/sort"; import ScoreSaberPlayer from "@/app/common/leaderboard/types/scoresaber/scoresaber-player"; import { useQuery } from "@tanstack/react-query"; import PlayerHeader from "./player-header"; import PlayerRankChart from "./player-rank-chart"; +import PlayerScores from "./player-scores"; const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes type Props = { initalPlayerData: ScoreSaberPlayer; + sort: ScoreSort; + page: number; }; -export default function PlayerData({ initalPlayerData }: Props) { +export default function PlayerData({ initalPlayerData, sort, page }: Props) { let player = initalPlayerData; const { data, isLoading, isError } = useQuery({ queryKey: ["player", player.id], @@ -28,6 +32,7 @@ export default function PlayerData({ initalPlayerData }: Props) {
+
); } diff --git a/src/app/components/player/player-scores.tsx b/src/app/components/player/player-scores.tsx new file mode 100644 index 0000000..ab0d947 --- /dev/null +++ b/src/app/components/player/player-scores.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { scoresaberLeaderboard } from "@/app/common/leaderboard/impl/scoresaber"; +import { ScoreSort } from "@/app/common/leaderboard/sort"; +import ScoreSaberPlayer from "@/app/common/leaderboard/types/scoresaber/scoresaber-player"; +import { useQuery } from "@tanstack/react-query"; +import Card from "../card"; +import Score from "./score"; + +type Props = { + /** + * The player to fetch scores for. + */ + player: ScoreSaberPlayer; + + /** + * The sort to use for fetching scores. + */ + sort: ScoreSort; + + /** + * The page to fetch scores for. + */ + page: number; +}; + +export default function PlayerScores({ player, sort, page }: Props) { + const { data, isLoading, isError } = useQuery({ + queryKey: ["playerScores", player.id], + queryFn: () => scoresaberLeaderboard.lookupPlayerScores(player.id, sort, page), + }); + + console.log(data); + + if (data == undefined || isLoading || isError) { + return null; + } + + return ( + +
+ {data.playerScores.map((playerScore, index) => { + return ; + })} +
+
+ ); +} diff --git a/src/app/components/player/score.tsx b/src/app/components/player/score.tsx new file mode 100644 index 0000000..87d2d39 --- /dev/null +++ b/src/app/components/player/score.tsx @@ -0,0 +1,37 @@ +import ScoreSaberPlayerScore from "@/app/common/leaderboard/types/scoresaber/scoresaber-player-score"; +import { timeAgo } from "@/app/common/time-utils"; +import { GlobeAmericasIcon } from "@heroicons/react/24/solid"; + +type Props = { + /** + * The score to display. + */ + playerScore: ScoreSaberPlayerScore; +}; + +export default function Score({ playerScore }: Props) { + const { score, leaderboard } = playerScore; + + return ( +
+
+
+ +

#{score.rank}

+
+

{timeAgo(new Date(score.timeSet))}

+
+
+ +
+
+

{leaderboard.songName}

+

{leaderboard.songAuthorName}

+

{leaderboard.levelAuthorName}

+
+
+
+
stats stuff
+
+ ); +}