This commit is contained in:
parent
bc86cae8aa
commit
e9f55389f4
@ -1,4 +1,5 @@
|
|||||||
import { scoresaberLeaderboard } from "@/app/common/leaderboard/impl/scoresaber";
|
import { scoresaberLeaderboard } from "@/app/common/leaderboard/impl/scoresaber";
|
||||||
|
import { ScoreSort } from "@/app/common/leaderboard/sort";
|
||||||
import { formatNumberWithCommas } from "@/app/common/number-utils";
|
import { formatNumberWithCommas } from "@/app/common/number-utils";
|
||||||
import PlayerData from "@/app/components/player/player-data";
|
import PlayerData from "@/app/components/player/player-data";
|
||||||
import { format } from "@formkit/tempo";
|
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) {
|
export default async function Search({ params: { slug } }: Props) {
|
||||||
const id = slug[0]; // The players id
|
const id = slug[0]; // The players id
|
||||||
// const sort: ScoreSort = (slug[1] as ScoreSort) || "recent"; // The sorting method
|
const sort: ScoreSort = (slug[1] as ScoreSort) || "recent"; // The sorting method
|
||||||
// const page = slug[2] || 1; // The page number
|
const page = parseInt(slug[2]) || 1; // The page number
|
||||||
const player = await scoresaberLeaderboard.lookupPlayer(id, false);
|
const player = await scoresaberLeaderboard.lookupPlayer(id, false);
|
||||||
|
|
||||||
if (player == undefined) {
|
if (player == undefined) {
|
||||||
@ -50,7 +51,7 @@ export default async function Search({ params: { slug } }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full">
|
<div className="flex flex-col h-full w-full">
|
||||||
<PlayerData initalPlayerData={player} />
|
<PlayerData initalPlayerData={player} sort={sort} page={page} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,9 @@ import Settings from "./types/settings";
|
|||||||
const SETTINGS_ID = "SSR"; // DO NOT CHANGE
|
const SETTINGS_ID = "SSR"; // DO NOT CHANGE
|
||||||
|
|
||||||
export default class Database extends Dexie {
|
export default class Database extends Dexie {
|
||||||
|
/**
|
||||||
|
* The settings for the website.
|
||||||
|
*/
|
||||||
settings!: EntityTable<Settings, "id">;
|
settings!: EntityTable<Settings, "id">;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -31,6 +34,9 @@ export default class Database extends Dexie {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populates the default settings
|
||||||
|
*/
|
||||||
async populateDefaults() {
|
async populateDefaults() {
|
||||||
await this.settings.add({
|
await this.settings.add({
|
||||||
id: SETTINGS_ID, // Fixed ID for the single settings object
|
id: SETTINGS_ID, // Fixed ID for the single settings object
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
import Leaderboard from "../leaderboard";
|
import Leaderboard from "../leaderboard";
|
||||||
|
import { ScoreSort } from "../sort";
|
||||||
import ScoreSaberPlayer from "../types/scoresaber/scoresaber-player";
|
import ScoreSaberPlayer from "../types/scoresaber/scoresaber-player";
|
||||||
|
import ScoreSaberPlayerScoresPage from "../types/scoresaber/scoresaber-player-scores-page";
|
||||||
import { ScoreSaberPlayerSearch } from "../types/scoresaber/scoresaber-player-search";
|
import { ScoreSaberPlayerSearch } from "../types/scoresaber/scoresaber-player-search";
|
||||||
|
|
||||||
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`;
|
||||||
const LOOKUP_PLAYER_ENDPOINT = `${API_BASE}/player/{playerId}/full`;
|
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 {
|
class ScoreSaberLeaderboard extends Leaderboard {
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -20,19 +23,15 @@ class ScoreSaberLeaderboard extends Leaderboard {
|
|||||||
*/
|
*/
|
||||||
async searchPlayers(query: string, useProxy = true): Promise<ScoreSaberPlayerSearch | undefined> {
|
async searchPlayers(query: string, useProxy = true): Promise<ScoreSaberPlayerSearch | undefined> {
|
||||||
this.log(`Searching for players matching "${query}"...`);
|
this.log(`Searching for players matching "${query}"...`);
|
||||||
try {
|
|
||||||
const results = await this.fetch<ScoreSaberPlayerSearch>(
|
const results = await this.fetch<ScoreSaberPlayerSearch>(
|
||||||
useProxy,
|
useProxy,
|
||||||
SEARCH_PLAYERS_ENDPOINT.replace("{query}", query)
|
SEARCH_PLAYERS_ENDPOINT.replace(":query", query)
|
||||||
);
|
);
|
||||||
if (results.players.length === 0) {
|
if (results.players.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
results.players.sort((a, b) => a.rank - b.rank);
|
results.players.sort((a, b) => a.rank - b.rank);
|
||||||
return results;
|
return results;
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -44,11 +43,32 @@ class ScoreSaberLeaderboard extends Leaderboard {
|
|||||||
*/
|
*/
|
||||||
async lookupPlayer(playerId: string, useProxy = true): Promise<ScoreSaberPlayer | undefined> {
|
async lookupPlayer(playerId: string, useProxy = true): Promise<ScoreSaberPlayer | undefined> {
|
||||||
this.log(`Looking up player "${playerId}"...`);
|
this.log(`Looking up player "${playerId}"...`);
|
||||||
try {
|
return await this.fetch<ScoreSaberPlayer>(useProxy, LOOKUP_PLAYER_ENDPOINT.replace(":id", playerId));
|
||||||
return await this.fetch<ScoreSaberPlayer>(useProxy, LOOKUP_PLAYER_ENDPOINT.replace("{playerId}", playerId));
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,6 +38,7 @@ export default class Leaderboard {
|
|||||||
* @returns the fetched data
|
* @returns the fetched data
|
||||||
*/
|
*/
|
||||||
public async fetch<T>(useProxy: boolean, url: string): Promise<T> {
|
public async fetch<T>(useProxy: boolean, url: string): Promise<T> {
|
||||||
|
try {
|
||||||
return await ky
|
return await ky
|
||||||
.get<T>(this.buildRequestUrl(useProxy, url), {
|
.get<T>(this.buildRequestUrl(useProxy, url), {
|
||||||
next: {
|
next: {
|
||||||
@ -45,5 +46,9 @@ export default class Leaderboard {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.json();
|
.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)}
|
src={getImageUrl(backgroundImage)}
|
||||||
alt="Background image"
|
alt="Background image"
|
||||||
fetchPriority="high"
|
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";
|
"use client";
|
||||||
|
|
||||||
import { scoresaberLeaderboard } from "@/app/common/leaderboard/impl/scoresaber";
|
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 ScoreSaberPlayer from "@/app/common/leaderboard/types/scoresaber/scoresaber-player";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
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";
|
||||||
|
|
||||||
const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
initalPlayerData: ScoreSaberPlayer;
|
initalPlayerData: ScoreSaberPlayer;
|
||||||
|
sort: ScoreSort;
|
||||||
|
page: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PlayerData({ initalPlayerData }: Props) {
|
export default function PlayerData({ initalPlayerData, 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],
|
||||||
@ -28,6 +32,7 @@ export default function PlayerData({ initalPlayerData }: Props) {
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<PlayerHeader player={player} />
|
<PlayerHeader player={player} />
|
||||||
<PlayerRankChart player={player} />
|
<PlayerRankChart player={player} />
|
||||||
|
<PlayerScores player={player} sort={sort} page={page} />
|
||||||
</div>
|
</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