This commit is contained in:
parent
bc86cae8aa
commit
e9f55389f4
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,6 @@
|
||||
export default interface ScoreSaberDifficulty {
|
||||
leaderboardId: number;
|
||||
difficulty: number;
|
||||
gameMode: string;
|
||||
difficultyRaw: string;
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
export default interface ScoreSaberLeaderboardPlayerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
profilePicture: string;
|
||||
country: string;
|
||||
permissions: number;
|
||||
role: string;
|
||||
}
|
@ -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[];
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
26
src/app/common/time-utils.ts
Normal file
26
src/app/common/time-utils.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
48
src/app/components/player/player-scores.tsx
Normal file
48
src/app/components/player/player-scores.tsx
Normal 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>
|
||||
);
|
||||
}
|
37
src/app/components/player/score.tsx
Normal file
37
src/app/components/player/score.tsx
Normal 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>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user