start backend work

This commit is contained in:
Lee
2024-10-08 15:32:02 +01:00
parent 04ce91b459
commit aa0a0c4c16
445 changed files with 367 additions and 11413 deletions

View File

@ -0,0 +1,18 @@
/**
* Copies the given string to the clipboard
*
* @param str the string to copy
*/
export function copyToClipboard(str: string) {
navigator.clipboard.writeText(str);
}
/**
* Checks if the current context is a worker
*/
export function isRunningAsWorker() {
if (typeof window === "undefined") {
return false;
}
return navigator.constructor.name === "WorkerNavigator";
}

View File

@ -0,0 +1,3 @@
export const Colors = {
primary: "#0070f3",
};

View File

@ -0,0 +1,85 @@
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">;
/**
* Cached 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.resetSettings();
}
/**
* Gets the settings from the database
*
* @returns the settings
*/
async getSettings(): Promise<Settings | undefined> {
return 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 this.settings.update(SETTINGS_ID, settings);
}
/**
* Resets the settings in the database
*/
async resetSettings() {
this.settings.delete(SETTINGS_ID);
this.settings.add({
id: SETTINGS_ID, // Fixed ID for the single settings object
backgroundCover: "/assets/background.jpg",
});
return this.getSettings();
}
}
export const db = new Database();

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 or color to use
*/
backgroundCover?: 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.backgroundCover = image;
this.db.setSettings(this);
}
}

View File

@ -0,0 +1,24 @@
import { config } from "../../config";
import { cache } from "react";
/**
* 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}`;
}
/**
* Gets the average color of an image
*
* @param src the image url
* @returns the average color
*/
export const getAverageColor = cache(async (src: string) => {
return {
hex: "#fff",
};
});

View File

@ -0,0 +1,12 @@
import * as mongoose from "mongoose";
/**
* Connects to the mongo database
*/
export async function connectMongo() {
const connectionUri = process.env.MONGO_URI;
if (!connectionUri) {
throw new Error("Missing MONGO_URI");
}
await mongoose.connect(connectionUri);
}

View File

@ -0,0 +1,19 @@
/**
* Formats a number without trailing zeros.
*
* @param num the number to format
* @returns the formatted number
*/
export function formatNumberWithCommas(num: number) {
return num.toLocaleString();
}
/**
* Formats the pp value
*
* @param num the pp to format
* @returns the formatted pp
*/
export function formatPp(num: number) {
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}

View File

@ -0,0 +1,26 @@
import { PlayerHistory } from "@/common/player/player-history";
/**
* Gets a value from an {@link PlayerHistory}
* based on the field
*
* @param history the history to get the value from
* @param field the field to get
*/
export function getValueFromHistory(history: PlayerHistory, field: string): number | null {
const keys = field.split(".");
/* eslint-disable @typescript-eslint/no-explicit-any */
let value: any = history;
// Navigate through the keys safely
for (const key of keys) {
if (value && key in value) {
value = value[key];
} else {
return null; // Return null if the key doesn't exist
}
}
// Ensure we return a number or null
return typeof value === "number" ? value : null;
}

View File

@ -0,0 +1,27 @@
/**
* Formats the ScoreSaber difficulty number
*
* @param diff the diffuiclity number
*/
export function getDifficultyFromScoreSaberDifficulty(diff: number) {
switch (diff) {
case 1: {
return "Easy";
}
case 3: {
return "Normal";
}
case 5: {
return "Hard";
}
case 7: {
return "Expert";
}
case 9: {
return "Expert+";
}
default: {
return "Unknown";
}
}
}

View File

@ -0,0 +1,101 @@
type Difficulty = {
name: DifficultyName;
gamemode?: string;
color: string;
};
type DifficultyName = "Easy" | "Normal" | "Hard" | "Expert" | "Expert+";
const difficulties: Difficulty[] = [
{ name: "Easy", color: "#59b0f4" },
{ name: "Normal", color: "#59b0f4" },
{ name: "Hard", color: "#FF6347" },
{ name: "Expert", color: "#bf2a42" },
{ name: "Expert+", color: "#8f48db" },
];
export type ScoreBadge = {
name: string;
min: number | null;
max: number | null;
color: string;
};
const scoreBadges: ScoreBadge[] = [
{ name: "SS+", min: 95, max: null, color: getDifficulty("Expert+")!.color },
{ name: "SS", min: 90, max: 95, color: getDifficulty("Expert")!.color },
{ name: "S+", min: 85, max: 90, color: getDifficulty("Hard")!.color },
{ name: "S", min: 80, max: 85, color: getDifficulty("Normal")!.color },
{ name: "A", min: 70, max: 80, color: getDifficulty("Easy")!.color },
{ name: "-", min: null, max: 70, color: "hsl(var(--accent))" },
];
/**
* Returns the color based on the accuracy provided.
*
* @param acc - The accuracy for the score
* @returns The corresponding color for the accuracy.
*/
export function getScoreBadgeFromAccuracy(acc: number): ScoreBadge {
// Check for SS+ first since it has no upper limit
if (acc >= 95) {
return scoreBadges[0]; // SS+ color
}
// Iterate through the rest of the badges
for (const badge of scoreBadges) {
const min = badge.min ?? -Infinity; // Treat null `min` as -Infinity
const max = badge.max ?? Infinity; // Treat null `max` as Infinity
// Check if the accuracy falls within the badge's range
if (acc >= min && acc < (max === null ? Infinity : max)) {
return badge; // Return the color of the matching badge
}
}
// Fallback color if no badge matches (should not happen)
return scoreBadges[scoreBadges.length - 1];
}
/**
* Parses a raw difficulty into a {@link Difficulty}
* Example: _Easy_SoloStandard -> { name: "Easy", type: "Standard", color: "#59b0f4" }
*
* @param rawDifficulty the raw difficulty to parse
* @return the parsed difficulty
*/
export function getDifficultyFromRawDifficulty(rawDifficulty: string): Difficulty {
const [name, ...type] = rawDifficulty
.replace("Plus", "+") // Replaces Plus with + so we can match it to our difficulty names
.replace("Solo", "") // Removes "Solo"
.replace(/^_+|_+$/g, "") // Removes leading and trailing underscores
.split("_");
const difficulty = difficulties.find(d => d.name === name);
if (!difficulty) {
throw new Error(`Unknown difficulty: ${rawDifficulty}`);
}
return {
...difficulty,
gamemode: type.join("_"),
};
}
/**
* Gets a {@link Difficulty} from its name
*
* @param diff the name of the difficulty
* @returns the difficulty
*/
export function getDifficulty(diff: DifficultyName) {
return difficulties.find(d => d.name === diff);
}
/**
* Turns the difficulty of a song into a color
*
* @param diff the difficulty to get the color for
* @returns the color for the difficulty
*/
export function songDifficultyToColor(diff: string) {
return getDifficultyFromRawDifficulty(diff).color;
}

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

View File

@ -0,0 +1,21 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Validates if the url is valid
*
* @param url the url to validate
* @returns true if the url is valid, false otherwise
*/
export function validateUrl(url: string) {
try {
new URL(url);
return true;
} catch {
return false;
}
}

View File

@ -0,0 +1,34 @@
import Cookies from "js-cookie";
/**
* Sets the player id cookie
*
* @param playerId the player id to set
*/
export function setPlayerIdCookie(playerId: string) {
Cookies.set("playerId", playerId, { path: "/" });
}
/**
* Gets if we're in production
*/
export function isProduction() {
return process.env.NODE_ENV === "production";
}
/**
* Gets the build information
*
* @returns the build information
*/
export function getBuildInformation() {
const buildId = process.env.NEXT_PUBLIC_BUILD_ID
? isProduction()
? process.env.NEXT_PUBLIC_BUILD_ID.slice(0, 7)
: "dev"
: "";
const buildTime = process.env.NEXT_PUBLIC_BUILD_TIME;
const buildTimeShort = process.env.NEXT_PUBLIC_BUILD_TIME_SHORT;
return { buildId, buildTime, buildTimeShort };
}

View File

@ -0,0 +1,16 @@
import * as Comlink from "comlink";
import { scoresaberService } from "@/common/service/impl/scoresaber";
export interface WorkerApi {
getPlayerExample: typeof getPlayerExample;
}
const workerApi: WorkerApi = {
getPlayerExample: getPlayerExample,
};
async function getPlayerExample() {
return await scoresaberService.lookupPlayer("76561198449412074");
}
Comlink.expose(workerApi);

View File

@ -0,0 +1,5 @@
import * as Comlink from "comlink";
import { WorkerApi } from "@/common/worker/worker";
export const scoresaberReloadedWorker = () =>
Comlink.wrap<WorkerApi>(new Worker(new URL("./worker.ts", import.meta.url)));

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(" ", "+"));
}