add map stats from beat saver
This commit is contained in:
parent
62090b8054
commit
33b931b5f1
@ -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.
|
||||||
|
27
projects/common/src/model/beatsaver/author.ts
Normal file
27
projects/common/src/model/beatsaver/author.ts
Normal file
@ -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);
|
|
128
projects/common/src/model/beatsaver/map-difficulty.ts
Normal file
128
projects/common/src/model/beatsaver/map-difficulty.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
55
projects/common/src/model/beatsaver/map-metadata.ts
Normal file
55
projects/common/src/model/beatsaver/map-metadata.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
31
projects/common/src/model/beatsaver/map-version.ts
Normal file
31
projects/common/src/model/beatsaver/map-version.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
99
projects/common/src/model/beatsaver/map.ts
Normal file
99
projects/common/src/model/beatsaver/map.ts
Normal file
@ -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";
|
|
1
projects/common/src/score/map-difficulty.ts
Normal file
1
projects/common/src/score/map-difficulty.ts
Normal file
@ -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`;
|
||||||
|
1
projects/common/src/types/map-characteristic.ts
Normal file
1
projects/common/src/types/map-characteristic.ts
Normal file
@ -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;
|
||||||
|
};
|
90
projects/common/src/types/token/beatsaver/map-difficulty.ts
Normal file
90
projects/common/src/types/token/beatsaver/map-difficulty.ts
Normal file
@ -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;
|
||||||
|
};
|
43
projects/common/src/types/token/beatsaver/map-version.ts
Normal file
43
projects/common/src/types/token/beatsaver/map-version.ts
Normal file
@ -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;
|
||||||
|
};
|
96
projects/common/src/types/token/beatsaver/map.ts
Normal file
96
projects/common/src/types/token/beatsaver/map.ts
Normal file
@ -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't have any friends :(</p>
|
<p>You don't have any friends :(</p>
|
||||||
|
53
projects/website/src/components/score/map-stats.tsx
Normal file
53
projects/website/src/components/score/map-stats.tsx
Normal file
@ -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}
|
||||||
|
Reference in New Issue
Block a user