add bsr, map and yt buttons to scores
Some checks failed
Deploy SSR / deploy (push) Has been cancelled

This commit is contained in:
Lee
2024-09-11 23:10:16 +01:00
parent 74f595e11a
commit b5df147728
77 changed files with 899 additions and 657 deletions

View File

@ -0,0 +1,8 @@
/**
* Copies the given string to the clipboard
*
* @param str the string to copy
*/
export function copyToClipboard(str: string) {
navigator.clipboard.writeText(str);
}

View File

@ -0,0 +1,54 @@
import ky from "ky";
export default class DataFetcher {
/**
* The name of the leaderboard.
*/
private name: string;
constructor(name: string) {
this.name = name;
}
/**
* Logs a message to the console.
*
* @param data the data to log
*/
public log(data: unknown) {
console.log(`[${this.name}]: ${data}`);
}
/**
* Builds a request url.
*
* @param useProxy whether to use proxy or not
* @param url the url to fetch
* @returns the request url
*/
private buildRequestUrl(useProxy: boolean, url: string): string {
return (useProxy ? "https://proxy.fascinated.cc/" : "") + url;
}
/**
* Fetches data from the given url.
*
* @param useProxy whether to use proxy or not
* @param url the url to fetch
* @returns the fetched data
*/
public async fetch<T>(useProxy: boolean, url: string): Promise<T> {
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,49 @@
import { db } from "../../database/database";
import DataFetcher from "../data-fetcher";
import { BeatSaverMap } from "../types/beatsaver/beatsaver-map";
const API_BASE = "https://api.beatsaver.com";
const LOOKUP_MAP_BY_HASH_ENDPOINT = `${API_BASE}/maps/hash/:query`;
class BeatSaverFetcher extends DataFetcher {
constructor() {
super("BeatSaver");
}
/**
* Gets the map that match the query.
*
* @param query the query to search for
* @param useProxy whether to use the proxy or not
* @returns the map that match the query, or undefined if no map were found
*/
async getMapBsr(query: string, useProxy = true): Promise<string | undefined> {
this.log(`Looking up the bsr for the map with hash ${query}...`);
const map = await db.beatSaverMaps.get(query);
// The map is cached
if (map != undefined) {
return map.bsr;
}
const response = await this.fetch<BeatSaverMap>(useProxy, LOOKUP_MAP_BY_HASH_ENDPOINT.replace(":query", query));
// Map not found
if (response == undefined) {
return undefined;
}
const bsr = response.id;
if (bsr == undefined) {
return undefined;
}
// Save map the the db
await db.beatSaverMaps.add({
hash: query,
bsr: bsr,
});
return bsr;
}
}
export const beatsaverFetcher = new BeatSaverFetcher();

View File

@ -0,0 +1,75 @@
import DataFetcher from "../data-fetcher";
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/:id/full`;
const LOOKUP_PLAYER_SCORES_ENDPOINT = `${API_BASE}/player/:id/scores?limit=:limit&sort=:sort&page=:page`;
class ScoreSaberFetcher extends DataFetcher {
constructor() {
super("ScoreSaber");
}
/**
* Gets the players that match the query.
*
* @param query the query to search for
* @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<ScoreSaberPlayerSearch | undefined> {
this.log(`Searching for players matching "${query}"...`);
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;
}
/**
* Looks up a player by their ID.
*
* @param playerId the ID of the player to look up
* @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<ScoreSaberPlayer | undefined> {
this.log(`Looking up player "${playerId}"...`);
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())
);
}
}
export const scoresaberFetcher = new ScoreSaberFetcher();

View File

@ -0,0 +1,4 @@
export enum ScoreSort {
top = "top",
recent = "recent",
}

View File

@ -0,0 +1,51 @@
export default interface BeatSaverAccount {
/**
* The id of the mapper
*/
id: number;
/**
* The name of the mapper.
*/
name: string;
/**
* The account hash of the mapper.
*/
hash: string;
/**
* The avatar url for the mapper.
*/
avatar: string;
/**
* The way the account was created
*/
type: string;
/**
* Whether the account is an admin or not.
*/
admin: boolean;
/**
* Whether the account is a curator or not.
*/
curator: boolean;
/**
* Whether the account is a senior curator or not.
*/
seniorCurator: boolean;
/**
* Whether the account is a verified mapper or not.
*/
verifiedMapper: boolean;
/**
* The playlist for the mappers songs.
*/
playlistUrl: string;
}

View File

@ -0,0 +1,36 @@
export default interface BeatSaverMapMetadata {
/**
* The bpm of the song.
*/
bpm: number;
/**
* The song's length in seconds.
*/
duration: number;
/**
* The song's name.
*/
songName: string;
/**
* The songs sub name.
*/
songSubName: string;
/**
* The artist(s) name.
*/
songAuthorName: string;
/**
* The song's author's url.
*/
songAuthorUrl: string;
/**
* The level mapper(s) name.
*/
levelAuthorName: string;
}

View File

@ -0,0 +1,31 @@
export default interface BeatSaverMapStats {
/**
* The amount of time the map has been played.
*/
plays: number;
/**
* The amount of times the map has been downloaded.
*/
downloads: number;
/**
* The amount of times the map has been upvoted.
*/
upvotes: number;
/**
* The amount of times the map has been downvoted.
*/
downvotes: number;
/**
* The score for the map
*/
score: number;
/**
* The amount of reviews for the map.
*/
reviews: number;
}

View File

@ -0,0 +1,24 @@
import BeatSaverAccount from "./beatsaver-account";
import BeatSaverMapMetadata from "./beatsaver-map-metadata";
import BeatSaverMapStats from "./beatsaver-map-stats";
export interface BeatSaverMap {
id: string;
name: string;
description: string;
uploader: BeatSaverAccount;
metadata: BeatSaverMapMetadata;
stats: BeatSaverMapStats;
uploaded: string;
automapper: boolean;
ranked: boolean;
qualified: boolean;
// todo: versions
createdAt: string;
updatedAt: string;
lastPublishedAt: string;
tags: string[];
declaredAi: string;
blRanked: boolean;
blQualified: boolean;
}

View File

@ -0,0 +1,11 @@
export interface ScoreSaberBadge {
/**
* The description of the badge.
*/
description: string;
/**
* The image of the badge.
*/
image: string;
}

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,16 @@
export default interface ScoreSaberMetadata {
/**
* The total amount of returned results.
*/
total: number;
/**
* The current page
*/
page: number;
/**
* The amount of results per page
*/
itemsPerPage: number;
}

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,8 @@
import ScoreSaberPlayer from "./scoresaber-player";
export interface ScoreSaberPlayerSearch {
/**
* The players that were found
*/
players: ScoreSaberPlayer[];
}

View File

@ -0,0 +1,84 @@
import { ScoreSaberBadge } from "./scoresaber-badge";
import ScoreSaberScoreStats from "./scoresaber-score-stats";
export default interface ScoreSaberPlayer {
/**
* The ID of the player.
*/
id: string;
/**
* The name of the player.
*/
name: string;
/**
* The profile picture of the player.
*/
profilePicture: string;
/**
* The bio of the player.
*/
bio: string | null;
/**
* The country of the player.
*/
country: string;
/**
* The amount of pp the player has.
*/
pp: number;
/**
* The rank of the player.
*/
rank: number;
/**
* The rank the player has in their country.
*/
countryRank: number;
/**
* The role of the player.
*/
role: string | null;
/**
* The badges the player has.
*/
badges: ScoreSaberBadge[] | null;
/**
* The previous 50 days of rank history.
*/
histories: string;
/**
* The score stats of the player.
*/
scoreStats: ScoreSaberScoreStats;
/**
* The permissions of the player. (bitwise)
*/
permissions: number;
/**
* Whether the player is banned or not.
*/
banned: boolean;
/**
* Whether the player is inactive or not.
*/
inactive: boolean;
/**
* The date the player joined ScoreSaber.
*/
firstSeen: string;
}

View File

@ -0,0 +1,31 @@
export default interface ScoreSaberScoreStats {
/**
* 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;
}

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,75 @@
import Dexie, { EntityTable } from "dexie";
import { setPlayerIdCookie } from "../website-utils";
import BeatSaverMap from "./types/beatsaver-map";
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">;
/**
* BeatSaver maps
*/
beatSaverMaps!: EntityTable<BeatSaverMap, "hash">;
constructor() {
super("ScoreSaberReloaded");
// Stores
this.version(1).stores({
settings: "id",
beatSaverMaps: "hash",
});
// Mapped tables
this.settings.mapToClass(Settings);
this.beatSaverMaps.mapToClass(BeatSaverMap);
// Populate default settings if the table is empty
this.on("populate", () => this.populateDefaults());
this.on("ready", async () => {
const settings = await this.getSettings();
// If the settings are not found, return
if (settings == undefined || settings.playerId == undefined) {
return;
}
setPlayerIdCookie(settings.playerId);
});
}
/**
* Populates the default settings
*/
async populateDefaults() {
await this.settings.add({
id: SETTINGS_ID, // Fixed ID for the single settings object
backgroundImage: "/assets/background.jpg",
});
}
/**
* Gets the settings from the database
*
* @returns the settings
*/
async getSettings(): Promise<Settings | undefined> {
return await this.settings.get(SETTINGS_ID);
}
/**
* Sets the settings in the database
*
* @param settings the settings to set
* @returns the settings
*/
async setSettings(settings: Settings) {
return await this.settings.update(SETTINGS_ID, settings);
}
}
export const db = new Database();

View File

@ -0,0 +1,17 @@
import { Entity } from "dexie";
import Database from "../database";
/**
* A beat saver map.
*/
export default class BeatSaverMap extends Entity<Database> {
/**
* The hash of the map.
*/
hash!: string;
/**
* The bsr code for the map.
*/
bsr!: string;
}

View File

@ -0,0 +1,42 @@
import { Entity } from "dexie";
import Database from "../database";
/**
* The website settings.
*/
export default class Settings extends Entity<Database> {
/**
* This is just so we can fetch the settings
*/
id!: string;
/**
* The ID of the tracked player
*/
playerId?: string;
/**
* The background image to use
*/
backgroundImage?: string;
/**
* Sets the players id
*
* @param id the new player id
*/
public setPlayerId(id: string) {
this.playerId = id;
this.db.setSettings(this);
}
/**
* Sets the background image
*
* @param image the new background image
*/
public setBackgroundImage(image: string) {
this.backgroundImage = image;
this.db.setSettings(this);
}
}

11
src/common/image-utils.ts Normal file
View File

@ -0,0 +1,11 @@
import { config } from "../../config";
/**
* Proxies all non-localhost images to make them load faster.
*
* @param originalUrl the original image url
* @returns the new image url
*/
export function getImageUrl(originalUrl: string) {
return `${!config.siteUrl.includes("localhost") ? "https://img.fascinated.cc/upload/q_70/" : ""}${originalUrl}`;
}

View File

@ -0,0 +1,9 @@
/**
* Formats a number without trailing zeros.
*
* @param num the number to format
* @returns the formatted number
*/
export function formatNumberWithCommas(num: number) {
return num.toLocaleString();
}

22
src/common/song-utils.ts Normal file
View File

@ -0,0 +1,22 @@
/**
* Turns a song name and author into a YouTube link
*
* @param name the name of the song
* @param songSubName the sub name of the song
* @param author the author of the song
* @returns the YouTube link for the song
*/
export function songNameToYouTubeLink(name: string, songSubName: string, author: string) {
const baseUrl = "https://www.youtube.com/results?search_query=";
let query = "";
if (name) {
query += `${name} `;
}
if (songSubName) {
query += `${songSubName} `;
}
if (author) {
query += `${author} `;
}
return encodeURI(baseUrl + query.trim().replaceAll(" ", "+"));
}

View File

@ -0,0 +1,9 @@
/**
* Capitalizes the first letter of a string.
*
* @param str the string to capitalize
* @returns the capitalized string
*/
export function capitalizeFirstLetter(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}

26
src/common/time-utils.ts Normal file
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 } = {
year: 3600 * 24 * 365,
month: 3600 * 24 * 30,
week: 3600 * 24 * 7,
day: 3600 * 24,
hour: 3600,
minute: 60,
second: 1,
};
const secondsElapsed = (date.getTime() - Date.now()) / 1000;
for (const key in ranges) {
if (ranges[key] < Math.abs(secondsElapsed)) {
const delta = secondsElapsed / ranges[key];
return formatter.format(Math.round(delta), key as Intl.RelativeTimeFormatUnit);
}
}
}

6
src/common/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -0,0 +1,8 @@
/**
* Sets the player id cookie
*
* @param playerId the player id to set
*/
export function setPlayerIdCookie(playerId: string) {
document.cookie = `playerId=${playerId}`;
}