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 { BeatSaverMap, BeatSaverMapModel } from "@ssr/common/model/beatsaver/beatsaver-map";
|
||||
import { BeatSaverMap, BeatSaverMapModel } from "@ssr/common/model/beatsaver/map";
|
||||
|
||||
export default class BeatSaverService {
|
||||
/**
|
||||
@ -12,28 +12,71 @@ export default class BeatSaverService {
|
||||
let map = await BeatSaverMapModel.findById(hash);
|
||||
if (map != undefined) {
|
||||
const toObject = map.toObject() as BeatSaverMap;
|
||||
if (toObject.unknownMap) {
|
||||
if (toObject.notFound) {
|
||||
return undefined;
|
||||
}
|
||||
return toObject;
|
||||
}
|
||||
|
||||
const token = await beatsaverService.lookupMap(hash);
|
||||
const uploader = token?.uploader;
|
||||
const metadata = token?.metadata;
|
||||
|
||||
map = await BeatSaverMapModel.create(
|
||||
token
|
||||
? {
|
||||
token && uploader && metadata
|
||||
? // eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
({
|
||||
_id: hash,
|
||||
bsr: token.id,
|
||||
name: token.name,
|
||||
description: token.description,
|
||||
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,
|
||||
unknownMap: true,
|
||||
notFound: true,
|
||||
}
|
||||
);
|
||||
if (map.unknownMap) {
|
||||
if (map.notFound) {
|
||||
return undefined;
|
||||
}
|
||||
return map.toObject() as BeatSaverMap;
|
||||
|
@ -7,7 +7,7 @@ import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score
|
||||
import { NotFoundError } from "elysia";
|
||||
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||
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({
|
||||
ttl: 1000 * 60 * 60 * 24,
|
||||
|
@ -13,7 +13,7 @@ import { ScoreSort } from "@ssr/common/score/score-sort";
|
||||
import { Leaderboards } from "@ssr/common/leaderboard";
|
||||
import Leaderboard from "@ssr/common/leaderboard/leaderboard";
|
||||
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 LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
|
||||
import Score from "@ssr/common/score/score";
|
||||
@ -188,7 +188,7 @@ export class ScoreService {
|
||||
};
|
||||
|
||||
const difficulty = leaderboard.difficulty;
|
||||
const difficultyKey = `${difficulty.difficultyName.replace("Plus", "+")}-${difficulty.modeName}`;
|
||||
const difficultyKey = `${difficulty.difficultyName}-${difficulty.modeName}`;
|
||||
const rawScoreImprovement = score.scoreImprovement;
|
||||
const data = {
|
||||
playerId: playerId,
|
||||
@ -312,15 +312,15 @@ export class ScoreService {
|
||||
if (score == undefined) {
|
||||
continue;
|
||||
}
|
||||
const tokenLeaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard);
|
||||
if (tokenLeaderboard == undefined) {
|
||||
const leaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard);
|
||||
if (leaderboard == undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const additionalData = await this.getAdditionalScoreData(
|
||||
id,
|
||||
tokenLeaderboard.songHash,
|
||||
`${tokenLeaderboard.difficulty.difficulty}-${tokenLeaderboard.difficulty.gameMode}`,
|
||||
leaderboard.songHash,
|
||||
`${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`,
|
||||
score.score
|
||||
);
|
||||
if (additionalData !== undefined) {
|
||||
@ -329,8 +329,8 @@ export class ScoreService {
|
||||
|
||||
scores.push({
|
||||
score: score,
|
||||
leaderboard: tokenLeaderboard,
|
||||
beatSaver: await BeatSaverService.getMap(tokenLeaderboard.songHash),
|
||||
leaderboard: leaderboard,
|
||||
beatSaver: await BeatSaverService.getMap(leaderboard.songHash),
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
@ -4,6 +4,7 @@ import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber
|
||||
import { getDifficultyFromScoreSaberDifficulty } from "../../utils/scoresaber-utils";
|
||||
import { parseDate } from "../../utils/time-utils";
|
||||
import { LeaderboardStatus } from "../leaderboard-status";
|
||||
import { MapCharacteristic } from "../../types/map-characteristic";
|
||||
|
||||
export default interface ScoreSaberLeaderboard extends Leaderboard {
|
||||
/**
|
||||
@ -41,7 +42,7 @@ export function getScoreSaberLeaderboardFromToken(token: ScoreSaberLeaderboardTo
|
||||
const difficulty: LeaderboardDifficulty = {
|
||||
leaderboardId: token.difficulty.leaderboardId,
|
||||
difficulty: getDifficultyFromScoreSaberDifficulty(token.difficulty.difficulty),
|
||||
gameMode: token.difficulty.gameMode.replace("Solo", ""),
|
||||
characteristic: token.difficulty.gameMode.replace("Solo", "") as MapCharacteristic,
|
||||
difficultyRaw: token.difficulty.difficultyRaw,
|
||||
};
|
||||
|
||||
@ -66,7 +67,7 @@ export function getScoreSaberLeaderboardFromToken(token: ScoreSaberLeaderboardTo
|
||||
return {
|
||||
leaderboardId: difficulty.leaderboardId,
|
||||
difficulty: getDifficultyFromScoreSaberDifficulty(difficulty.difficulty),
|
||||
gameMode: difficulty.gameMode.replace("Solo", ""),
|
||||
characteristic: difficulty.gameMode.replace("Solo", "") as MapCharacteristic,
|
||||
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 {
|
||||
/**
|
||||
@ -9,12 +10,12 @@ export default interface LeaderboardDifficulty {
|
||||
/**
|
||||
* 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.
|
||||
|
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> = {
|
||||
/**
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Metadata } from "../types/metadata";
|
||||
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
|
||||
import { BeatSaverMap } from "../model/beatsaver/map";
|
||||
|
||||
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> {
|
||||
/**
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 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
|
||||
* @returns the beatsaver mapper profile url
|
||||
@ -9,3 +10,18 @@ import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
|
||||
export function getBeatSaverMapperProfileUrl(map?: BeatSaverMap) {
|
||||
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
|
||||
*/
|
||||
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
|
||||
*
|
||||
* @param diff the diffuiclity number
|
||||
*/
|
||||
export function getDifficultyFromScoreSaberDifficulty(diff: number): Difficulty {
|
||||
export function getDifficultyFromScoreSaberDifficulty(diff: number): MapDifficulty {
|
||||
switch (diff) {
|
||||
case 1: {
|
||||
return "Easy";
|
||||
@ -20,7 +20,7 @@ export function getDifficultyFromScoreSaberDifficulty(diff: number): Difficulty
|
||||
return "Expert";
|
||||
}
|
||||
case 9: {
|
||||
return "Expert+";
|
||||
return "ExpertPlus";
|
||||
}
|
||||
default: {
|
||||
return "Unknown";
|
||||
|
@ -134,3 +134,20 @@ export function getDaysAgo(date: Date): number {
|
||||
export function parseDate(date: string): 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 { Toaster } from "@/components/ui/toaster";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import BackgroundCover from "../components/background-cover";
|
||||
@ -79,7 +78,6 @@ export default function RootLayout({
|
||||
<OfflineNetwork>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
|
||||
<QueryProvider>
|
||||
<AnimatePresence>
|
||||
<ApiHealth />
|
||||
<main className="flex flex-col min-h-screen gap-2 text-white w-full">
|
||||
<NavBar />
|
||||
@ -88,7 +86,6 @@ export default function RootLayout({
|
||||
</div>
|
||||
<Footer />
|
||||
</main>
|
||||
</AnimatePresence>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</OfflineNetwork>
|
||||
|
@ -1,17 +1,23 @@
|
||||
import { MapDifficulty } from "@ssr/common/score/map-difficulty";
|
||||
|
||||
type Difficulty = {
|
||||
name: DifficultyName;
|
||||
gamemode?: string;
|
||||
/**
|
||||
* The name of the difficulty
|
||||
*/
|
||||
name: MapDifficulty;
|
||||
|
||||
/**
|
||||
* The color of the difficulty
|
||||
*/
|
||||
color: string;
|
||||
};
|
||||
|
||||
type DifficultyName = "Easy" | "Normal" | "Hard" | "Expert" | "Expert+";
|
||||
|
||||
const difficulties: Difficulty[] = [
|
||||
{ name: "Easy", color: "#3cb371" },
|
||||
{ name: "Normal", color: "#59b0f4" },
|
||||
{ name: "Hard", color: "#FF6347" },
|
||||
{ name: "Expert", color: "#bf2a42" },
|
||||
{ name: "Expert+", color: "#8f48db" },
|
||||
{ name: "ExpertPlus", color: "#8f48db" },
|
||||
];
|
||||
|
||||
export type ScoreBadge = {
|
||||
@ -22,7 +28,7 @@ export type 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: "S+", min: 85, max: 90, color: getDifficulty("Hard")!.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];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a raw difficulty into a {@link Difficulty}
|
||||
* Example: _Easy_SoloStandard -> { name: "Easy", type: "Standard", color: "#59b0f4" }
|
||||
*
|
||||
* @param rawDifficulty the raw difficulty to parse
|
||||
* @return the parsed difficulty
|
||||
*/
|
||||
export function getDifficultyFromRawDifficulty(rawDifficulty: string): Difficulty {
|
||||
const [name, ...type] = rawDifficulty
|
||||
.replace("Plus", "+") // Replaces Plus with + so we can match it to our difficulty names
|
||||
.replace("Solo", "") // Removes "Solo"
|
||||
.replace(/^_+|_+$/g, "") // Removes leading and trailing underscores
|
||||
.split("_");
|
||||
const difficulty = difficulties.find(d => d.name === name);
|
||||
if (!difficulty) {
|
||||
throw new Error(`Unknown difficulty: ${rawDifficulty}`);
|
||||
}
|
||||
return {
|
||||
...difficulty,
|
||||
gamemode: type.join("_"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a {@link Difficulty} from its name
|
||||
*
|
||||
* @param diff the name of the difficulty
|
||||
* @returns the difficulty
|
||||
*/
|
||||
export function getDifficulty(diff: DifficultyName) {
|
||||
return difficulties.find(d => d.name === diff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the difficulty of a song into a color
|
||||
*
|
||||
* @param diff the difficulty to get the color for
|
||||
* @returns the color for the difficulty
|
||||
*/
|
||||
export function songDifficultyToColor(diff: string) {
|
||||
return getDifficultyFromRawDifficulty(diff).color;
|
||||
export function getDifficulty(diff: MapDifficulty) {
|
||||
const difficulty = difficulties.find(d => d.name === diff);
|
||||
if (!difficulty) {
|
||||
throw new Error(`Unknown difficulty: ${diff}`);
|
||||
}
|
||||
return difficulty;
|
||||
}
|
||||
|
@ -14,14 +14,7 @@ import {
|
||||
import { ChevronDoubleLeftIcon, ChevronDoubleRightIcon } from "@heroicons/react/16/solid";
|
||||
|
||||
type PaginationItemWrapperProps = {
|
||||
/**
|
||||
* Whether a page is currently loading.
|
||||
*/
|
||||
isLoadingPage: boolean;
|
||||
|
||||
/**
|
||||
* The children to render.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
@ -38,34 +31,11 @@ function PaginationItemWrapper({ isLoadingPage, children }: PaginationItemWrappe
|
||||
}
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* If true, the pagination will be rendered as a mobile-friendly pagination.
|
||||
*/
|
||||
mobilePagination: boolean;
|
||||
|
||||
/**
|
||||
* The current page.
|
||||
*/
|
||||
page: number;
|
||||
|
||||
/**
|
||||
* The total number of pages.
|
||||
*/
|
||||
totalPages: number;
|
||||
|
||||
/**
|
||||
* The page to show a loading icon on.
|
||||
*/
|
||||
loadingPage: number | undefined;
|
||||
|
||||
/**
|
||||
* Callback function that is called when the user clicks on a page number.
|
||||
*/
|
||||
onPageChange: (page: number) => void;
|
||||
|
||||
/**
|
||||
* Optional callback to generate the URL for each page.
|
||||
*/
|
||||
generatePageUrl?: (page: number) => string;
|
||||
};
|
||||
|
||||
@ -89,15 +59,12 @@ export default function Pagination({
|
||||
if (newPage < 1 || newPage > totalPages || newPage === currentPage || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentPage(newPage);
|
||||
onPageChange(newPage);
|
||||
};
|
||||
|
||||
const handleLinkClick = (newPage: number, event: React.MouseEvent) => {
|
||||
event.preventDefault(); // Prevent default navigation behavior
|
||||
|
||||
// Check if the new page is valid
|
||||
event.preventDefault();
|
||||
if (newPage < 1 || newPage > totalPages || newPage === currentPage || isLoading) {
|
||||
return;
|
||||
}
|
||||
@ -116,26 +83,26 @@ export default function Pagination({
|
||||
|
||||
if (startPage > 1) {
|
||||
pageNumbers.push(
|
||||
<>
|
||||
<PaginationItemWrapper key="start" isLoadingPage={isLoading}>
|
||||
<PaginationItemWrapper key={`start-1`} isLoadingPage={isLoading}>
|
||||
{!mobilePagination && (
|
||||
<PaginationLink href={generatePageUrl ? generatePageUrl(1) : ""} onClick={e => handleLinkClick(1, e)}>
|
||||
1
|
||||
</PaginationLink>
|
||||
)}
|
||||
</PaginationItemWrapper>
|
||||
{startPage > 2 && !mobilePagination && (
|
||||
<PaginationItemWrapper key="ellipsis-start" isLoadingPage={isLoading}>
|
||||
);
|
||||
if (startPage > 2 && !mobilePagination) {
|
||||
pageNumbers.push(
|
||||
<PaginationItemWrapper key={`ellipsis-start`} isLoadingPage={isLoading}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItemWrapper>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pageNumbers.push(
|
||||
<PaginationItemWrapper key={i} isLoadingPage={isLoading}>
|
||||
<PaginationItemWrapper key={`page-${i}`} isLoadingPage={isLoading}>
|
||||
<PaginationLink
|
||||
isActive={i === currentPage}
|
||||
href={generatePageUrl ? generatePageUrl(i) : ""}
|
||||
@ -153,17 +120,15 @@ export default function Pagination({
|
||||
return (
|
||||
<ShadCnPagination className="select-none">
|
||||
<PaginationContent>
|
||||
{/* ">>" before the Previous button in mobile mode */}
|
||||
{mobilePagination && (
|
||||
<PaginationItemWrapper key="mobile-start" isLoadingPage={isLoading}>
|
||||
<PaginationItemWrapper key={`mobile-start`} isLoadingPage={isLoading}>
|
||||
<PaginationLink href={generatePageUrl ? generatePageUrl(1) : ""} onClick={e => handleLinkClick(1, e)}>
|
||||
<ChevronDoubleLeftIcon className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
</PaginationItemWrapper>
|
||||
)}
|
||||
|
||||
{/* Previous button - disabled on the first page */}
|
||||
<PaginationItemWrapper isLoadingPage={isLoading}>
|
||||
<PaginationItemWrapper key={`previous`} isLoadingPage={isLoading}>
|
||||
<PaginationPrevious
|
||||
href={currentPage > 1 && generatePageUrl ? generatePageUrl(currentPage - 1) : ""}
|
||||
onClick={e => handleLinkClick(currentPage - 1, e)}
|
||||
@ -176,10 +141,10 @@ export default function Pagination({
|
||||
|
||||
{!mobilePagination && currentPage < totalPages && totalPages - currentPage > 2 && (
|
||||
<>
|
||||
<PaginationItemWrapper key="ellipsis-start" isLoadingPage={isLoading}>
|
||||
<PaginationItemWrapper key={`ellipsis-end`} isLoadingPage={isLoading}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItemWrapper>
|
||||
<PaginationItemWrapper key="end" isLoadingPage={isLoading}>
|
||||
<PaginationItemWrapper key={`end`} isLoadingPage={isLoading}>
|
||||
<PaginationLink
|
||||
href={generatePageUrl ? generatePageUrl(totalPages) : ""}
|
||||
onClick={e => handleLinkClick(totalPages, e)}
|
||||
@ -190,8 +155,7 @@ export default function Pagination({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Next button - disabled on the last page */}
|
||||
<PaginationItemWrapper isLoadingPage={isLoading}>
|
||||
<PaginationItemWrapper key={`next`} isLoadingPage={isLoading}>
|
||||
<PaginationNext
|
||||
href={currentPage < totalPages && generatePageUrl ? generatePageUrl(currentPage + 1) : ""}
|
||||
onClick={e => handleLinkClick(currentPage + 1, e)}
|
||||
@ -200,9 +164,8 @@ export default function Pagination({
|
||||
/>
|
||||
</PaginationItemWrapper>
|
||||
|
||||
{/* ">>" after the Next button in mobile mode */}
|
||||
{mobilePagination && (
|
||||
<PaginationItemWrapper key="mobile-end" isLoadingPage={isLoading}>
|
||||
<PaginationItemWrapper key={`mobile-end`} isLoadingPage={isLoading}>
|
||||
<PaginationLink
|
||||
href={generatePageUrl ? generatePageUrl(totalPages) : ""}
|
||||
onClick={e => handleLinkClick(totalPages, e)}
|
||||
|
@ -66,11 +66,11 @@ export default function SearchPlayer() {
|
||||
{results !== undefined && (
|
||||
<ScrollArea>
|
||||
<div className="flex flex-col gap-1 max-h-60">
|
||||
{results?.map(player => {
|
||||
{results?.map((player, index) => {
|
||||
return (
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<Avatar>
|
||||
|
@ -3,7 +3,7 @@ import Image from "next/image";
|
||||
import { LeaderboardSongStarCount } from "@/components/leaderboard/leaderboard-song-star-count";
|
||||
import ScoreButtons from "@/components/score/score-buttons";
|
||||
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 FallbackLink from "@/components/fallback-link";
|
||||
import { formatNumber } from "@ssr/common/utils/number-utils";
|
||||
|
@ -8,7 +8,7 @@ import Pagination from "../input/pagination";
|
||||
import LeaderboardScore from "./leaderboard-score";
|
||||
import { scoreAnimation } from "@/components/score/score-animation";
|
||||
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 ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
||||
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||
@ -140,28 +140,26 @@ export default function LeaderboardScores({
|
||||
|
||||
{showDifficulties && (
|
||||
<div className="flex gap-2 justify-center items-center flex-wrap">
|
||||
{leaderboard.difficulties.map(({ difficultyRaw, leaderboardId }) => {
|
||||
const difficulty = getDifficultyFromRawDifficulty(difficultyRaw);
|
||||
// todo: add support for other gamemodes?
|
||||
if (difficulty.gamemode !== "Standard") {
|
||||
{leaderboard.difficulties.map(({ difficulty, characteristic, leaderboardId }, index) => {
|
||||
if (characteristic !== "Standard") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isSelected = leaderboardId === selectedLeaderboardId;
|
||||
return (
|
||||
<Button
|
||||
key={difficultyRaw}
|
||||
key={index}
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
handleLeaderboardChange(leaderboardId);
|
||||
}}
|
||||
className={`border ${isSelected ? "bg-primary/5 font-bold" : ""}`}
|
||||
style={{
|
||||
color: getDifficultyFromRawDifficulty(difficultyRaw).color,
|
||||
borderColor: getDifficultyFromRawDifficulty(difficultyRaw).color,
|
||||
color: getDifficulty(difficulty).color,
|
||||
borderColor: getDifficulty(difficulty).color,
|
||||
}}
|
||||
>
|
||||
{difficulty.name}
|
||||
{difficulty}
|
||||
</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 ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||
|
||||
@ -18,7 +18,7 @@ export function LeaderboardSongStarCount({ leaderboard }: LeaderboardSongStarCou
|
||||
<div
|
||||
className="w-fit h-[20px] rounded-sm flex justify-center items-center text-xs cursor-default"
|
||||
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">
|
||||
|
@ -26,7 +26,7 @@ export default function FriendsButton() {
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-2">
|
||||
{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">
|
||||
<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 ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
||||
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";
|
||||
|
||||
type Props = {
|
||||
|
@ -21,7 +21,7 @@ type ScoreModifiersProps = {
|
||||
export function ScoreModifiers({ score, type, limit }: ScoreModifiersProps) {
|
||||
const modifiers = score.modifiers;
|
||||
if (modifiers.length === 0) {
|
||||
return <p>-</p>;
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
|
@ -2,10 +2,10 @@ import FallbackLink from "@/components/fallback-link";
|
||||
import Tooltip from "@/components/tooltip";
|
||||
import { StarIcon } from "@heroicons/react/24/solid";
|
||||
import Image from "next/image";
|
||||
import { songDifficultyToColor } from "@/common/song-utils";
|
||||
import Link from "next/link";
|
||||
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 = {
|
||||
leaderboard: ScoreSaberLeaderboard;
|
||||
@ -14,25 +14,25 @@ type Props = {
|
||||
|
||||
export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
|
||||
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 difficulty = leaderboard.difficulty;
|
||||
const difficulty = leaderboard.difficulty.difficulty.replace("Plus", "+");
|
||||
return (
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="relative flex justify-center h-[64px]">
|
||||
<Tooltip
|
||||
display={
|
||||
<>
|
||||
<p>Difficulty: {difficulty.difficulty}</p>
|
||||
<div>
|
||||
<p>Difficulty: {difficulty}</p>
|
||||
{starCount > 0 && <p>Stars: {starCount.toFixed(2)}</p>}
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="absolute w-full h-[18px] bottom-0 right-0 rounded-sm flex justify-center items-center text-[0.70rem] cursor-default"
|
||||
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 ? (
|
||||
@ -41,7 +41,7 @@ export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
|
||||
<StarIcon className="w-[14px] h-[14px]" />
|
||||
</div>
|
||||
) : (
|
||||
<p>{difficulty.difficulty}</p>
|
||||
<p>{difficulty}</p>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
@ -3,7 +3,7 @@
|
||||
import LeaderboardScores from "@/components/leaderboard/leaderboard-scores";
|
||||
import { useEffect, useState } from "react";
|
||||
import ScoreButtons from "./score-buttons";
|
||||
import ScoreSongInfo from "./score-info";
|
||||
import ScoreSongInfo from "./score-song-info";
|
||||
import ScoreRankInfo from "./score-rank-info";
|
||||
import ScoreStats from "./score-stats";
|
||||
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 ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
||||
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 Card from "@/components/card";
|
||||
import StatValue from "@/components/stat-value";
|
||||
import { MapStats } from "@/components/score/map-stats";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
@ -106,11 +106,7 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr
|
||||
className="w-full mt-2"
|
||||
>
|
||||
<Card className="flex gap-4 w-full relative border border-input">
|
||||
{score.additionalData && (
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
<StatValue name="Pauses" value={score.additionalData.pauses} />
|
||||
</div>
|
||||
)}
|
||||
<MapStats leaderboard={leaderboard} beatSaver={beatSaverMap} />
|
||||
|
||||
<LeaderboardScores
|
||||
initialPage={getPageFromRank(score.rank, 12)}
|
||||
|
@ -6,6 +6,11 @@ type Props = {
|
||||
*/
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* The icon for the stat.
|
||||
*/
|
||||
icon?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* The background color of the stat.
|
||||
*/
|
||||
@ -17,7 +22,7 @@ type Props = {
|
||||
value: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function StatValue({ name, color, value }: Props) {
|
||||
export default function StatValue({ name, icon, color, value }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
@ -28,6 +33,7 @@ export default function StatValue({ name, color, value }: Props) {
|
||||
backgroundColor: (!color?.includes("bg") && color) || undefined,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{name && (
|
||||
<>
|
||||
<p>{name}</p>
|
||||
|
@ -37,8 +37,7 @@ export default function Tooltip({ children, display, asChild = true, side = "top
|
||||
return (
|
||||
<ShadCnTooltip>
|
||||
<TooltipTrigger className={className} asChild={asChild}>
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
className={cn("cursor-default", className)}
|
||||
onClick={() => setOpen(!open)}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
@ -46,7 +45,7 @@ export default function Tooltip({ children, display, asChild = true, side = "top
|
||||
onTouchStart={() => setOpen(!open)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[350px]" side={side}>
|
||||
{display}
|
||||
|
Reference in New Issue
Block a user