basic score view
Some checks failed
Deploy SSR / deploy (push) Failing after 26s

This commit is contained in:
Lee 2024-09-11 20:10:27 +01:00
parent bc86cae8aa
commit e9f55389f4
15 changed files with 271 additions and 30 deletions

View File

@ -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<Met
export default async function Search({ params: { slug } }: Props) {
const id = slug[0]; // The players id
// const sort: ScoreSort = (slug[1] as ScoreSort) || "recent"; // The sorting method
// const page = slug[2] || 1; // The page number
const sort: ScoreSort = (slug[1] as ScoreSort) || "recent"; // The sorting method
const page = parseInt(slug[2]) || 1; // The page number
const player = await scoresaberLeaderboard.lookupPlayer(id, false);
if (player == undefined) {
@ -50,7 +51,7 @@ export default async function Search({ params: { slug } }: Props) {
return (
<div className="flex flex-col h-full w-full">
<PlayerData initalPlayerData={player} />
<PlayerData initalPlayerData={player} sort={sort} page={page} />
</div>
);
}

View File

@ -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<Settings, "id">;
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

View File

@ -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<ScoreSaberPlayerSearch | undefined> {
this.log(`Searching for players matching "${query}"...`);
try {
const results = await this.fetch<ScoreSaberPlayerSearch>(
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<ScoreSaberPlayerSearch>(
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<ScoreSaberPlayer | undefined> {
this.log(`Looking up player "${playerId}"...`);
try {
return await this.fetch<ScoreSaberPlayer>(useProxy, LOOKUP_PLAYER_ENDPOINT.replace("{playerId}", playerId));
} catch {
return undefined;
}
return await this.fetch<ScoreSaberPlayer>(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<ScoreSaberPlayerScoresPage | undefined> {
this.log(`Looking up scores for player "${playerId}", sort "${sort}", page "${page}"...`);
return await this.fetch<ScoreSaberPlayerScoresPage>(
useProxy,
LOOKUP_PLAYER_SCORES_ENDPOINT.replace(":id", playerId)
.replace(":limit", 8 + "")
.replace(":sort", sort)
.replace(":page", page.toString())
);
}
}

View File

@ -38,12 +38,17 @@ export default class Leaderboard {
* @returns the fetched data
*/
public async fetch<T>(useProxy: boolean, url: string): Promise<T> {
return await ky
.get<T>(this.buildRequestUrl(useProxy, url), {
next: {
revalidate: 60, // 1 minute
},
})
.json();
try {
return await ky
.get<T>(this.buildRequestUrl(useProxy, url), {
next: {
revalidate: 60, // 1 minute
},
})
.json();
} catch (error) {
console.error(error);
throw error;
}
}
}

View File

@ -0,0 +1,6 @@
export default interface ScoreSaberDifficulty {
leaderboardId: number;
difficulty: number;
gameMode: string;
difficultyRaw: string;
}

View File

@ -0,0 +1,8 @@
export default interface ScoreSaberLeaderboardPlayerInfo {
id: string;
name: string;
profilePicture: string;
country: string;
permissions: number;
role: string;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) {
<div className="flex flex-col gap-2">
<PlayerHeader player={player} />
<PlayerRankChart player={player} />
<PlayerScores player={player} sort={sort} page={page} />
</div>
);
}

View File

@ -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 (
<Card className="gap-2">
<div className="grid min-w-full grid-cols-1 divide-y divide-border">
{data.playerScores.map((playerScore, index) => {
return <Score key={index} playerScore={playerScore} />;
})}
</div>
</Card>
);
}

View File

@ -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 (
<div className="grid gap-2 md:gap-0 grid-cols-1 pb-2 pt-2 first:pt-0 last:pb-0 grid-cols-[20px 1fr] md:grid-cols-[0.85fr_6fr_1.3fr]">
<div className="flex w-full flex-row justify-between items-center md:w-[125px] md:justify-center md:flex-col">
<div className="flex gap-1 items-center">
<GlobeAmericasIcon className="w-5 h-5" />
<p className="text-pp">#{score.rank}</p>
</div>
<p className="text-sm">{timeAgo(new Date(score.timeSet))}</p>
</div>
<div className="flex gap-3">
<img src={leaderboard.coverImage} className="w-16 h-16 rounded-md" />
<div className="flex">
<div className="flex flex-col">
<p>{leaderboard.songName}</p>
<p className="text-sm">{leaderboard.songAuthorName}</p>
<p className="text-sm">{leaderboard.levelAuthorName}</p>
</div>
</div>
</div>
<div className="flex justify-end">stats stuff</div>
</div>
);
}