add map stats from beat saver
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Successful in 47s
Deploy Website / docker (ubuntu-latest) (push) Failing after 1m34s

This commit is contained in:
Lee 2024-10-23 15:33:25 +01:00
parent 62090b8054
commit 33b931b5f1
47 changed files with 835 additions and 289 deletions

@ -1,5 +1,5 @@
import { beatsaverService } from "@ssr/common/service/impl/beatsaver"; import { beatsaverService } from "@ssr/common/service/impl/beatsaver";
import { BeatSaverMap, BeatSaverMapModel } from "@ssr/common/model/beatsaver/beatsaver-map"; import { BeatSaverMap, BeatSaverMapModel } from "@ssr/common/model/beatsaver/map";
export default class BeatSaverService { export default class BeatSaverService {
/** /**
@ -12,28 +12,71 @@ export default class BeatSaverService {
let map = await BeatSaverMapModel.findById(hash); let map = await BeatSaverMapModel.findById(hash);
if (map != undefined) { if (map != undefined) {
const toObject = map.toObject() as BeatSaverMap; const toObject = map.toObject() as BeatSaverMap;
if (toObject.unknownMap) { if (toObject.notFound) {
return undefined; return undefined;
} }
return toObject; return toObject;
} }
const token = await beatsaverService.lookupMap(hash); const token = await beatsaverService.lookupMap(hash);
const uploader = token?.uploader;
const metadata = token?.metadata;
map = await BeatSaverMapModel.create( map = await BeatSaverMapModel.create(
token token && uploader && metadata
? { ? // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
({
_id: hash, _id: hash,
bsr: token.id, bsr: token.id,
name: token.name,
description: token.description,
author: { author: {
id: token.uploader.id, id: uploader.id,
name: uploader.name,
avatar: uploader.avatar,
}, },
} metadata: {
bpm: metadata.bpm,
duration: metadata.duration,
levelAuthorName: metadata.levelAuthorName,
songAuthorName: metadata.songAuthorName,
songName: metadata.songName,
songSubName: metadata.songSubName,
},
versions: token.versions.map(version => {
return {
hash: version.hash.toUpperCase(),
difficulties: version.diffs.map(diff => {
return {
njs: diff.njs,
offset: diff.offset,
notes: diff.notes,
bombs: diff.bombs,
obstacles: diff.obstacles,
nps: diff.nps,
characteristic: diff.characteristic,
difficulty: diff.difficulty,
events: diff.events,
chroma: diff.chroma,
mappingExtensions: diff.me,
noodleExtensions: diff.ne,
cinema: diff.cinema,
maxScore: diff.maxScore,
label: diff.label,
};
}),
createdAt: new Date(version.createdAt),
};
}),
lastRefreshed: new Date(),
} as BeatSaverMap)
: { : {
_id: hash, _id: hash,
unknownMap: true, notFound: true,
} }
); );
if (map.unknownMap) { if (map.notFound) {
return undefined; return undefined;
} }
return map.toObject() as BeatSaverMap; return map.toObject() as BeatSaverMap;

@ -7,7 +7,7 @@ import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score
import { NotFoundError } from "elysia"; import { NotFoundError } from "elysia";
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; import { getScoreSaberLeaderboardFromToken } from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import BeatSaverService from "./beatsaver.service"; import BeatSaverService from "./beatsaver.service";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map"; import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
const leaderboardCache = new SSRCache({ const leaderboardCache = new SSRCache({
ttl: 1000 * 60 * 60 * 24, ttl: 1000 * 60 * 60 * 24,

@ -13,7 +13,7 @@ import { ScoreSort } from "@ssr/common/score/score-sort";
import { Leaderboards } from "@ssr/common/leaderboard"; import { Leaderboards } from "@ssr/common/leaderboard";
import Leaderboard from "@ssr/common/leaderboard/leaderboard"; import Leaderboard from "@ssr/common/leaderboard/leaderboard";
import LeaderboardService from "./leaderboard.service"; import LeaderboardService from "./leaderboard.service";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map"; import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
import { PlayerScore } from "@ssr/common/score/player-score"; import { PlayerScore } from "@ssr/common/score/player-score";
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response"; import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
import Score from "@ssr/common/score/score"; import Score from "@ssr/common/score/score";
@ -188,7 +188,7 @@ export class ScoreService {
}; };
const difficulty = leaderboard.difficulty; const difficulty = leaderboard.difficulty;
const difficultyKey = `${difficulty.difficultyName.replace("Plus", "+")}-${difficulty.modeName}`; const difficultyKey = `${difficulty.difficultyName}-${difficulty.modeName}`;
const rawScoreImprovement = score.scoreImprovement; const rawScoreImprovement = score.scoreImprovement;
const data = { const data = {
playerId: playerId, playerId: playerId,
@ -312,15 +312,15 @@ export class ScoreService {
if (score == undefined) { if (score == undefined) {
continue; continue;
} }
const tokenLeaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard); const leaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard);
if (tokenLeaderboard == undefined) { if (leaderboard == undefined) {
continue; continue;
} }
const additionalData = await this.getAdditionalScoreData( const additionalData = await this.getAdditionalScoreData(
id, id,
tokenLeaderboard.songHash, leaderboard.songHash,
`${tokenLeaderboard.difficulty.difficulty}-${tokenLeaderboard.difficulty.gameMode}`, `${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`,
score.score score.score
); );
if (additionalData !== undefined) { if (additionalData !== undefined) {
@ -329,8 +329,8 @@ export class ScoreService {
scores.push({ scores.push({
score: score, score: score,
leaderboard: tokenLeaderboard, leaderboard: leaderboard,
beatSaver: await BeatSaverService.getMap(tokenLeaderboard.songHash), beatSaver: await BeatSaverService.getMap(leaderboard.songHash),
}); });
} }
break; break;

@ -4,6 +4,7 @@ import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber
import { getDifficultyFromScoreSaberDifficulty } from "../../utils/scoresaber-utils"; import { getDifficultyFromScoreSaberDifficulty } from "../../utils/scoresaber-utils";
import { parseDate } from "../../utils/time-utils"; import { parseDate } from "../../utils/time-utils";
import { LeaderboardStatus } from "../leaderboard-status"; import { LeaderboardStatus } from "../leaderboard-status";
import { MapCharacteristic } from "../../types/map-characteristic";
export default interface ScoreSaberLeaderboard extends Leaderboard { export default interface ScoreSaberLeaderboard extends Leaderboard {
/** /**
@ -41,7 +42,7 @@ export function getScoreSaberLeaderboardFromToken(token: ScoreSaberLeaderboardTo
const difficulty: LeaderboardDifficulty = { const difficulty: LeaderboardDifficulty = {
leaderboardId: token.difficulty.leaderboardId, leaderboardId: token.difficulty.leaderboardId,
difficulty: getDifficultyFromScoreSaberDifficulty(token.difficulty.difficulty), difficulty: getDifficultyFromScoreSaberDifficulty(token.difficulty.difficulty),
gameMode: token.difficulty.gameMode.replace("Solo", ""), characteristic: token.difficulty.gameMode.replace("Solo", "") as MapCharacteristic,
difficultyRaw: token.difficulty.difficultyRaw, difficultyRaw: token.difficulty.difficultyRaw,
}; };
@ -66,7 +67,7 @@ export function getScoreSaberLeaderboardFromToken(token: ScoreSaberLeaderboardTo
return { return {
leaderboardId: difficulty.leaderboardId, leaderboardId: difficulty.leaderboardId,
difficulty: getDifficultyFromScoreSaberDifficulty(difficulty.difficulty), difficulty: getDifficultyFromScoreSaberDifficulty(difficulty.difficulty),
gameMode: difficulty.gameMode.replace("Solo", ""), characteristic: difficulty.gameMode.replace("Solo", "") as MapCharacteristic,
difficultyRaw: difficulty.difficultyRaw, difficultyRaw: difficulty.difficultyRaw,
}; };
}) })

@ -1,4 +1,5 @@
import { Difficulty } from "../score/difficulty"; import { MapDifficulty } from "../score/map-difficulty";
import { MapCharacteristic } from "../types/map-characteristic";
export default interface LeaderboardDifficulty { export default interface LeaderboardDifficulty {
/** /**
@ -9,12 +10,12 @@ export default interface LeaderboardDifficulty {
/** /**
* The difficulty of the leaderboard. * The difficulty of the leaderboard.
*/ */
difficulty: Difficulty; difficulty: MapDifficulty;
/** /**
* The game mode of the leaderboard. * The characteristic of the leaderboard.
*/ */
gameMode: string; characteristic: MapCharacteristic;
/** /**
* The raw difficulty of the leaderboard. * The raw difficulty of the leaderboard.

@ -0,0 +1,27 @@
import { prop } from "@typegoose/typegoose";
export default class BeatSaverAuthor {
/**
* The id of the author.
*/
@prop({ required: true })
id: number;
/**
* The name of the mapper.
*/
@prop({ required: true })
name: string;
/**
* The avatar URL for the mapper.
*/
@prop({ required: true })
avatar: string;
constructor(id: number, name: string, avatar: string) {
this.id = id;
this.name = name;
this.avatar = avatar;
}
}

@ -1,13 +0,0 @@
import { prop } from "@typegoose/typegoose";
export default class BeatsaverAuthor {
/**
* The id of the author.
*/
@prop({ required: true })
id: number;
constructor(id: number) {
this.id = id;
}
}

@ -1,57 +0,0 @@
import { getModelForClass, modelOptions, prop, ReturnModelType, Severity } from "@typegoose/typegoose";
import { Document } from "mongoose";
import BeatsaverAuthor from "./beatsaver-author";
/**
* The model for a BeatSaver map.
*/
@modelOptions({
options: { allowMixed: Severity.ALLOW },
schemaOptions: {
toObject: {
virtuals: true,
transform: function (_, ret) {
ret.id = ret._id;
delete ret._id;
delete ret.__v;
return ret;
},
},
},
})
export class BeatSaverMap {
/**
* The internal MongoDB ID (_id).
*/
@prop({ required: true })
private _id!: string;
/**
* The bsr code for the map.
* @private
*/
@prop({ required: false })
public bsr!: string;
/**
* The author of the map.
*/
@prop({ required: false, _id: false, type: () => BeatsaverAuthor })
public author!: BeatsaverAuthor;
/**
* True if the map is unknown on beatsaver.
*/
@prop({ required: false })
public unknownMap?: boolean;
/**
* Exposes `id` as a virtual field mapped from `_id`.
*/
public get id(): string {
return this._id;
}
}
export type BeatSaverMapDocument = BeatSaverMap & Document;
export const BeatSaverMapModel: ReturnModelType<typeof BeatSaverMap> = getModelForClass(BeatSaverMap);

@ -0,0 +1,128 @@
import { prop } from "@typegoose/typegoose";
import { MapDifficulty } from "../../score/map-difficulty";
export default class BeatSaverMapDifficulty {
/**
* The NJS of this difficulty.
*/
@prop({ required: true })
njs: number;
/**
* The NJS offset of this difficulty.
*/
@prop({ required: true })
offset: number;
/**
* The amount of notes in this difficulty.
*/
@prop({ required: true })
notes: number;
/**
* The amount of bombs in this difficulty.
*/
@prop({ required: true })
bombs: number;
/**
* The amount of obstacles in this difficulty.
*/
@prop({ required: true })
obstacles: number;
/**
* The notes per second in this difficulty.
*/
@prop({ required: true })
nps: number;
/**
* The characteristic of this difficulty.
*/
@prop({ required: true, enum: ["Standard", "Lawless"] })
characteristic: "Standard" | "Lawless";
/**
* The difficulty level.
*/
@prop({ required: true })
difficulty: MapDifficulty;
/**
* The amount of lighting events in this difficulty.
*/
@prop({ required: true })
events: number;
/**
* Whether this difficulty uses Chroma.
*/
@prop({ required: true, default: false })
chroma: boolean;
/**
* Does this difficulty use Mapping Extensions.
*/
@prop({ required: true, default: false })
mappingExtensions: boolean;
/**
* Does this difficulty use Noodle Extensions.
*/
@prop({ required: true, default: false })
noodleExtensions: boolean;
/**
* Whether this difficulty uses cinema mode.
*/
@prop({ required: true, default: false })
cinema: boolean;
/**
* The maximum score achievable in this difficulty.
*/
@prop({ required: true })
maxScore: number;
/**
* The custom label for this difficulty.
*/
@prop()
label: string;
constructor(
njs: number,
offset: number,
notes: number,
bombs: number,
obstacles: number,
nps: number,
characteristic: "Standard" | "Lawless",
difficulty: MapDifficulty,
events: number,
chroma: boolean,
mappingExtensions: boolean,
noodleExtensions: boolean,
cinema: boolean,
maxScore: number,
label: string
) {
this.njs = njs;
this.offset = offset;
this.notes = notes;
this.bombs = bombs;
this.obstacles = obstacles;
this.nps = nps;
this.characteristic = characteristic;
this.difficulty = difficulty;
this.events = events;
this.chroma = chroma;
this.mappingExtensions = mappingExtensions;
this.noodleExtensions = noodleExtensions;
this.cinema = cinema;
this.maxScore = maxScore;
this.label = label;
}
}

@ -0,0 +1,55 @@
import { prop } from "@typegoose/typegoose";
export default class BeatSaverMapMetadata {
/**
* The bpm of the song.
*/
@prop({ required: true })
bpm: number;
/**
* The song's length in seconds.
*/
@prop({ required: true })
duration: number;
/**
* The song's name.
*/
@prop({ required: true })
songName: string;
/**
* The song's sub name.
*/
@prop({ required: false })
songSubName: string;
/**
* The artist(s) name.
*/
@prop({ required: true })
songAuthorName: string;
/**
* The level mapper(s) name.
*/
@prop({ required: true })
levelAuthorName: string;
constructor(
bpm: number,
duration: number,
songName: string,
songSubName: string,
songAuthorName: string,
levelAuthorName: string
) {
this.bpm = bpm;
this.duration = duration;
this.songName = songName;
this.songSubName = songSubName;
this.songAuthorName = songAuthorName;
this.levelAuthorName = levelAuthorName;
}
}

@ -0,0 +1,31 @@
import { modelOptions, prop, Severity } from "@typegoose/typegoose";
import BeatSaverMapDifficulty from "./map-difficulty";
@modelOptions({
options: { allowMixed: Severity.ALLOW },
})
export default class BeatSaverMapVersion {
/**
* The hash of this map.
*/
@prop({ required: true })
hash: string;
/**
* The date the map was created.
*/
@prop({ required: true })
createdAt: Date;
/**
* The difficulties of this map.
*/
@prop({ required: true })
difficulties: BeatSaverMapDifficulty[];
constructor(hash: string, createdAt: Date, difficulties: BeatSaverMapDifficulty[]) {
this.hash = hash;
this.createdAt = createdAt;
this.difficulties = difficulties;
}
}

@ -0,0 +1,99 @@
import { getModelForClass, modelOptions, prop, ReturnModelType, Severity } from "@typegoose/typegoose";
import { Document } from "mongoose";
import BeatSaverAuthor from "./author";
import BeatSaverMapVersion from "./map-version";
import BeatSaverMapMetadata from "./map-metadata";
/**
* The model for a BeatSaver map.
*/
@modelOptions({
options: { allowMixed: Severity.ALLOW },
schemaOptions: {
collection: "beatsaver-maps",
toObject: {
virtuals: true,
transform: function (_, ret) {
ret.id = ret._id;
delete ret._id;
delete ret.__v;
return ret;
},
},
},
})
export class BeatSaverMap {
/**
* The internal MongoDB ID (_id).
*/
@prop({ required: true })
protected _id!: string;
/**
* The name of the map.
*/
@prop({ required: false })
public name!: string;
/**
* The description of the map.
*/
@prop({ required: false })
public description!: string;
/**
* The bsr code for the map.
*/
@prop({ required: false })
public bsr!: string;
/**
* The author of the map.
*/
@prop({ required: false, _id: false, type: () => BeatSaverAuthor })
public author!: BeatSaverAuthor;
/**
* The versions of the map.
*/
@prop({ required: false, _id: false, type: () => [BeatSaverMapVersion] })
public versions!: BeatSaverMapVersion[];
/**
* The metadata of the map.
*/
@prop({ required: false, _id: false, type: () => BeatSaverMapMetadata })
public metadata!: BeatSaverMapMetadata;
/**
* True if the map is not found on beatsaver.
*/
@prop({ required: false })
public notFound?: boolean;
/**
* The last time the map data was refreshed.
*/
@prop({ required: true })
public lastRefreshed!: Date;
/**
* Exposes `id` as a virtual field mapped from `_id`.
*/
public get id(): string {
return this._id;
}
/**
* Should the map data be refreshed?
*
* @returns true if the map data should be refreshed
*/
public shouldRefresh(): boolean {
const now = new Date();
return now.getTime() - this.lastRefreshed.getTime() > 1000 * 60 * 60 * 24 * 3; // 3 days
}
}
export type BeatSaverMapDocument = BeatSaverMap & Document;
export const BeatSaverMapModel: ReturnModelType<typeof BeatSaverMap> = getModelForClass(BeatSaverMap);

@ -1,4 +1,4 @@
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map"; import { BeatSaverMap } from "../model/beatsaver/map";
export type LeaderboardResponse<L> = { export type LeaderboardResponse<L> = {
/** /**

@ -1,5 +1,5 @@
import { Metadata } from "../types/metadata"; import { Metadata } from "../types/metadata";
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map"; import { BeatSaverMap } from "../model/beatsaver/map";
export default interface LeaderboardScoresResponse<S, L> { export default interface LeaderboardScoresResponse<S, L> {
/** /**

@ -1 +0,0 @@
export type Difficulty = "Easy" | "Normal" | "Hard" | "Expert" | "Expert+" | "Unknown";

@ -0,0 +1 @@
export type MapDifficulty = "Easy" | "Normal" | "Hard" | "Expert" | "ExpertPlus" | "Unknown";

@ -1,4 +1,4 @@
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map"; import { BeatSaverMap } from "../model/beatsaver/map";
export interface PlayerScore<S, L> { export interface PlayerScore<S, L> {
/** /**

@ -1,5 +1,5 @@
import Service from "../service"; import Service from "../service";
import { BeatSaverMapToken } from "../../types/token/beatsaver/beat-saver-map-token"; import { BeatSaverMapToken } from "../../types/token/beatsaver/map";
const API_BASE = "https://api.beatsaver.com"; const API_BASE = "https://api.beatsaver.com";
const LOOKUP_MAP_BY_HASH_ENDPOINT = `${API_BASE}/maps/hash/:query`; const LOOKUP_MAP_BY_HASH_ENDPOINT = `${API_BASE}/maps/hash/:query`;

@ -0,0 +1 @@
export type MapCharacteristic = "Standard" | "Lawless";

@ -1,13 +0,0 @@
import { Metadata } from "./metadata";
export type Page<T> = {
/**
* The data to return.
*/
data: T[];
/**
* The metadata of the page.
*/
metadata: Metadata;
};

@ -1,24 +0,0 @@
import BeatSaverAccountToken from "./beat-saver-account-token";
import BeatSaverMapMetadataToken from "./beat-saver-map-metadata-token";
import BeatSaverMapStatsToken from "./beat-saver-map-stats-token";
export interface BeatSaverMapToken {
id: string;
name: string;
description: string;
uploader: BeatSaverAccountToken;
metadata: BeatSaverMapMetadataToken;
stats: BeatSaverMapStatsToken;
uploaded: string;
automapper: boolean;
ranked: boolean;
qualified: boolean;
// todo: versions
createdAt: string;
updatedAt: string;
lastPublishedAt: string;
tags: string[];
declaredAi: string;
blRanked: boolean;
blQualified: boolean;
}

@ -0,0 +1,16 @@
export type MapDifficultyParitySummaryToken = {
/**
* The amount of parity errors.
*/
errors: number;
/**
* The amount of parity warnings.
*/
warns: number;
/**
* The amount of resets in the difficulty.
*/
resets: number;
};

@ -0,0 +1,90 @@
import { MapDifficulty } from "../../../score/map-difficulty";
import { MapDifficultyParitySummaryToken } from "./difficulty-parity-summary";
export type BeatSaverMapDifficultyToken = {
/**
* The NJS of this difficulty.
*/
njs: number;
/**
* The NJS offset of this difficulty.
*/
offset: number;
/**
* The amount of notes in this difficulty.
*/
notes: number;
/**
* The amount of bombs in this difficulty.
*/
bombs: number;
/**
* The amount of obstacles in this difficulty.
*/
obstacles: number;
/**
* The notes per second in this difficulty.
*/
nps: number;
/**
* The length of this difficulty in seconds.
*/
length: number;
/**
* The characteristic of this difficulty.
*/
characteristic: "Standard" | "Lawless";
/**
* The difficulty of this difficulty.
*/
difficulty: MapDifficulty;
/**
* The amount of lighting events in this difficulty.
*/
events: number;
/**
* Whether this difficulty uses Chroma.
*/
chroma: boolean;
/**
* Quite frankly I have no fucking idea what these are.
*/
me: boolean;
ne: boolean;
/**
* Does this difficulty use cinema?
*/
cinema: boolean;
/**
* The length of this difficulty in seconds.
*/
seconds: number;
/**
* The parity summary of this difficulty.
*/
paritySummary: MapDifficultyParitySummaryToken;
/**
* The maximum score of this difficulty.
*/
maxScore: number;
/**
* The custom difficulty label.
*/
label: string;
};

@ -0,0 +1,43 @@
import { BeatSaverMapDifficultyToken } from "./map-difficulty";
export type BeatSaverMapVersionToken = {
/**
* The hash of the map.
*/
hash: string;
/**
* The stage of the map.
*/
stage: "Published"; // todo: find the rest of these
/**
* The date the map was created.
*/
createdAt: string;
/**
* The sage score of the map. (no idea what this is x.x)
*/
sageScore: number;
/**
* The difficulties in the map.
*/
diffs: BeatSaverMapDifficultyToken[];
/**
* The URL to the download of the map.
*/
downloadURL: string;
/**
* The URL to the cover image.
*/
coverURL: string;
/**
* The URL to the preview of the map.
*/
previewURL: string;
};

@ -0,0 +1,96 @@
import BeatSaverAccountToken from "./account";
import BeatSaverMapMetadataToken from "./map-metadata";
import BeatSaverMapStatsToken from "./map-stats";
import { BeatSaverMapVersionToken } from "./map-version";
export interface BeatSaverMapToken {
/**
* The id of the map.
*/
id: string;
/**
* The name of the map.
*/
name: string;
/**
* The description of the map.
*/
description: string;
/**
* The uploader of the map.
*/
uploader: BeatSaverAccountToken;
/**
* The metadata of the map.
*/
metadata: BeatSaverMapMetadataToken;
/**
* The stats of the map.
*/
stats: BeatSaverMapStatsToken;
/**
* The date the map was uploaded.
*/
uploaded: string;
/**
* Whether the map was mapped by an automapper.
*/
automapper: boolean;
/**
* Whether the map is ranked on ScoreSaber.
*/
ranked: boolean;
/**
* Whether the map is qualified on ScoreSaber.
*/
qualified: boolean;
/**
* The versions of the map.
*/
versions: BeatSaverMapVersionToken[];
/**
* The date the map was created.
*/
createdAt: string;
/**
* The date the map was last updated.
*/
updatedAt: string;
/**
* The date the map was last published.
*/
lastPublishedAt: string;
/**
* The tags of the map.
*/
tags: string[];
/**
* Whether the map is declared to be mapped by an AI.
*/
declaredAi: string;
/**
* Whether the map is ranked on BeatLeader.
*/
blRanked: boolean;
/**
* Whether the map is qualified on BeatLeader.
*/
blQualified: boolean;
}

@ -1,7 +1,8 @@
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map"; import { BeatSaverMap } from "../model/beatsaver/map";
import { MapDifficulty } from "../score/map-difficulty";
/** /**
* Gets the beatSaver mapper profile url. * Gets the BeatSaver mapper profile url.
* *
* @param map the beatsaver map * @param map the beatsaver map
* @returns the beatsaver mapper profile url * @returns the beatsaver mapper profile url
@ -9,3 +10,18 @@ import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
export function getBeatSaverMapperProfileUrl(map?: BeatSaverMap) { export function getBeatSaverMapperProfileUrl(map?: BeatSaverMap) {
return map != undefined ? `https://beatsaver.com/profile/${map?.author.id}` : undefined; return map != undefined ? `https://beatsaver.com/profile/${map?.author.id}` : undefined;
} }
/**
* Gets a BeatSaver difficulty from a map.
*
* @param map the map to get the difficulty from
* @param hash the hash of the map
* @param difficulty the difficulty to get
*/
export function getBeatSaverDifficulty(map: BeatSaverMap, hash: string, difficulty: MapDifficulty) {
const version = map.versions.find(v => v.hash === hash);
if (version == undefined) {
return undefined;
}
return version.difficulties.find(d => d.difficulty === difficulty);
}

@ -60,7 +60,7 @@ export function sortPlayerHistory(history: Map<string, PlayerHistory>) {
* @param id the player id * @param id the player id
*/ */
export async function trackPlayer(id: string) { export async function trackPlayer(id: string) {
await kyFetch(`${Config.apiUrl}/player/history/1/${id}?createIfMissing=true`); await kyFetch(`${Config.apiUrl}/player/history/${id}/1?createIfMissing=true`);
} }
/** /**

@ -1,11 +1,11 @@
import { Difficulty } from "../score/difficulty"; import { MapDifficulty } from "../score/map-difficulty";
/** /**
* Formats the ScoreSaber difficulty number * Formats the ScoreSaber difficulty number
* *
* @param diff the diffuiclity number * @param diff the diffuiclity number
*/ */
export function getDifficultyFromScoreSaberDifficulty(diff: number): Difficulty { export function getDifficultyFromScoreSaberDifficulty(diff: number): MapDifficulty {
switch (diff) { switch (diff) {
case 1: { case 1: {
return "Easy"; return "Easy";
@ -20,7 +20,7 @@ export function getDifficultyFromScoreSaberDifficulty(diff: number): Difficulty
return "Expert"; return "Expert";
} }
case 9: { case 9: {
return "Expert+"; return "ExpertPlus";
} }
default: { default: {
return "Unknown"; return "Unknown";

@ -134,3 +134,20 @@ export function getDaysAgo(date: Date): number {
export function parseDate(date: string): Date { export function parseDate(date: string): Date {
return new Date(date); return new Date(date);
} }
/**
* Formats the time in the format "MM:SS"
*
* @param seconds the time to format in seconds
* @returns the formatted time in "MM:SS" format
*/
export function formatTime(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
// Zero pad minutes and seconds to ensure two digits
const formattedMinutes = minutes < 10 ? `0${minutes}` : `${minutes}`;
const formattedSeconds = remainingSeconds < 10 ? `0${remainingSeconds}` : `${remainingSeconds}`;
return `${formattedMinutes}:${formattedSeconds}`;
}

@ -5,7 +5,6 @@ import { QueryProvider } from "@/components/providers/query-provider";
import { ThemeProvider } from "@/components/providers/theme-provider"; import { ThemeProvider } from "@/components/providers/theme-provider";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { AnimatePresence } from "framer-motion";
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import localFont from "next/font/local"; import localFont from "next/font/local";
import BackgroundCover from "../components/background-cover"; import BackgroundCover from "../components/background-cover";
@ -79,7 +78,6 @@ export default function RootLayout({
<OfflineNetwork> <OfflineNetwork>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange> <ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
<QueryProvider> <QueryProvider>
<AnimatePresence>
<ApiHealth /> <ApiHealth />
<main className="flex flex-col min-h-screen gap-2 text-white w-full"> <main className="flex flex-col min-h-screen gap-2 text-white w-full">
<NavBar /> <NavBar />
@ -88,7 +86,6 @@ export default function RootLayout({
</div> </div>
<Footer /> <Footer />
</main> </main>
</AnimatePresence>
</QueryProvider> </QueryProvider>
</ThemeProvider> </ThemeProvider>
</OfflineNetwork> </OfflineNetwork>

@ -1,17 +1,23 @@
import { MapDifficulty } from "@ssr/common/score/map-difficulty";
type Difficulty = { type Difficulty = {
name: DifficultyName; /**
gamemode?: string; * The name of the difficulty
*/
name: MapDifficulty;
/**
* The color of the difficulty
*/
color: string; color: string;
}; };
type DifficultyName = "Easy" | "Normal" | "Hard" | "Expert" | "Expert+";
const difficulties: Difficulty[] = [ const difficulties: Difficulty[] = [
{ name: "Easy", color: "#3cb371" }, { name: "Easy", color: "#3cb371" },
{ name: "Normal", color: "#59b0f4" }, { name: "Normal", color: "#59b0f4" },
{ name: "Hard", color: "#FF6347" }, { name: "Hard", color: "#FF6347" },
{ name: "Expert", color: "#bf2a42" }, { name: "Expert", color: "#bf2a42" },
{ name: "Expert+", color: "#8f48db" }, { name: "ExpertPlus", color: "#8f48db" },
]; ];
export type ScoreBadge = { export type ScoreBadge = {
@ -22,7 +28,7 @@ export type ScoreBadge = {
}; };
const scoreBadges: ScoreBadge[] = [ const scoreBadges: ScoreBadge[] = [
{ name: "SS+", min: 95, max: null, color: getDifficulty("Expert+")!.color }, { name: "SS+", min: 95, max: null, color: getDifficulty("ExpertPlus")!.color },
{ name: "SS", min: 90, max: 95, 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: 85, max: 90, color: getDifficulty("Hard")!.color },
{ name: "S", min: 80, max: 85, color: getDifficulty("Normal")!.color }, { name: "S", min: 80, max: 85, color: getDifficulty("Normal")!.color },
@ -57,45 +63,16 @@ export function getScoreBadgeFromAccuracy(acc: number): ScoreBadge {
return scoreBadges[scoreBadges.length - 1]; 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 * Gets a {@link Difficulty} from its name
* *
* @param diff the name of the difficulty * @param diff the name of the difficulty
* @returns the difficulty * @returns the difficulty
*/ */
export function getDifficulty(diff: DifficultyName) { export function getDifficulty(diff: MapDifficulty) {
return difficulties.find(d => d.name === diff); const difficulty = difficulties.find(d => d.name === diff);
if (!difficulty) {
throw new Error(`Unknown difficulty: ${diff}`);
} }
return difficulty;
/**
* 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;
} }

@ -14,14 +14,7 @@ import {
import { ChevronDoubleLeftIcon, ChevronDoubleRightIcon } from "@heroicons/react/16/solid"; import { ChevronDoubleLeftIcon, ChevronDoubleRightIcon } from "@heroicons/react/16/solid";
type PaginationItemWrapperProps = { type PaginationItemWrapperProps = {
/**
* Whether a page is currently loading.
*/
isLoadingPage: boolean; isLoadingPage: boolean;
/**
* The children to render.
*/
children: React.ReactNode; children: React.ReactNode;
}; };
@ -38,34 +31,11 @@ function PaginationItemWrapper({ isLoadingPage, children }: PaginationItemWrappe
} }
type Props = { type Props = {
/**
* If true, the pagination will be rendered as a mobile-friendly pagination.
*/
mobilePagination: boolean; mobilePagination: boolean;
/**
* The current page.
*/
page: number; page: number;
/**
* The total number of pages.
*/
totalPages: number; totalPages: number;
/**
* The page to show a loading icon on.
*/
loadingPage: number | undefined; loadingPage: number | undefined;
/**
* Callback function that is called when the user clicks on a page number.
*/
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
/**
* Optional callback to generate the URL for each page.
*/
generatePageUrl?: (page: number) => string; generatePageUrl?: (page: number) => string;
}; };
@ -89,15 +59,12 @@ export default function Pagination({
if (newPage < 1 || newPage > totalPages || newPage === currentPage || isLoading) { if (newPage < 1 || newPage > totalPages || newPage === currentPage || isLoading) {
return; return;
} }
setCurrentPage(newPage); setCurrentPage(newPage);
onPageChange(newPage); onPageChange(newPage);
}; };
const handleLinkClick = (newPage: number, event: React.MouseEvent) => { const handleLinkClick = (newPage: number, event: React.MouseEvent) => {
event.preventDefault(); // Prevent default navigation behavior event.preventDefault();
// Check if the new page is valid
if (newPage < 1 || newPage > totalPages || newPage === currentPage || isLoading) { if (newPage < 1 || newPage > totalPages || newPage === currentPage || isLoading) {
return; return;
} }
@ -116,26 +83,26 @@ export default function Pagination({
if (startPage > 1) { if (startPage > 1) {
pageNumbers.push( pageNumbers.push(
<> <PaginationItemWrapper key={`start-1`} isLoadingPage={isLoading}>
<PaginationItemWrapper key="start" isLoadingPage={isLoading}>
{!mobilePagination && ( {!mobilePagination && (
<PaginationLink href={generatePageUrl ? generatePageUrl(1) : ""} onClick={e => handleLinkClick(1, e)}> <PaginationLink href={generatePageUrl ? generatePageUrl(1) : ""} onClick={e => handleLinkClick(1, e)}>
1 1
</PaginationLink> </PaginationLink>
)} )}
</PaginationItemWrapper> </PaginationItemWrapper>
{startPage > 2 && !mobilePagination && ( );
<PaginationItemWrapper key="ellipsis-start" isLoadingPage={isLoading}> if (startPage > 2 && !mobilePagination) {
pageNumbers.push(
<PaginationItemWrapper key={`ellipsis-start`} isLoadingPage={isLoading}>
<PaginationEllipsis /> <PaginationEllipsis />
</PaginationItemWrapper> </PaginationItemWrapper>
)}
</>
); );
} }
}
for (let i = startPage; i <= endPage; i++) { for (let i = startPage; i <= endPage; i++) {
pageNumbers.push( pageNumbers.push(
<PaginationItemWrapper key={i} isLoadingPage={isLoading}> <PaginationItemWrapper key={`page-${i}`} isLoadingPage={isLoading}>
<PaginationLink <PaginationLink
isActive={i === currentPage} isActive={i === currentPage}
href={generatePageUrl ? generatePageUrl(i) : ""} href={generatePageUrl ? generatePageUrl(i) : ""}
@ -153,17 +120,15 @@ export default function Pagination({
return ( return (
<ShadCnPagination className="select-none"> <ShadCnPagination className="select-none">
<PaginationContent> <PaginationContent>
{/* ">>" before the Previous button in mobile mode */}
{mobilePagination && ( {mobilePagination && (
<PaginationItemWrapper key="mobile-start" isLoadingPage={isLoading}> <PaginationItemWrapper key={`mobile-start`} isLoadingPage={isLoading}>
<PaginationLink href={generatePageUrl ? generatePageUrl(1) : ""} onClick={e => handleLinkClick(1, e)}> <PaginationLink href={generatePageUrl ? generatePageUrl(1) : ""} onClick={e => handleLinkClick(1, e)}>
<ChevronDoubleLeftIcon className="h-4 w-4" /> <ChevronDoubleLeftIcon className="h-4 w-4" />
</PaginationLink> </PaginationLink>
</PaginationItemWrapper> </PaginationItemWrapper>
)} )}
{/* Previous button - disabled on the first page */} <PaginationItemWrapper key={`previous`} isLoadingPage={isLoading}>
<PaginationItemWrapper isLoadingPage={isLoading}>
<PaginationPrevious <PaginationPrevious
href={currentPage > 1 && generatePageUrl ? generatePageUrl(currentPage - 1) : ""} href={currentPage > 1 && generatePageUrl ? generatePageUrl(currentPage - 1) : ""}
onClick={e => handleLinkClick(currentPage - 1, e)} onClick={e => handleLinkClick(currentPage - 1, e)}
@ -176,10 +141,10 @@ export default function Pagination({
{!mobilePagination && currentPage < totalPages && totalPages - currentPage > 2 && ( {!mobilePagination && currentPage < totalPages && totalPages - currentPage > 2 && (
<> <>
<PaginationItemWrapper key="ellipsis-start" isLoadingPage={isLoading}> <PaginationItemWrapper key={`ellipsis-end`} isLoadingPage={isLoading}>
<PaginationEllipsis /> <PaginationEllipsis />
</PaginationItemWrapper> </PaginationItemWrapper>
<PaginationItemWrapper key="end" isLoadingPage={isLoading}> <PaginationItemWrapper key={`end`} isLoadingPage={isLoading}>
<PaginationLink <PaginationLink
href={generatePageUrl ? generatePageUrl(totalPages) : ""} href={generatePageUrl ? generatePageUrl(totalPages) : ""}
onClick={e => handleLinkClick(totalPages, e)} onClick={e => handleLinkClick(totalPages, e)}
@ -190,8 +155,7 @@ export default function Pagination({
</> </>
)} )}
{/* Next button - disabled on the last page */} <PaginationItemWrapper key={`next`} isLoadingPage={isLoading}>
<PaginationItemWrapper isLoadingPage={isLoading}>
<PaginationNext <PaginationNext
href={currentPage < totalPages && generatePageUrl ? generatePageUrl(currentPage + 1) : ""} href={currentPage < totalPages && generatePageUrl ? generatePageUrl(currentPage + 1) : ""}
onClick={e => handleLinkClick(currentPage + 1, e)} onClick={e => handleLinkClick(currentPage + 1, e)}
@ -200,9 +164,8 @@ export default function Pagination({
/> />
</PaginationItemWrapper> </PaginationItemWrapper>
{/* ">>" after the Next button in mobile mode */}
{mobilePagination && ( {mobilePagination && (
<PaginationItemWrapper key="mobile-end" isLoadingPage={isLoading}> <PaginationItemWrapper key={`mobile-end`} isLoadingPage={isLoading}>
<PaginationLink <PaginationLink
href={generatePageUrl ? generatePageUrl(totalPages) : ""} href={generatePageUrl ? generatePageUrl(totalPages) : ""}
onClick={e => handleLinkClick(totalPages, e)} onClick={e => handleLinkClick(totalPages, e)}

@ -66,11 +66,11 @@ export default function SearchPlayer() {
{results !== undefined && ( {results !== undefined && (
<ScrollArea> <ScrollArea>
<div className="flex flex-col gap-1 max-h-60"> <div className="flex flex-col gap-1 max-h-60">
{results?.map(player => { {results?.map((player, index) => {
return ( return (
<Link <Link
href={`/player/${player.id}`} href={`/player/${player.id}`}
key={player.id} key={index}
className="bg-secondary p-2 rounded-md flex gap-2 items-center hover:brightness-75 transition-all transform-gpu" className="bg-secondary p-2 rounded-md flex gap-2 items-center hover:brightness-75 transition-all transform-gpu"
> >
<Avatar> <Avatar>

@ -3,7 +3,7 @@ import Image from "next/image";
import { LeaderboardSongStarCount } from "@/components/leaderboard/leaderboard-song-star-count"; import { LeaderboardSongStarCount } from "@/components/leaderboard/leaderboard-song-star-count";
import ScoreButtons from "@/components/score/score-buttons"; import ScoreButtons from "@/components/score/score-buttons";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map"; import { BeatSaverMap } from "../../../../common/src/model/beatsaver/map";
import { getBeatSaverMapperProfileUrl } from "@ssr/common/utils/beatsaver.util"; import { getBeatSaverMapperProfileUrl } from "@ssr/common/utils/beatsaver.util";
import FallbackLink from "@/components/fallback-link"; import FallbackLink from "@/components/fallback-link";
import { formatNumber } from "@ssr/common/utils/number-utils"; import { formatNumber } from "@ssr/common/utils/number-utils";

@ -8,7 +8,7 @@ import Pagination from "../input/pagination";
import LeaderboardScore from "./leaderboard-score"; import LeaderboardScore from "./leaderboard-score";
import { scoreAnimation } from "@/components/score/score-animation"; import { scoreAnimation } from "@/components/score/score-animation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getDifficultyFromRawDifficulty } from "@/common/song-utils"; import { getDifficulty } from "@/common/song-utils";
import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils"; import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils";
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score"; import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
@ -140,28 +140,26 @@ export default function LeaderboardScores({
{showDifficulties && ( {showDifficulties && (
<div className="flex gap-2 justify-center items-center flex-wrap"> <div className="flex gap-2 justify-center items-center flex-wrap">
{leaderboard.difficulties.map(({ difficultyRaw, leaderboardId }) => { {leaderboard.difficulties.map(({ difficulty, characteristic, leaderboardId }, index) => {
const difficulty = getDifficultyFromRawDifficulty(difficultyRaw); if (characteristic !== "Standard") {
// todo: add support for other gamemodes?
if (difficulty.gamemode !== "Standard") {
return null; return null;
} }
const isSelected = leaderboardId === selectedLeaderboardId; const isSelected = leaderboardId === selectedLeaderboardId;
return ( return (
<Button <Button
key={difficultyRaw} key={index}
variant={isSelected ? "default" : "outline"} variant={isSelected ? "default" : "outline"}
onClick={() => { onClick={() => {
handleLeaderboardChange(leaderboardId); handleLeaderboardChange(leaderboardId);
}} }}
className={`border ${isSelected ? "bg-primary/5 font-bold" : ""}`} className={`border ${isSelected ? "bg-primary/5 font-bold" : ""}`}
style={{ style={{
color: getDifficultyFromRawDifficulty(difficultyRaw).color, color: getDifficulty(difficulty).color,
borderColor: getDifficultyFromRawDifficulty(difficultyRaw).color, borderColor: getDifficulty(difficulty).color,
}} }}
> >
{difficulty.name} {difficulty}
</Button> </Button>
); );
})} })}

@ -1,4 +1,4 @@
import { songDifficultyToColor } from "@/common/song-utils"; import { getDifficulty } from "@/common/song-utils";
import { StarIcon } from "@heroicons/react/24/solid"; import { StarIcon } from "@heroicons/react/24/solid";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
@ -18,7 +18,7 @@ export function LeaderboardSongStarCount({ leaderboard }: LeaderboardSongStarCou
<div <div
className="w-fit h-[20px] rounded-sm flex justify-center items-center text-xs cursor-default" className="w-fit h-[20px] rounded-sm flex justify-center items-center text-xs cursor-default"
style={{ style={{
backgroundColor: songDifficultyToColor(leaderboard.difficulty.difficultyRaw) + "f0", // Transparency value (in hex 0-255) backgroundColor: getDifficulty(leaderboard.difficulty.difficulty).color + "f0", // Transparency value (in hex 0-255)
}} }}
> >
<div className="flex gap-1 items-center justify-center p-1"> <div className="flex gap-1 items-center justify-center p-1">

@ -26,7 +26,7 @@ export default function FriendsButton() {
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-2"> <PopoverContent className="p-2">
{friends && friends.length > 0 ? ( {friends && friends.length > 0 ? (
friends.map(friend => <Friend player={friend} key={friend.id} onClick={() => setOpen(false)} />) friends.map((friend, index) => <Friend player={friend} key={index} onClick={() => setOpen(false)} />)
) : ( ) : (
<div className="text-sm flex flex-col gap-2 justify-center items-center"> <div className="text-sm flex flex-col gap-2 justify-center items-center">
<p>You don&#39;t have any friends :(</p> <p>You don&#39;t have any friends :(</p>

@ -0,0 +1,53 @@
import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
import StatValue from "@/components/stat-value";
import { getBeatSaverDifficulty } from "@ssr/common/utils/beatsaver.util";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { formatTime } from "@ssr/common/utils/time-utils";
import { formatNumberWithCommas } from "@ssr/common/utils/number-utils";
import { BombIcon, BrickWallIcon, DrumIcon, MusicIcon, TimerIcon } from "lucide-react";
import { BsSpeedometer } from "react-icons/bs";
import { CubeIcon } from "@heroicons/react/24/solid";
type MapAndScoreData = {
/**
* The leaderboard that the score was set on.
*/
leaderboard: ScoreSaberLeaderboard;
/**
* The map that the score was set on.
*/
beatSaver?: BeatSaverMap;
};
export function MapStats({ leaderboard, beatSaver }: MapAndScoreData) {
const metadata = beatSaver?.metadata;
const mapDiff = beatSaver
? getBeatSaverDifficulty(beatSaver, leaderboard.songHash, leaderboard.difficulty.difficulty)
: undefined;
return (
<div className="flex flex-col gap-2">
{/* Map Stats */}
{mapDiff && metadata && (
<div className="flex flex-wrap gap-2 justify-center">
<StatValue name="Length" icon={<TimerIcon className="w-4 h-4" />} value={formatTime(metadata.duration)} />
<StatValue name="BPM" icon={<MusicIcon className="w-4 h-4" />} value={formatNumberWithCommas(metadata.bpm)} />
<StatValue name="NPS" icon={<DrumIcon className="w-4 h-4" />} value={mapDiff.nps.toFixed(2)} />
<StatValue name="NJS" icon={<BsSpeedometer className="w-4 h-4" />} value={mapDiff.njs.toFixed(2)} />
<StatValue
name="Notes"
icon={<CubeIcon className="w-4 h-4" />}
value={formatNumberWithCommas(mapDiff.notes)}
/>
<StatValue
name="Bombs"
icon={<BombIcon className="w-4 h-4" />}
value={formatNumberWithCommas(mapDiff.bombs)}
/>
<StatValue name="Obstacles" icon={<BrickWallIcon className="w-4 h-4" />} value={mapDiff.obstacles} />
</div>
)}
</div>
);
}

@ -12,7 +12,7 @@ import clsx from "clsx";
import ScoreEditorButton from "@/components/score/score-editor-button"; import ScoreEditorButton from "@/components/score/score-editor-button";
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score"; import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map"; import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
import BeatSaberPepeLogo from "@/components/logos/beatsaber-pepe-logo"; import BeatSaberPepeLogo from "@/components/logos/beatsaber-pepe-logo";
type Props = { type Props = {

@ -21,7 +21,7 @@ type ScoreModifiersProps = {
export function ScoreModifiers({ score, type, limit }: ScoreModifiersProps) { export function ScoreModifiers({ score, type, limit }: ScoreModifiersProps) {
const modifiers = score.modifiers; const modifiers = score.modifiers;
if (modifiers.length === 0) { if (modifiers.length === 0) {
return <p>-</p>; return <span>-</span>;
} }
switch (type) { switch (type) {

@ -2,10 +2,10 @@ import FallbackLink from "@/components/fallback-link";
import Tooltip from "@/components/tooltip"; import Tooltip from "@/components/tooltip";
import { StarIcon } from "@heroicons/react/24/solid"; import { StarIcon } from "@heroicons/react/24/solid";
import Image from "next/image"; import Image from "next/image";
import { songDifficultyToColor } from "@/common/song-utils";
import Link from "next/link"; import Link from "next/link";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map"; import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
import { getDifficulty } from "@/common/song-utils";
type Props = { type Props = {
leaderboard: ScoreSaberLeaderboard; leaderboard: ScoreSaberLeaderboard;
@ -14,25 +14,25 @@ type Props = {
export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) { export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
const mappersProfile = const mappersProfile =
beatSaverMap != undefined ? `https://beatsaver.com/profile/${beatSaverMap?.author.id}` : undefined; beatSaverMap != undefined ? `https://beatsaver.com/profile/${beatSaverMap.author.id}` : undefined;
const starCount = leaderboard.stars; const starCount = leaderboard.stars;
const difficulty = leaderboard.difficulty; const difficulty = leaderboard.difficulty.difficulty.replace("Plus", "+");
return ( return (
<div className="flex gap-3 items-center"> <div className="flex gap-3 items-center">
<div className="relative flex justify-center h-[64px]"> <div className="relative flex justify-center h-[64px]">
<Tooltip <Tooltip
display={ display={
<> <div>
<p>Difficulty: {difficulty.difficulty}</p> <p>Difficulty: {difficulty}</p>
{starCount > 0 && <p>Stars: {starCount.toFixed(2)}</p>} {starCount > 0 && <p>Stars: {starCount.toFixed(2)}</p>}
</> </div>
} }
> >
<div <div
className="absolute w-full h-[18px] bottom-0 right-0 rounded-sm flex justify-center items-center text-[0.70rem] cursor-default" className="absolute w-full h-[18px] bottom-0 right-0 rounded-sm flex justify-center items-center text-[0.70rem] cursor-default"
style={{ style={{
backgroundColor: songDifficultyToColor(difficulty.difficultyRaw) + "f0", // Transparency value (in hex 0-255) backgroundColor: getDifficulty(leaderboard.difficulty.difficulty).color + "f0", // Transparency value (in hex 0-255)
}} }}
> >
{starCount > 0 ? ( {starCount > 0 ? (
@ -41,7 +41,7 @@ export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
<StarIcon className="w-[14px] h-[14px]" /> <StarIcon className="w-[14px] h-[14px]" />
</div> </div>
) : ( ) : (
<p>{difficulty.difficulty}</p> <p>{difficulty}</p>
)} )}
</div> </div>
</Tooltip> </Tooltip>

@ -3,7 +3,7 @@
import LeaderboardScores from "@/components/leaderboard/leaderboard-scores"; import LeaderboardScores from "@/components/leaderboard/leaderboard-scores";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import ScoreButtons from "./score-buttons"; import ScoreButtons from "./score-buttons";
import ScoreSongInfo from "./score-info"; import ScoreSongInfo from "./score-song-info";
import ScoreRankInfo from "./score-rank-info"; import ScoreRankInfo from "./score-rank-info";
import ScoreStats from "./score-stats"; import ScoreStats from "./score-stats";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
@ -11,10 +11,10 @@ import { getPageFromRank } from "@ssr/common/utils/utils";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score"; import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map"; import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
import { useIsMobile } from "@/hooks/use-is-mobile"; import { useIsMobile } from "@/hooks/use-is-mobile";
import Card from "@/components/card"; import Card from "@/components/card";
import StatValue from "@/components/stat-value"; import { MapStats } from "@/components/score/map-stats";
type Props = { type Props = {
/** /**
@ -106,11 +106,7 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr
className="w-full mt-2" className="w-full mt-2"
> >
<Card className="flex gap-4 w-full relative border border-input"> <Card className="flex gap-4 w-full relative border border-input">
{score.additionalData && ( <MapStats leaderboard={leaderboard} beatSaver={beatSaverMap} />
<div className="flex w-full items-center justify-center gap-2">
<StatValue name="Pauses" value={score.additionalData.pauses} />
</div>
)}
<LeaderboardScores <LeaderboardScores
initialPage={getPageFromRank(score.rank, 12)} initialPage={getPageFromRank(score.rank, 12)}

@ -6,6 +6,11 @@ type Props = {
*/ */
name?: string; name?: string;
/**
* The icon for the stat.
*/
icon?: React.ReactNode;
/** /**
* The background color of the stat. * The background color of the stat.
*/ */
@ -17,7 +22,7 @@ type Props = {
value: React.ReactNode; value: React.ReactNode;
}; };
export default function StatValue({ name, color, value }: Props) { export default function StatValue({ name, icon, color, value }: Props) {
return ( return (
<div <div
className={clsx( className={clsx(
@ -28,6 +33,7 @@ export default function StatValue({ name, color, value }: Props) {
backgroundColor: (!color?.includes("bg") && color) || undefined, backgroundColor: (!color?.includes("bg") && color) || undefined,
}} }}
> >
{icon}
{name && ( {name && (
<> <>
<p>{name}</p> <p>{name}</p>

@ -37,8 +37,7 @@ export default function Tooltip({ children, display, asChild = true, side = "top
return ( return (
<ShadCnTooltip> <ShadCnTooltip>
<TooltipTrigger className={className} asChild={asChild}> <TooltipTrigger className={className} asChild={asChild}>
<button <div
type="button"
className={cn("cursor-default", className)} className={cn("cursor-default", className)}
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
onMouseEnter={() => setOpen(true)} onMouseEnter={() => setOpen(true)}
@ -46,7 +45,7 @@ export default function Tooltip({ children, display, asChild = true, side = "top
onTouchStart={() => setOpen(!open)} onTouchStart={() => setOpen(!open)}
> >
{children} {children}
</button> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-[350px]" side={side}> <TooltipContent className="max-w-[350px]" side={side}>
{display} {display}