move score page fetching to the backend
This commit is contained in:
parent
118dc9d9f1
commit
b3c124631a
26
projects/backend/src/controller/leaderboard.controller.ts
Normal file
26
projects/backend/src/controller/leaderboard.controller.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Controller, Get } from "elysia-decorators";
|
||||||
|
import { t } from "elysia";
|
||||||
|
import { Leaderboards } from "@ssr/common/leaderboard";
|
||||||
|
import LeaderboardService from "../service/leaderboard.service";
|
||||||
|
|
||||||
|
@Controller("/leaderboard")
|
||||||
|
export default class LeaderboardController {
|
||||||
|
@Get("/:leaderboard/:id", {
|
||||||
|
config: {},
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String({ required: true }),
|
||||||
|
leaderboard: t.String({ required: true }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
public async getLeaderboard({
|
||||||
|
params: { leaderboard, id },
|
||||||
|
}: {
|
||||||
|
params: {
|
||||||
|
leaderboard: Leaderboards;
|
||||||
|
id: string;
|
||||||
|
page: number;
|
||||||
|
};
|
||||||
|
}): Promise<unknown> {
|
||||||
|
return await LeaderboardService.getLeaderboard(leaderboard, id);
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +1,8 @@
|
|||||||
import { Controller, Get } from "elysia-decorators";
|
import { Controller, Get } from "elysia-decorators";
|
||||||
import { PlayerService } from "../service/player.service";
|
import { PlayerService } from "../service/player.service";
|
||||||
import { t } from "elysia";
|
import { t } from "elysia";
|
||||||
import { PlayerHistory } from "@ssr/common/types/player/player-history";
|
import { PlayerHistory } from "@ssr/common/player/player-history";
|
||||||
import { PlayerTrackedSince } from "@ssr/common/types/player/player-tracked-since";
|
import { PlayerTrackedSince } from "@ssr/common/player/player-tracked-since";
|
||||||
|
|
||||||
@Controller("/player")
|
@Controller("/player")
|
||||||
export default class PlayerController {
|
export default class PlayerController {
|
||||||
|
55
projects/backend/src/controller/scores.controller.ts
Normal file
55
projects/backend/src/controller/scores.controller.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { Controller, Get } from "elysia-decorators";
|
||||||
|
import { t } from "elysia";
|
||||||
|
import { Leaderboards } from "@ssr/common/leaderboard";
|
||||||
|
import { ScoreService } from "../service/score.service";
|
||||||
|
|
||||||
|
@Controller("/scores")
|
||||||
|
export default class ScoresController {
|
||||||
|
@Get("/player/:leaderboard/:id/:page/:sort", {
|
||||||
|
config: {},
|
||||||
|
params: t.Object({
|
||||||
|
leaderboard: t.String({ required: true }),
|
||||||
|
id: t.String({ required: true }),
|
||||||
|
page: t.Number({ required: true }),
|
||||||
|
sort: t.String({ required: true }),
|
||||||
|
}),
|
||||||
|
query: t.Object({
|
||||||
|
search: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
public async getScores({
|
||||||
|
params: { leaderboard, id, page, sort },
|
||||||
|
query: { search },
|
||||||
|
}: {
|
||||||
|
params: {
|
||||||
|
leaderboard: Leaderboards;
|
||||||
|
id: string;
|
||||||
|
page: number;
|
||||||
|
sort: string;
|
||||||
|
};
|
||||||
|
query: { search?: string };
|
||||||
|
}): Promise<unknown> {
|
||||||
|
return await ScoreService.getPlayerScores(leaderboard, id, page, sort, search);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("/leaderboard/:leaderboard/:id/:page", {
|
||||||
|
config: {},
|
||||||
|
params: t.Object({
|
||||||
|
leaderboard: t.String({ required: true }),
|
||||||
|
id: t.String({ required: true }),
|
||||||
|
page: t.Number({ required: true }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
public async getLeaderboardScores({
|
||||||
|
params: { leaderboard, id, page },
|
||||||
|
}: {
|
||||||
|
params: {
|
||||||
|
leaderboard: Leaderboards;
|
||||||
|
id: string;
|
||||||
|
page: number;
|
||||||
|
};
|
||||||
|
query: { search?: string };
|
||||||
|
}): Promise<unknown> {
|
||||||
|
return await ScoreService.getLeaderboardScores(leaderboard, id, page);
|
||||||
|
}
|
||||||
|
}
|
@ -14,7 +14,6 @@ import { setLogLevel } from "@typegoose/typegoose";
|
|||||||
import PlayerController from "./controller/player.controller";
|
import PlayerController from "./controller/player.controller";
|
||||||
import { PlayerService } from "./service/player.service";
|
import { PlayerService } from "./service/player.service";
|
||||||
import { cron } from "@elysiajs/cron";
|
import { cron } from "@elysiajs/cron";
|
||||||
import { PlayerDocument, PlayerModel } from "./model/player";
|
|
||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||||
import { delay } from "@ssr/common/utils/utils";
|
import { delay } from "@ssr/common/utils/utils";
|
||||||
import { connectScoreSaberWebSocket } from "@ssr/common/websocket/scoresaber-websocket";
|
import { connectScoreSaberWebSocket } from "@ssr/common/websocket/scoresaber-websocket";
|
||||||
@ -22,6 +21,9 @@ import ImageController from "./controller/image.controller";
|
|||||||
import ReplayController from "./controller/replay.controller";
|
import ReplayController from "./controller/replay.controller";
|
||||||
import { ScoreService } from "./service/score.service";
|
import { ScoreService } from "./service/score.service";
|
||||||
import { Config } from "@ssr/common/config";
|
import { Config } from "@ssr/common/config";
|
||||||
|
import { PlayerDocument, PlayerModel } from "@ssr/common/model/player";
|
||||||
|
import ScoresController from "./controller/scores.controller";
|
||||||
|
import LeaderboardController from "./controller/leaderboard.controller";
|
||||||
|
|
||||||
// Load .env file
|
// Load .env file
|
||||||
dotenv.config({
|
dotenv.config({
|
||||||
@ -159,7 +161,14 @@ app.use(
|
|||||||
*/
|
*/
|
||||||
app.use(
|
app.use(
|
||||||
decorators({
|
decorators({
|
||||||
controllers: [AppController, PlayerController, ImageController, ReplayController],
|
controllers: [
|
||||||
|
AppController,
|
||||||
|
PlayerController,
|
||||||
|
ImageController,
|
||||||
|
ReplayController,
|
||||||
|
ScoresController,
|
||||||
|
LeaderboardController,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { PlayerModel } from "../model/player";
|
import { PlayerModel } from "@ssr/common/model/player";
|
||||||
import { AppStatistics } from "@ssr/common/types/backend/app-statistics";
|
import { AppStatistics } from "@ssr/common/types/backend/app-statistics";
|
||||||
|
|
||||||
export class AppService {
|
export class AppService {
|
||||||
|
30
projects/backend/src/service/beatsaver.service.ts
Normal file
30
projects/backend/src/service/beatsaver.service.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { beatsaverService } from "@ssr/common/service/impl/beatsaver";
|
||||||
|
import { BeatSaverMap, BeatSaverMapModel } from "@ssr/common/model/beatsaver/beatsaver-map";
|
||||||
|
|
||||||
|
export default class BeatSaverService {
|
||||||
|
/**
|
||||||
|
* Gets a map by its hash.
|
||||||
|
*
|
||||||
|
* @param hash the hash of the map
|
||||||
|
* @returns the beatsaver map
|
||||||
|
*/
|
||||||
|
public static async getMap(hash: string): Promise<BeatSaverMap | undefined> {
|
||||||
|
let map = await BeatSaverMapModel.findById(hash);
|
||||||
|
if (map != undefined) {
|
||||||
|
return map.toObject() as BeatSaverMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await beatsaverService.lookupMap(hash);
|
||||||
|
if (token == undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
map = await BeatSaverMapModel.create({
|
||||||
|
_id: hash,
|
||||||
|
bsr: token.id,
|
||||||
|
author: {
|
||||||
|
id: token.uploader.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return map.toObject() as BeatSaverMap;
|
||||||
|
}
|
||||||
|
}
|
@ -7,7 +7,7 @@ import { StarIcon } from "../../components/star-icon";
|
|||||||
import { GlobeIcon } from "../../components/globe-icon";
|
import { GlobeIcon } from "../../components/globe-icon";
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
||||||
import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/types/player/impl/scoresaber-player";
|
import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/player/impl/scoresaber-player";
|
||||||
import { Jimp } from "jimp";
|
import { Jimp } from "jimp";
|
||||||
import { extractColors } from "extract-colors";
|
import { extractColors } from "extract-colors";
|
||||||
import { Config } from "@ssr/common/config";
|
import { Config } from "@ssr/common/config";
|
||||||
|
78
projects/backend/src/service/leaderboard.service.ts
Normal file
78
projects/backend/src/service/leaderboard.service.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { Leaderboards } from "@ssr/common/leaderboard";
|
||||||
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||||
|
import { SSRCache } from "@ssr/common/cache";
|
||||||
|
import { LeaderboardResponse } from "@ssr/common/response/leaderboard-response";
|
||||||
|
import Leaderboard from "@ssr/common/leaderboard/leaderboard";
|
||||||
|
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
||||||
|
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";
|
||||||
|
|
||||||
|
const leaderboardCache = new SSRCache({
|
||||||
|
ttl: 1000 * 60 * 60 * 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default class LeaderboardService {
|
||||||
|
/**
|
||||||
|
* Gets the leaderboard.
|
||||||
|
*
|
||||||
|
* @param leaderboard the leaderboard
|
||||||
|
* @param id the id
|
||||||
|
*/
|
||||||
|
private static async getLeaderboardToken<T>(leaderboard: Leaderboards, id: string): Promise<T | undefined> {
|
||||||
|
const cacheKey = `${leaderboard}-${id}`;
|
||||||
|
if (leaderboardCache.has(cacheKey)) {
|
||||||
|
return leaderboardCache.get(cacheKey) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (leaderboard) {
|
||||||
|
case "scoresaber": {
|
||||||
|
const leaderboard = (await scoresaberService.lookupLeaderboard(id)) as T;
|
||||||
|
leaderboardCache.set(cacheKey, leaderboard);
|
||||||
|
return leaderboard;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a leaderboard.
|
||||||
|
*
|
||||||
|
* @param leaderboardName the leaderboard to get
|
||||||
|
* @param id the players id
|
||||||
|
* @returns the scores
|
||||||
|
*/
|
||||||
|
public static async getLeaderboard(
|
||||||
|
leaderboardName: Leaderboards,
|
||||||
|
id: string
|
||||||
|
): Promise<LeaderboardResponse<Leaderboard>> {
|
||||||
|
let leaderboard: Leaderboard | undefined;
|
||||||
|
let beatSaverMap: BeatSaverMap | undefined;
|
||||||
|
|
||||||
|
switch (leaderboardName) {
|
||||||
|
case "scoresaber": {
|
||||||
|
const leaderboardToken = await LeaderboardService.getLeaderboardToken<ScoreSaberLeaderboardToken>(
|
||||||
|
leaderboardName,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
if (leaderboardToken == undefined) {
|
||||||
|
throw new NotFoundError(`Leaderboard not found for "${id}"`);
|
||||||
|
}
|
||||||
|
leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken);
|
||||||
|
beatSaverMap = await BeatSaverService.getMap(leaderboard.songHash);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
leaderboard: leaderboard,
|
||||||
|
beatsaver: beatSaverMap,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { PlayerDocument, PlayerModel } from "../model/player";
|
import { PlayerDocument, PlayerModel } from "@ssr/common/model/player";
|
||||||
import { NotFoundError } from "../error/not-found-error";
|
import { NotFoundError } from "../error/not-found-error";
|
||||||
import { getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-utils";
|
import { getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-utils";
|
||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||||
|
@ -5,6 +5,21 @@ import { MessageBuilder, Webhook } from "discord-webhook-node";
|
|||||||
import { formatPp } from "@ssr/common/utils/number-utils";
|
import { formatPp } from "@ssr/common/utils/number-utils";
|
||||||
import { isProduction } from "@ssr/common/utils/utils";
|
import { isProduction } from "@ssr/common/utils/utils";
|
||||||
import { Config } from "@ssr/common/config";
|
import { Config } from "@ssr/common/config";
|
||||||
|
import { Metadata } from "@ssr/common/types/metadata";
|
||||||
|
import { NotFoundError } from "elysia";
|
||||||
|
import BeatSaverService from "./beatsaver.service";
|
||||||
|
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
|
import { getScoreSaberScoreFromToken } from "@ssr/common/score/impl/scoresaber-score";
|
||||||
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||||
|
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 { PlayerScore } from "@ssr/common/score/player-score";
|
||||||
|
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
|
||||||
|
import Score from "@ssr/common/score/score";
|
||||||
|
import PlayerScoresResponse from "@ssr/common/response/player-scores-response";
|
||||||
|
|
||||||
export class ScoreService {
|
export class ScoreService {
|
||||||
/**
|
/**
|
||||||
@ -45,4 +60,137 @@ export class ScoreService {
|
|||||||
embed.setColor("#00ff00");
|
embed.setColor("#00ff00");
|
||||||
await hook.send(embed);
|
await hook.send(embed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets scores for a player.
|
||||||
|
*
|
||||||
|
* @param leaderboardName the leaderboard to get the scores from
|
||||||
|
* @param id the players id
|
||||||
|
* @param page the page to get
|
||||||
|
* @param sort the sort to use
|
||||||
|
* @param search the search to use
|
||||||
|
* @returns the scores
|
||||||
|
*/
|
||||||
|
public static async getPlayerScores(
|
||||||
|
leaderboardName: Leaderboards,
|
||||||
|
id: string,
|
||||||
|
page: number,
|
||||||
|
sort: string,
|
||||||
|
search?: string
|
||||||
|
): Promise<PlayerScoresResponse<unknown, unknown>> {
|
||||||
|
const scores: PlayerScore<unknown, unknown>[] | undefined = [];
|
||||||
|
let beatSaverMap: BeatSaverMap | undefined;
|
||||||
|
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
|
||||||
|
|
||||||
|
switch (leaderboardName) {
|
||||||
|
case "scoresaber": {
|
||||||
|
const leaderboardScores = await scoresaberService.lookupPlayerScores({
|
||||||
|
playerId: id,
|
||||||
|
page: page,
|
||||||
|
sort: sort as ScoreSort,
|
||||||
|
search: search,
|
||||||
|
});
|
||||||
|
if (leaderboardScores == undefined) {
|
||||||
|
throw new NotFoundError(
|
||||||
|
`No scores found for "${id}", leaderboard "${leaderboardName}", page "${page}", sort "${sort}", search "${search}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const token of leaderboardScores.playerScores) {
|
||||||
|
const score = getScoreSaberScoreFromToken(token.score);
|
||||||
|
if (score == undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const tokenLeaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard);
|
||||||
|
if (tokenLeaderboard == undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
beatSaverMap = await BeatSaverService.getMap(tokenLeaderboard.songHash);
|
||||||
|
|
||||||
|
scores.push({
|
||||||
|
score: score,
|
||||||
|
leaderboard: tokenLeaderboard,
|
||||||
|
beatSaver: beatSaverMap,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata = new Metadata(
|
||||||
|
Math.ceil(leaderboardScores.metadata.total / leaderboardScores.metadata.itemsPerPage),
|
||||||
|
leaderboardScores.metadata.total,
|
||||||
|
leaderboardScores.metadata.page,
|
||||||
|
leaderboardScores.metadata.itemsPerPage
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
scores: scores,
|
||||||
|
metadata: metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets scores for a leaderboard.
|
||||||
|
*
|
||||||
|
* @param leaderboardName the leaderboard to get the scores from
|
||||||
|
* @param id the leaderboard id
|
||||||
|
* @param page the page to get
|
||||||
|
* @returns the scores
|
||||||
|
*/
|
||||||
|
public static async getLeaderboardScores(
|
||||||
|
leaderboardName: Leaderboards,
|
||||||
|
id: string,
|
||||||
|
page: number
|
||||||
|
): Promise<LeaderboardScoresResponse<unknown>> {
|
||||||
|
const scores: Score[] = [];
|
||||||
|
let leaderboard: Leaderboard | undefined;
|
||||||
|
let beatSaverMap: BeatSaverMap | undefined;
|
||||||
|
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
|
||||||
|
|
||||||
|
switch (leaderboardName) {
|
||||||
|
case "scoresaber": {
|
||||||
|
const leaderboardScores = await scoresaberService.lookupLeaderboardScores(id, page);
|
||||||
|
if (leaderboardScores == undefined) {
|
||||||
|
throw new NotFoundError(`No scores found for "${id}", leaderboard "${leaderboardName}", page "${page}""`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaderboardResponse = await LeaderboardService.getLeaderboard(leaderboardName, id);
|
||||||
|
if (leaderboardResponse == undefined) {
|
||||||
|
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
|
||||||
|
}
|
||||||
|
leaderboard = leaderboardResponse.leaderboard;
|
||||||
|
beatSaverMap = leaderboardResponse.beatsaver;
|
||||||
|
|
||||||
|
for (const token of leaderboardScores.scores) {
|
||||||
|
const score = getScoreSaberScoreFromToken(token);
|
||||||
|
if (score == undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
scores.push(score);
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata = new Metadata(
|
||||||
|
Math.ceil(leaderboardScores.metadata.total / leaderboardScores.metadata.itemsPerPage),
|
||||||
|
leaderboardScores.metadata.total,
|
||||||
|
leaderboardScores.metadata.page,
|
||||||
|
leaderboardScores.metadata.itemsPerPage
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
scores: scores,
|
||||||
|
leaderboard: leaderboard,
|
||||||
|
beatSaver: beatSaverMap,
|
||||||
|
metadata: metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,6 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"jsx": "react"
|
"jsx": "react",
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ky": "^1.7.2",
|
"ky": "^1.7.2",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0",
|
||||||
|
"@typegoose/typegoose": "^12.8.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
5
projects/common/src/leaderboard.ts
Normal file
5
projects/common/src/leaderboard.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
const Leaderboards = {
|
||||||
|
SCORESABER: "scoresaber",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Leaderboards = (typeof Leaderboards)[keyof typeof Leaderboards];
|
@ -0,0 +1,63 @@
|
|||||||
|
import Leaderboard from "../leaderboard";
|
||||||
|
import LeaderboardDifficulty from "../leaderboard-difficulty";
|
||||||
|
import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber-leaderboard-token";
|
||||||
|
import { getDifficultyFromScoreSaberDifficulty } from "../../utils/scoresaber-utils";
|
||||||
|
import { parseDate } from "../../utils/time-utils";
|
||||||
|
|
||||||
|
export default interface ScoreSaberLeaderboard extends Leaderboard {
|
||||||
|
/**
|
||||||
|
* The star count for the leaderboard.
|
||||||
|
*/
|
||||||
|
readonly stars: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total amount of plays.
|
||||||
|
*/
|
||||||
|
readonly plays: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of plays today.
|
||||||
|
*/
|
||||||
|
readonly dailyPlays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a {@link ScoreSaberLeaderboardToken} into a {@link ScoreSaberLeaderboard}.
|
||||||
|
*
|
||||||
|
* @param token the token to parse
|
||||||
|
*/
|
||||||
|
export function getScoreSaberLeaderboardFromToken(token: ScoreSaberLeaderboardToken): ScoreSaberLeaderboard {
|
||||||
|
const difficulty: LeaderboardDifficulty = {
|
||||||
|
leaderboardId: token.difficulty.leaderboardId,
|
||||||
|
difficulty: getDifficultyFromScoreSaberDifficulty(token.difficulty.difficulty),
|
||||||
|
gameMode: token.difficulty.gameMode,
|
||||||
|
difficultyRaw: token.difficulty.difficultyRaw,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id: token.id,
|
||||||
|
songHash: token.songHash,
|
||||||
|
songName: token.songName,
|
||||||
|
songSubName: token.songSubName,
|
||||||
|
songAuthorName: token.songAuthorName,
|
||||||
|
levelAuthorName: token.levelAuthorName,
|
||||||
|
difficulty: difficulty,
|
||||||
|
difficulties:
|
||||||
|
token.difficulties != undefined && token.difficulties.length > 0
|
||||||
|
? token.difficulties.map(difficulty => {
|
||||||
|
return {
|
||||||
|
leaderboardId: difficulty.leaderboardId,
|
||||||
|
difficulty: getDifficultyFromScoreSaberDifficulty(difficulty.difficulty),
|
||||||
|
gameMode: difficulty.gameMode,
|
||||||
|
difficultyRaw: difficulty.difficultyRaw,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [difficulty],
|
||||||
|
maxScore: token.maxScore,
|
||||||
|
ranked: token.ranked,
|
||||||
|
songArt: token.coverImage,
|
||||||
|
timestamp: parseDate(token.createdDate),
|
||||||
|
stars: token.stars,
|
||||||
|
plays: token.plays,
|
||||||
|
dailyPlays: token.dailyPlays,
|
||||||
|
};
|
||||||
|
}
|
23
projects/common/src/leaderboard/leaderboard-difficulty.ts
Normal file
23
projects/common/src/leaderboard/leaderboard-difficulty.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Difficulty } from "../score/difficulty";
|
||||||
|
|
||||||
|
export default interface LeaderboardDifficulty {
|
||||||
|
/**
|
||||||
|
* The id of the leaderboard.
|
||||||
|
*/
|
||||||
|
leaderboardId: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The difficulty of the leaderboard.
|
||||||
|
*/
|
||||||
|
difficulty: Difficulty;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The game mode of the leaderboard.
|
||||||
|
*/
|
||||||
|
gameMode: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The raw difficulty of the leaderboard.
|
||||||
|
*/
|
||||||
|
difficultyRaw: string;
|
||||||
|
}
|
75
projects/common/src/leaderboard/leaderboard.ts
Normal file
75
projects/common/src/leaderboard/leaderboard.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import LeaderboardDifficulty from "./leaderboard-difficulty";
|
||||||
|
|
||||||
|
export default interface Leaderboard {
|
||||||
|
/**
|
||||||
|
* The id of the leaderboard.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
readonly id: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hash of the song this leaderboard is for.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
readonly songHash: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the song this leaderboard is for.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
readonly songName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sub name of the leaderboard.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
readonly songSubName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The author of the song this leaderboard is for.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
readonly songAuthorName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The author of the level this leaderboard is for.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
readonly levelAuthorName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The difficulty of the leaderboard.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
readonly difficulty: LeaderboardDifficulty;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The difficulties of the leaderboard.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
readonly difficulties: LeaderboardDifficulty[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum score of the leaderboard.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
readonly maxScore: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the leaderboard is ranked.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
readonly ranked: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The link to the song art.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
readonly songArt: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date the leaderboard was created.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
readonly timestamp: Date;
|
||||||
|
}
|
13
projects/common/src/model/beatsaver/beatsaver-author.ts
Normal file
13
projects/common/src/model/beatsaver/beatsaver-author.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
51
projects/common/src/model/beatsaver/beatsaver-map.ts
Normal file
51
projects/common/src/model/beatsaver/beatsaver-map.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
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: true })
|
||||||
|
public bsr!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The author of the map.
|
||||||
|
*/
|
||||||
|
@prop({ required: true, _id: false, type: () => BeatsaverAuthor })
|
||||||
|
public author!: BeatsaverAuthor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
@ -1,7 +1,7 @@
|
|||||||
import { getModelForClass, modelOptions, prop, ReturnModelType, Severity } from "@typegoose/typegoose";
|
import { getModelForClass, modelOptions, prop, ReturnModelType, Severity } from "@typegoose/typegoose";
|
||||||
import { Document } from "mongoose";
|
import { Document } from "mongoose";
|
||||||
import { PlayerHistory } from "@ssr/common/types/player/player-history";
|
import { PlayerHistory } from "../player/player-history";
|
||||||
import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-utils";
|
import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate } from "../utils/time-utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The model for a player.
|
* The model for a player.
|
||||||
@ -109,8 +109,5 @@ export class Player {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This type defines a Mongoose document based on Player.
|
|
||||||
export type PlayerDocument = Player & Document;
|
export type PlayerDocument = Player & Document;
|
||||||
|
|
||||||
// This type ensures that PlayerModel returns Mongoose documents (PlayerDocument) that have Mongoose methods (save, remove, etc.)
|
|
||||||
export const PlayerModel: ReturnModelType<typeof Player> = getModelForClass(Player);
|
export const PlayerModel: ReturnModelType<typeof Player> = getModelForClass(Player);
|
@ -1,10 +1,10 @@
|
|||||||
import Player, { StatisticChange } from "../player";
|
import Player, { StatisticChange } from "../player";
|
||||||
import ky from "ky";
|
import ky from "ky";
|
||||||
import { PlayerHistory } from "../player-history";
|
import { PlayerHistory } from "../player-history";
|
||||||
import ScoreSaberPlayerToken from "../../token/scoresaber/score-saber-player-token";
|
import ScoreSaberPlayerToken from "../../types/token/scoresaber/score-saber-player-token";
|
||||||
import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate } from "../../../utils/time-utils";
|
import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate } from "../../utils/time-utils";
|
||||||
import { getPageFromRank } from "../../../utils/utils";
|
import { getPageFromRank } from "../../utils/utils";
|
||||||
import { Config } from "../../../config";
|
import { Config } from "../../config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A ScoreSaber player.
|
* A ScoreSaber player.
|
13
projects/common/src/response/leaderboard-response.ts
Normal file
13
projects/common/src/response/leaderboard-response.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
|
||||||
|
|
||||||
|
export type LeaderboardResponse<L> = {
|
||||||
|
/**
|
||||||
|
* The leaderboard.
|
||||||
|
*/
|
||||||
|
leaderboard: L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The beatsaver map.
|
||||||
|
*/
|
||||||
|
beatsaver?: BeatSaverMap;
|
||||||
|
};
|
25
projects/common/src/response/leaderboard-scores-response.ts
Normal file
25
projects/common/src/response/leaderboard-scores-response.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Metadata } from "../types/metadata";
|
||||||
|
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
|
||||||
|
import Score from "../score/score";
|
||||||
|
|
||||||
|
export default interface LeaderboardScoresResponse<L> {
|
||||||
|
/**
|
||||||
|
* The scores that were set.
|
||||||
|
*/
|
||||||
|
readonly scores: Score[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The leaderboard that was used.
|
||||||
|
*/
|
||||||
|
readonly leaderboard: L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The beatsaver map for the song.
|
||||||
|
*/
|
||||||
|
readonly beatSaver?: BeatSaverMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pagination metadata.
|
||||||
|
*/
|
||||||
|
readonly metadata: Metadata;
|
||||||
|
}
|
14
projects/common/src/response/player-scores-response.ts
Normal file
14
projects/common/src/response/player-scores-response.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Metadata } from "../types/metadata";
|
||||||
|
import { PlayerScore } from "../score/player-score";
|
||||||
|
|
||||||
|
export default interface PlayerScoresResponse<S, L> {
|
||||||
|
/**
|
||||||
|
* The scores that were set.
|
||||||
|
*/
|
||||||
|
readonly scores: PlayerScore<S, L>[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pagination metadata.
|
||||||
|
*/
|
||||||
|
readonly metadata: Metadata;
|
||||||
|
}
|
1
projects/common/src/score/difficulty.ts
Normal file
1
projects/common/src/score/difficulty.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export type Difficulty = "Easy" | "Normal" | "Hard" | "Expert" | "Expert+" | "Unknown";
|
62
projects/common/src/score/impl/scoresaber-score.ts
Normal file
62
projects/common/src/score/impl/scoresaber-score.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import Score from "../score";
|
||||||
|
import { Modifier } from "../modifier";
|
||||||
|
import ScoreSaberScoreToken from "../../types/token/scoresaber/score-saber-score-token";
|
||||||
|
import ScoreSaberLeaderboardPlayerInfoToken from "../../types/token/scoresaber/score-saber-leaderboard-player-info-token";
|
||||||
|
|
||||||
|
export default interface ScoreSaberScore extends Score {
|
||||||
|
/**
|
||||||
|
* The score's id.
|
||||||
|
*/
|
||||||
|
readonly id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of pp for the score.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
readonly pp: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The weight of the score, or undefined if not ranked.s
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
readonly weight?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player who set the score
|
||||||
|
*/
|
||||||
|
readonly playerInfo: ScoreSaberLeaderboardPlayerInfoToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a {@link ScoreSaberScore} from a {@link ScoreSaberScoreToken}.
|
||||||
|
*
|
||||||
|
* @param token the token to convert
|
||||||
|
*/
|
||||||
|
export function getScoreSaberScoreFromToken(token: ScoreSaberScoreToken): ScoreSaberScore {
|
||||||
|
const modifiers: Modifier[] =
|
||||||
|
token.modifiers == undefined || token.modifiers === ""
|
||||||
|
? []
|
||||||
|
: token.modifiers.split(",").map(mod => {
|
||||||
|
mod = mod.toUpperCase();
|
||||||
|
const modifier = Modifier[mod as keyof typeof Modifier];
|
||||||
|
if (modifier === undefined) {
|
||||||
|
throw new Error(`Unknown modifier: ${mod}`);
|
||||||
|
}
|
||||||
|
return modifier;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
leaderboard: "scoresaber",
|
||||||
|
score: token.baseScore,
|
||||||
|
rank: token.rank,
|
||||||
|
modifiers: modifiers,
|
||||||
|
misses: token.missedNotes,
|
||||||
|
badCuts: token.badCuts,
|
||||||
|
fullCombo: token.fullCombo,
|
||||||
|
timestamp: new Date(token.timeSet),
|
||||||
|
id: token.id,
|
||||||
|
pp: token.pp,
|
||||||
|
weight: token.weight,
|
||||||
|
playerInfo: token.leaderboardPlayerInfo,
|
||||||
|
};
|
||||||
|
}
|
@ -15,4 +15,6 @@ export enum Modifier {
|
|||||||
CS = "Fail on Saber Clash",
|
CS = "Fail on Saber Clash",
|
||||||
IF = "One Life",
|
IF = "One Life",
|
||||||
BE = "Battery Energy",
|
BE = "Battery Energy",
|
||||||
|
NF = "No Fail",
|
||||||
|
NB = "No Bombs",
|
||||||
}
|
}
|
6
projects/common/src/score/player-leaderboard-score.ts
Normal file
6
projects/common/src/score/player-leaderboard-score.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default interface PlayerLeaderboardScore<S> {
|
||||||
|
/**
|
||||||
|
* The score that was set.
|
||||||
|
*/
|
||||||
|
readonly score: S;
|
||||||
|
}
|
18
projects/common/src/score/player-score.ts
Normal file
18
projects/common/src/score/player-score.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
|
||||||
|
|
||||||
|
export interface PlayerScore<S, L> {
|
||||||
|
/**
|
||||||
|
* The score.
|
||||||
|
*/
|
||||||
|
readonly score: S;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The leaderboard the score was set on.
|
||||||
|
*/
|
||||||
|
readonly leaderboard: L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The BeatSaver of the song.
|
||||||
|
*/
|
||||||
|
readonly beatSaver?: BeatSaverMap;
|
||||||
|
}
|
51
projects/common/src/score/score.ts
Normal file
51
projects/common/src/score/score.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { Modifier } from "./modifier";
|
||||||
|
import { Leaderboards } from "../leaderboard";
|
||||||
|
|
||||||
|
export default interface Score {
|
||||||
|
/**
|
||||||
|
* The leaderboard the score is from.
|
||||||
|
*/
|
||||||
|
readonly leaderboard: Leaderboards;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base score for the score.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
readonly score: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rank for the score.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
readonly rank: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The modifiers used on the score.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
readonly modifiers: Modifier[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount missed notes.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
readonly misses: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of bad cuts.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
readonly badCuts: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether every note was hit.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
readonly fullCombo: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time the score was set.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
readonly timestamp: Date;
|
||||||
|
}
|
@ -2,7 +2,7 @@ import Service from "../service";
|
|||||||
import { ScoreSaberPlayerSearchToken } from "../../types/token/scoresaber/score-saber-player-search-token";
|
import { ScoreSaberPlayerSearchToken } from "../../types/token/scoresaber/score-saber-player-search-token";
|
||||||
import ScoreSaberPlayerToken from "../../types/token/scoresaber/score-saber-player-token";
|
import ScoreSaberPlayerToken from "../../types/token/scoresaber/score-saber-player-token";
|
||||||
import { ScoreSaberPlayersPageToken } from "../../types/token/scoresaber/score-saber-players-page-token";
|
import { ScoreSaberPlayersPageToken } from "../../types/token/scoresaber/score-saber-players-page-token";
|
||||||
import { ScoreSort } from "../../types/score/score-sort";
|
import { ScoreSort } from "../../score/score-sort";
|
||||||
import ScoreSaberPlayerScoresPageToken from "../../types/token/scoresaber/score-saber-player-scores-page-token";
|
import ScoreSaberPlayerScoresPageToken from "../../types/token/scoresaber/score-saber-player-scores-page-token";
|
||||||
import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber-leaderboard-token";
|
import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber-leaderboard-token";
|
||||||
import ScoreSaberLeaderboardScoresPageToken from "../../types/token/scoresaber/score-saber-leaderboard-scores-page-token";
|
import ScoreSaberLeaderboardScoresPageToken from "../../types/token/scoresaber/score-saber-leaderboard-scores-page-token";
|
||||||
|
@ -40,7 +40,6 @@ export default class Service {
|
|||||||
try {
|
try {
|
||||||
return await ky.get<T>(this.buildRequestUrl(true, url)).json();
|
return await ky.get<T>(this.buildRequestUrl(true, url)).json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error fetching data from ${url}:`, error);
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
28
projects/common/src/types/metadata.ts
Normal file
28
projects/common/src/types/metadata.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
export class Metadata {
|
||||||
|
/**
|
||||||
|
* The amount of pages in the pagination
|
||||||
|
*/
|
||||||
|
public readonly totalPages: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total amount of items
|
||||||
|
*/
|
||||||
|
public readonly totalItems: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current page
|
||||||
|
*/
|
||||||
|
public readonly page: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of items per page
|
||||||
|
*/
|
||||||
|
public readonly itemsPerPage: number;
|
||||||
|
|
||||||
|
constructor(totalPages: number, totalItems: number, page: number, itemsPerPage: number) {
|
||||||
|
this.totalPages = totalPages;
|
||||||
|
this.totalItems = totalItems;
|
||||||
|
this.page = page;
|
||||||
|
this.itemsPerPage = itemsPerPage;
|
||||||
|
}
|
||||||
|
}
|
13
projects/common/src/types/page.ts
Normal file
13
projects/common/src/types/page.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Metadata } from "./metadata";
|
||||||
|
|
||||||
|
export type Page<T> = {
|
||||||
|
/**
|
||||||
|
* The data to return.
|
||||||
|
*/
|
||||||
|
data: T[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The metadata of the page.
|
||||||
|
*/
|
||||||
|
metadata: Metadata;
|
||||||
|
};
|
@ -1,47 +0,0 @@
|
|||||||
import Score from "../score";
|
|
||||||
import { Modifier } from "../modifier";
|
|
||||||
import ScoreSaberScoreToken from "../../token/scoresaber/score-saber-score-token";
|
|
||||||
|
|
||||||
export default class ScoreSaberScore extends Score {
|
|
||||||
constructor(
|
|
||||||
score: number,
|
|
||||||
weight: number | undefined,
|
|
||||||
rank: number,
|
|
||||||
worth: number,
|
|
||||||
modifiers: Modifier[],
|
|
||||||
misses: number,
|
|
||||||
badCuts: number,
|
|
||||||
fullCombo: boolean,
|
|
||||||
timestamp: Date
|
|
||||||
) {
|
|
||||||
super(score, weight, rank, worth, modifiers, misses, badCuts, fullCombo, timestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets a {@link ScoreSaberScore} from a {@link ScoreSaberScoreToken}.
|
|
||||||
*
|
|
||||||
* @param token the token to convert
|
|
||||||
*/
|
|
||||||
public static fromToken(token: ScoreSaberScoreToken): ScoreSaberScore {
|
|
||||||
const modifiers: Modifier[] = token.modifiers.split(",").map(mod => {
|
|
||||||
mod = mod.toUpperCase();
|
|
||||||
const modifier = Modifier[mod as keyof typeof Modifier];
|
|
||||||
if (modifier === undefined) {
|
|
||||||
throw new Error(`Unknown modifier: ${mod}`);
|
|
||||||
}
|
|
||||||
return modifier;
|
|
||||||
});
|
|
||||||
|
|
||||||
return new ScoreSaberScore(
|
|
||||||
token.baseScore,
|
|
||||||
token.weight,
|
|
||||||
token.rank,
|
|
||||||
token.pp,
|
|
||||||
modifiers,
|
|
||||||
token.missedNotes,
|
|
||||||
token.badCuts,
|
|
||||||
token.fullCombo,
|
|
||||||
new Date(token.timeSet)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,116 +0,0 @@
|
|||||||
import { Modifier } from "./modifier";
|
|
||||||
|
|
||||||
export default class Score {
|
|
||||||
/**
|
|
||||||
* The base score for the score.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private readonly _score: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The weight of the score, or undefined if not ranked.s
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private readonly _weight: number | undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The rank for the score.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private readonly _rank: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The worth of the score (this could be pp, ap, cr, etc.),
|
|
||||||
* or undefined if not ranked.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private readonly _worth: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The modifiers used on the score.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private readonly _modifiers: Modifier[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The amount missed notes.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private readonly _misses: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The amount of bad cuts.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private readonly _badCuts: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether every note was hit.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private readonly _fullCombo: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The time the score was set.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private readonly _timestamp: Date;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
score: number,
|
|
||||||
weight: number | undefined,
|
|
||||||
rank: number,
|
|
||||||
worth: number,
|
|
||||||
modifiers: Modifier[],
|
|
||||||
misses: number,
|
|
||||||
badCuts: number,
|
|
||||||
fullCombo: boolean,
|
|
||||||
timestamp: Date
|
|
||||||
) {
|
|
||||||
this._score = score;
|
|
||||||
this._weight = weight;
|
|
||||||
this._rank = rank;
|
|
||||||
this._worth = worth;
|
|
||||||
this._modifiers = modifiers;
|
|
||||||
this._misses = misses;
|
|
||||||
this._badCuts = badCuts;
|
|
||||||
this._fullCombo = fullCombo;
|
|
||||||
this._timestamp = timestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
get score(): number {
|
|
||||||
return this._score;
|
|
||||||
}
|
|
||||||
|
|
||||||
get weight(): number | undefined {
|
|
||||||
return this._weight;
|
|
||||||
}
|
|
||||||
|
|
||||||
get rank(): number {
|
|
||||||
return this._rank;
|
|
||||||
}
|
|
||||||
|
|
||||||
get worth(): number {
|
|
||||||
return this._worth;
|
|
||||||
}
|
|
||||||
|
|
||||||
get modifiers(): Modifier[] {
|
|
||||||
return this._modifiers;
|
|
||||||
}
|
|
||||||
|
|
||||||
get misses(): number {
|
|
||||||
return this._misses;
|
|
||||||
}
|
|
||||||
|
|
||||||
get badCuts(): number {
|
|
||||||
return this._badCuts;
|
|
||||||
}
|
|
||||||
|
|
||||||
get fullCombo(): boolean {
|
|
||||||
return this._fullCombo;
|
|
||||||
}
|
|
||||||
|
|
||||||
get timestamp(): Date {
|
|
||||||
return this._timestamp;
|
|
||||||
}
|
|
||||||
}
|
|
@ -19,8 +19,8 @@ export default interface ScoreSaberLeaderboardToken {
|
|||||||
maxPP: number;
|
maxPP: number;
|
||||||
stars: number;
|
stars: number;
|
||||||
positiveModifiers: boolean;
|
positiveModifiers: boolean;
|
||||||
plays: boolean;
|
plays: number;
|
||||||
dailyPlays: boolean;
|
dailyPlays: number;
|
||||||
coverImage: string;
|
coverImage: string;
|
||||||
difficulties: ScoreSaberDifficultyToken[];
|
difficulties: ScoreSaberDifficultyToken[];
|
||||||
}
|
}
|
||||||
|
14
projects/common/src/utils/leaderboard.util.ts
Normal file
14
projects/common/src/utils/leaderboard.util.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Config } from "../config";
|
||||||
|
import { LeaderboardResponse } from "../response/leaderboard-response";
|
||||||
|
import { kyFetch } from "./utils";
|
||||||
|
import { Leaderboards } from "../leaderboard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the leaderboard
|
||||||
|
*
|
||||||
|
* @param id the leaderboard id
|
||||||
|
* @param leaderboard the leaderboard
|
||||||
|
*/
|
||||||
|
export async function fetchLeaderboard<L>(leaderboard: Leaderboards, id: string) {
|
||||||
|
return kyFetch<LeaderboardResponse<L>>(`${Config.apiUrl}/leaderboard/${leaderboard}/${id}`);
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { PlayerHistory } from "../types/player/player-history";
|
import { PlayerHistory } from "../player/player-history";
|
||||||
import { kyFetch } from "./utils";
|
import { kyFetch } from "./utils";
|
||||||
import { Config } from "../config";
|
import { Config } from "../config";
|
||||||
|
|
||||||
|
37
projects/common/src/utils/score-utils.ts
Normal file
37
projects/common/src/utils/score-utils.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { Leaderboards } from "../leaderboard";
|
||||||
|
import { kyFetch } from "./utils";
|
||||||
|
import PlayerScoresResponse from "../response/player-scores-response";
|
||||||
|
import { Config } from "../config";
|
||||||
|
import { ScoreSort } from "../score/score-sort";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the player's scores
|
||||||
|
*
|
||||||
|
* @param leaderboard the leaderboard
|
||||||
|
* @param id the player id
|
||||||
|
* @param page the page
|
||||||
|
* @param sort the sort
|
||||||
|
* @param search the search
|
||||||
|
*/
|
||||||
|
export async function fetchPlayerScores<S, L>(
|
||||||
|
leaderboard: Leaderboards,
|
||||||
|
id: string,
|
||||||
|
page: number,
|
||||||
|
sort: ScoreSort,
|
||||||
|
search?: string
|
||||||
|
) {
|
||||||
|
return kyFetch<PlayerScoresResponse<S, L>>(
|
||||||
|
`${Config.apiUrl}/scores/player/${leaderboard}/${id}/${page}/${sort}${search ? `?search=${search}` : ""}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the player's scores
|
||||||
|
*
|
||||||
|
* @param leaderboard the leaderboard
|
||||||
|
* @param id the player id
|
||||||
|
* @param page the page
|
||||||
|
*/
|
||||||
|
export async function fetchLeaderboardScores<S, L>(leaderboard: Leaderboards, id: string, page: number) {
|
||||||
|
return kyFetch<PlayerScoresResponse<S, L>>(`${Config.apiUrl}/scores/leaderboard/${leaderboard}/${id}/${page}`);
|
||||||
|
}
|
@ -1,9 +1,11 @@
|
|||||||
|
import { Difficulty } from "../score/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) {
|
export function getDifficultyFromScoreSaberDifficulty(diff: number): Difficulty {
|
||||||
switch (diff) {
|
switch (diff) {
|
||||||
case 1: {
|
case 1: {
|
||||||
return "Easy";
|
return "Easy";
|
||||||
|
@ -7,6 +7,8 @@
|
|||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
@ -3,11 +3,14 @@ import { redirect } from "next/navigation";
|
|||||||
import { Colors } from "@/common/colors";
|
import { Colors } from "@/common/colors";
|
||||||
import { getAverageColor } from "@/common/image-utils";
|
import { getAverageColor } from "@/common/image-utils";
|
||||||
import { LeaderboardData } from "@/components/leaderboard/leaderboard-data";
|
import { LeaderboardData } from "@/components/leaderboard/leaderboard-data";
|
||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
|
||||||
import ScoreSaberLeaderboardScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-scores-page-token";
|
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
|
||||||
import { Config } from "@ssr/common/config";
|
import { Config } from "@ssr/common/config";
|
||||||
|
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
||||||
|
import { LeaderboardResponse } from "@ssr/common/response/leaderboard-response";
|
||||||
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
|
import { fetchLeaderboard } from "@ssr/common/utils/leaderboard.util";
|
||||||
|
import PlayerScoresResponse from "../../../../../../common/src/response/player-scores-response.ts";
|
||||||
|
import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils";
|
||||||
|
|
||||||
const UNKNOWN_LEADERBOARD = {
|
const UNKNOWN_LEADERBOARD = {
|
||||||
title: "ScoreSaber Reloaded - Unknown Leaderboard",
|
title: "ScoreSaber Reloaded - Unknown Leaderboard",
|
||||||
@ -24,8 +27,8 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type LeaderboardData = {
|
type LeaderboardData = {
|
||||||
leaderboard: ScoreSaberLeaderboardToken | undefined;
|
leaderboardResponse: LeaderboardResponse<ScoreSaberLeaderboard>;
|
||||||
scores: ScoreSaberLeaderboardScoresPageToken | undefined;
|
scores?: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
|
||||||
page: number;
|
page: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -38,7 +41,10 @@ const leaderboardCache = new NodeCache({ stdTTL: 60, checkperiod: 120 });
|
|||||||
* @param fetchScores whether to fetch the scores
|
* @param fetchScores whether to fetch the scores
|
||||||
* @returns the leaderboard data and scores
|
* @returns the leaderboard data and scores
|
||||||
*/
|
*/
|
||||||
const getLeaderboardData = async ({ params }: Props, fetchScores: boolean = true) => {
|
const getLeaderboardData = async (
|
||||||
|
{ params }: Props,
|
||||||
|
fetchScores: boolean = true
|
||||||
|
): Promise<LeaderboardData | undefined> => {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const id = slug[0]; // The leaderboard id
|
const id = slug[0]; // The leaderboard id
|
||||||
const page = parseInt(slug[1]) || 1; // The page number
|
const page = parseInt(slug[1]) || 1; // The page number
|
||||||
@ -47,16 +53,17 @@ const getLeaderboardData = async ({ params }: Props, fetchScores: boolean = true
|
|||||||
if (leaderboardCache.has(cacheId)) {
|
if (leaderboardCache.has(cacheId)) {
|
||||||
return leaderboardCache.get(cacheId) as LeaderboardData;
|
return leaderboardCache.get(cacheId) as LeaderboardData;
|
||||||
}
|
}
|
||||||
|
const leaderboard = await fetchLeaderboard<ScoreSaberLeaderboard>("scoresaber", id + "");
|
||||||
const leaderboard = await scoresaberService.lookupLeaderboard(id);
|
if (leaderboard === undefined) {
|
||||||
let scores: ScoreSaberLeaderboardScoresPageToken | undefined;
|
return undefined;
|
||||||
if (fetchScores) {
|
|
||||||
scores = await scoresaberService.lookupLeaderboardScores(id + "", page);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const leaderboardData = {
|
const scores = fetchScores
|
||||||
|
? await fetchLeaderboardScores<ScoreSaberScore, ScoreSaberLeaderboard>("scoresaber", id + "", page)
|
||||||
|
: undefined;
|
||||||
|
const leaderboardData: LeaderboardData = {
|
||||||
|
leaderboardResponse: leaderboard,
|
||||||
page: page,
|
page: page,
|
||||||
leaderboard: leaderboard,
|
|
||||||
scores: scores,
|
scores: scores,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -65,8 +72,8 @@ const getLeaderboardData = async ({ params }: Props, fetchScores: boolean = true
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function generateMetadata(props: Props): Promise<Metadata> {
|
export async function generateMetadata(props: Props): Promise<Metadata> {
|
||||||
const { leaderboard } = await getLeaderboardData(props, false);
|
const response = await getLeaderboardData(props, false);
|
||||||
if (leaderboard === undefined) {
|
if (response === undefined) {
|
||||||
return {
|
return {
|
||||||
title: UNKNOWN_LEADERBOARD.title,
|
title: UNKNOWN_LEADERBOARD.title,
|
||||||
description: UNKNOWN_LEADERBOARD.description,
|
description: UNKNOWN_LEADERBOARD.description,
|
||||||
@ -77,6 +84,7 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { leaderboard } = response.leaderboardResponse;
|
||||||
return {
|
return {
|
||||||
title: `${leaderboard.songName} ${leaderboard.songSubName} by ${leaderboard.songAuthorName}`,
|
title: `${leaderboard.songName} ${leaderboard.songSubName} by ${leaderboard.songAuthorName}`,
|
||||||
openGraph: {
|
openGraph: {
|
||||||
@ -95,24 +103,25 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateViewport(props: Props): Promise<Viewport> {
|
export async function generateViewport(props: Props): Promise<Viewport> {
|
||||||
const { leaderboard } = await getLeaderboardData(props, false);
|
const response = await getLeaderboardData(props, false);
|
||||||
if (leaderboard === undefined) {
|
if (response === undefined) {
|
||||||
return {
|
return {
|
||||||
themeColor: Colors.primary,
|
themeColor: Colors.primary,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const color = await getAverageColor(leaderboard.coverImage);
|
const color = await getAverageColor(response.leaderboardResponse.leaderboard.songArt);
|
||||||
return {
|
return {
|
||||||
themeColor: color.color,
|
themeColor: color.color,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function LeaderboardPage(props: Props) {
|
export default async function LeaderboardPage(props: Props) {
|
||||||
const { leaderboard, scores, page } = await getLeaderboardData(props);
|
const response = await getLeaderboardData(props);
|
||||||
if (leaderboard == undefined) {
|
if (response == undefined) {
|
||||||
return redirect("/");
|
return redirect("/");
|
||||||
}
|
}
|
||||||
|
const { leaderboardResponse, scores } = response;
|
||||||
|
|
||||||
return <LeaderboardData initialLeaderboard={leaderboard} initialPage={page} initialScores={scores} />;
|
return <LeaderboardData initialLeaderboard={response.leaderboardResponse} initialScores={scores} />;
|
||||||
}
|
}
|
||||||
|
@ -3,13 +3,16 @@ import { Metadata, Viewport } from "next";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Colors } from "@/common/colors";
|
import { Colors } from "@/common/colors";
|
||||||
import { getAverageColor } from "@/common/image-utils";
|
import { getAverageColor } from "@/common/image-utils";
|
||||||
import { ScoreSort } from "@ssr/common/types/score/score-sort";
|
|
||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||||
import ScoreSaberPlayerScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-player-scores-page-token";
|
|
||||||
import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/types/player/impl/scoresaber-player";
|
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import { getCookieValue } from "@ssr/common/utils/cookie-utils";
|
import { getCookieValue } from "@ssr/common/utils/cookie-utils";
|
||||||
import { Config } from "@ssr/common/config";
|
import { Config } from "@ssr/common/config";
|
||||||
|
import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/player/impl/scoresaber-player";
|
||||||
|
import { ScoreSort } from "@ssr/common/score/score-sort";
|
||||||
|
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
||||||
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
|
import { fetchPlayerScores } from "@ssr/common/utils/score-utils";
|
||||||
|
import PlayerScoresResponse from "../../../../../../common/src/response/player-scores-response";
|
||||||
|
|
||||||
const UNKNOWN_PLAYER = {
|
const UNKNOWN_PLAYER = {
|
||||||
title: "ScoreSaber Reloaded - Unknown Player",
|
title: "ScoreSaber Reloaded - Unknown Player",
|
||||||
@ -27,7 +30,7 @@ type Props = {
|
|||||||
|
|
||||||
type PlayerData = {
|
type PlayerData = {
|
||||||
player: ScoreSaberPlayer | undefined;
|
player: ScoreSaberPlayer | undefined;
|
||||||
scores: ScoreSaberPlayerScoresPageToken | undefined;
|
scores: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined;
|
||||||
sort: ScoreSort;
|
sort: ScoreSort;
|
||||||
page: number;
|
page: number;
|
||||||
search: string;
|
search: string;
|
||||||
@ -56,14 +59,9 @@ const getPlayerData = async ({ params }: Props, fetchScores: boolean = true): Pr
|
|||||||
|
|
||||||
const playerToken = await scoresaberService.lookupPlayer(id);
|
const playerToken = await scoresaberService.lookupPlayer(id);
|
||||||
const player = playerToken && (await getScoreSaberPlayerFromToken(playerToken, await getCookieValue("playerId")));
|
const player = playerToken && (await getScoreSaberPlayerFromToken(playerToken, await getCookieValue("playerId")));
|
||||||
let scores: ScoreSaberPlayerScoresPageToken | undefined;
|
let scores: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined;
|
||||||
if (fetchScores) {
|
if (fetchScores) {
|
||||||
scores = await scoresaberService.lookupPlayerScores({
|
scores = await fetchPlayerScores("scoresaber", id, page, sort, search);
|
||||||
playerId: id,
|
|
||||||
sort,
|
|
||||||
page,
|
|
||||||
search,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const playerData = {
|
const playerData = {
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import Dexie, { EntityTable } from "dexie";
|
import Dexie, { EntityTable } from "dexie";
|
||||||
import BeatSaverMap from "./types/beatsaver-map";
|
|
||||||
import Settings from "./types/settings";
|
import Settings from "./types/settings";
|
||||||
import { Friend } from "@/common/database/types/friends";
|
import { Friend } from "@/common/database/types/friends";
|
||||||
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
|
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
|
||||||
@ -15,11 +14,6 @@ export default class Database extends Dexie {
|
|||||||
*/
|
*/
|
||||||
settings!: EntityTable<Settings, "id">;
|
settings!: EntityTable<Settings, "id">;
|
||||||
|
|
||||||
/**
|
|
||||||
* Cached BeatSaver maps
|
|
||||||
*/
|
|
||||||
beatSaverMaps!: EntityTable<BeatSaverMap, "hash">;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The added friends
|
* The added friends
|
||||||
*/
|
*/
|
||||||
@ -37,7 +31,6 @@ export default class Database extends Dexie {
|
|||||||
|
|
||||||
// Mapped tables
|
// Mapped tables
|
||||||
this.settings.mapToClass(Settings);
|
this.settings.mapToClass(Settings);
|
||||||
this.beatSaverMaps.mapToClass(BeatSaverMap);
|
|
||||||
|
|
||||||
// Populate default settings if the table is empty
|
// Populate default settings if the table is empty
|
||||||
this.on("populate", () => this.populateDefaults());
|
this.on("populate", () => this.populateDefaults());
|
||||||
|
@ -1,23 +0,0 @@
|
|||||||
import { Entity } from "dexie";
|
|
||||||
import Database from "../database";
|
|
||||||
import { BeatSaverMapToken } from "@ssr/common/types/token/beatsaver/beat-saver-map-token";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A beat saver map.
|
|
||||||
*/
|
|
||||||
export default class BeatSaverMap extends Entity<Database> {
|
|
||||||
/**
|
|
||||||
* The hash of the map.
|
|
||||||
*/
|
|
||||||
hash!: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The bsr code for the map.
|
|
||||||
*/
|
|
||||||
bsr!: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The full data for the map.
|
|
||||||
*/
|
|
||||||
fullData!: BeatSaverMapToken;
|
|
||||||
}
|
|
@ -36,7 +36,7 @@ export function ApiHealth() {
|
|||||||
? "The API has recovered connectivity."
|
? "The API has recovered connectivity."
|
||||||
: "The API has lost connectivity, some data may be unavailable.",
|
: "The API has lost connectivity, some data may be unavailable.",
|
||||||
variant: online ? "success" : "destructive",
|
variant: online ? "success" : "destructive",
|
||||||
duration: 10_000, // 10 seconds
|
duration: 5_000, // 5 seconds
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@ import { useToast } from "@/hooks/use-toast";
|
|||||||
import Tooltip from "../tooltip";
|
import Tooltip from "../tooltip";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { PersonIcon } from "@radix-ui/react-icons";
|
import { PersonIcon } from "@radix-ui/react-icons";
|
||||||
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player";
|
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
||||||
import { trackPlayer } from "@ssr/common/utils/player-utils";
|
import { trackPlayer } from "@ssr/common/utils/player-utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -2,70 +2,55 @@
|
|||||||
|
|
||||||
import LeaderboardScores from "@/components/leaderboard/leaderboard-scores";
|
import LeaderboardScores from "@/components/leaderboard/leaderboard-scores";
|
||||||
import { LeaderboardInfo } from "@/components/leaderboard/leaderboard-info";
|
import { LeaderboardInfo } from "@/components/leaderboard/leaderboard-info";
|
||||||
|
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
||||||
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
|
import { LeaderboardResponse } from "@ssr/common/response/leaderboard-response";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import BeatSaverMap from "@/common/database/types/beatsaver-map";
|
import { fetchLeaderboard } from "@ssr/common/utils/leaderboard.util";
|
||||||
import ScoreSaberLeaderboardScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-scores-page-token";
|
import PlayerScoresResponse from "../../../../common/src/response/player-scores-response.ts";
|
||||||
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
|
||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
const REFRESH_INTERVAL = 1000 * 60 * 5;
|
||||||
import { lookupBeatSaverMap } from "@/common/beatsaver-utils";
|
|
||||||
|
|
||||||
type LeaderboardDataProps = {
|
type LeaderboardDataProps = {
|
||||||
/**
|
/**
|
||||||
* The page to show when opening the leaderboard.
|
* The initial leaderboard data.
|
||||||
*/
|
*/
|
||||||
initialPage?: number;
|
initialLeaderboard: LeaderboardResponse<ScoreSaberLeaderboard>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The initial scores to show.
|
* The initial score data.
|
||||||
*/
|
*/
|
||||||
initialScores?: ScoreSaberLeaderboardScoresPageToken;
|
initialScores: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
|
||||||
|
|
||||||
/**
|
|
||||||
* The leaderboard to display.
|
|
||||||
*/
|
|
||||||
initialLeaderboard: ScoreSaberLeaderboardToken;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LeaderboardData({ initialPage, initialScores, initialLeaderboard }: LeaderboardDataProps) {
|
export function LeaderboardData({ initialLeaderboard, initialScores }: LeaderboardDataProps) {
|
||||||
const [beatSaverMap, setBeatSaverMap] = useState<BeatSaverMap | undefined>();
|
const [currentLeaderboardId, setCurrentLeaderboardId] = useState(initialLeaderboard.leaderboard.id);
|
||||||
const [selectedLeaderboardId, setSelectedLeaderboardId] = useState(initialLeaderboard.id);
|
|
||||||
|
|
||||||
let currentLeaderboard = initialLeaderboard;
|
let leaderboard = initialLeaderboard;
|
||||||
const { data } = useQuery({
|
const { data, isLoading, isError } = useQuery({
|
||||||
queryKey: ["leaderboard", selectedLeaderboardId],
|
queryKey: ["leaderboard", currentLeaderboardId],
|
||||||
queryFn: () => scoresaberService.lookupLeaderboard(selectedLeaderboardId + ""),
|
queryFn: async (): Promise<LeaderboardResponse<ScoreSaberLeaderboard> | undefined> => {
|
||||||
initialData: initialLeaderboard,
|
return fetchLeaderboard<ScoreSaberLeaderboard>("scoresaber", currentLeaderboardId + "");
|
||||||
|
},
|
||||||
|
refetchInterval: REFRESH_INTERVAL,
|
||||||
|
refetchIntervalInBackground: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data) {
|
if (data && (!isLoading || !isError)) {
|
||||||
currentLeaderboard = data;
|
leaderboard = data;
|
||||||
}
|
|
||||||
|
|
||||||
const fetchBeatSaverData = useCallback(async () => {
|
|
||||||
const beatSaverMap = await lookupBeatSaverMap(initialLeaderboard.songHash);
|
|
||||||
setBeatSaverMap(beatSaverMap);
|
|
||||||
}, [initialLeaderboard.songHash]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchBeatSaverData();
|
|
||||||
}, [fetchBeatSaverData]);
|
|
||||||
|
|
||||||
if (!currentLeaderboard) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col-reverse xl:flex-row w-full gap-2">
|
<main className="flex flex-col-reverse xl:flex-row w-full gap-2">
|
||||||
<LeaderboardScores
|
<LeaderboardScores
|
||||||
leaderboard={currentLeaderboard}
|
leaderboard={leaderboard.leaderboard}
|
||||||
initialScores={initialScores}
|
initialScores={initialScores}
|
||||||
initialPage={initialPage}
|
leaderboardChanged={newId => setCurrentLeaderboardId(newId)}
|
||||||
showDifficulties
|
showDifficulties
|
||||||
isLeaderboardPage
|
isLeaderboardPage
|
||||||
leaderboardChanged={id => setSelectedLeaderboardId(id)}
|
|
||||||
/>
|
/>
|
||||||
<LeaderboardInfo leaderboard={currentLeaderboard} beatSaverMap={beatSaverMap} />
|
<LeaderboardInfo leaderboard={leaderboard.leaderboard} beatSaverMap={leaderboard.beatsaver} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,14 +2,14 @@ import Card from "@/components/card";
|
|||||||
import Image from "next/image";
|
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 BeatSaverMap from "@/common/database/types/beatsaver-map";
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map";
|
||||||
|
|
||||||
type LeaderboardInfoProps = {
|
type LeaderboardInfoProps = {
|
||||||
/**
|
/**
|
||||||
* The leaderboard to display.
|
* The leaderboard to display.
|
||||||
*/
|
*/
|
||||||
leaderboard: ScoreSaberLeaderboardToken;
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The beat saver map associated with the leaderboard.
|
* The beat saver map associated with the leaderboard.
|
||||||
@ -46,7 +46,7 @@ export function LeaderboardInfo({ leaderboard, beatSaverMap }: LeaderboardInfoPr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Image
|
<Image
|
||||||
src={leaderboard.coverImage}
|
src={leaderboard.songArt}
|
||||||
alt={`${leaderboard.songName} Cover Image`}
|
alt={`${leaderboard.songName} Cover Image`}
|
||||||
className="rounded-md w-[96px] h-[96px]"
|
className="rounded-md w-[96px] h-[96px]"
|
||||||
width={96}
|
width={96}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token";
|
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
||||||
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player";
|
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
@ -12,11 +12,11 @@ type Props = {
|
|||||||
/**
|
/**
|
||||||
* The score to display.
|
* The score to display.
|
||||||
*/
|
*/
|
||||||
score: ScoreSaberScoreToken;
|
score: ScoreSaberScore;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LeaderboardPlayer({ player, score }: Props) {
|
export default function LeaderboardPlayer({ player, score }: Props) {
|
||||||
const scorePlayer = score.leaderboardPlayerInfo;
|
const scorePlayer = score.playerInfo;
|
||||||
const isPlayerWhoSetScore = player && scorePlayer.id === player.id;
|
const isPlayerWhoSetScore = player && scorePlayer.id === player.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -4,8 +4,8 @@ import clsx from "clsx";
|
|||||||
import { getScoreBadgeFromAccuracy } from "@/common/song-utils";
|
import { getScoreBadgeFromAccuracy } from "@/common/song-utils";
|
||||||
import Tooltip from "@/components/tooltip";
|
import Tooltip from "@/components/tooltip";
|
||||||
import { ScoreBadge, ScoreBadges } from "@/components/score/score-badge";
|
import { ScoreBadge, ScoreBadges } from "@/components/score/score-badge";
|
||||||
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token";
|
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
||||||
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
|
|
||||||
const badges: ScoreBadge[] = [
|
const badges: ScoreBadge[] = [
|
||||||
{
|
{
|
||||||
@ -13,7 +13,7 @@ const badges: ScoreBadge[] = [
|
|||||||
color: () => {
|
color: () => {
|
||||||
return "bg-pp";
|
return "bg-pp";
|
||||||
},
|
},
|
||||||
create: (score: ScoreSaberScoreToken) => {
|
create: (score: ScoreSaberScore) => {
|
||||||
const pp = score.pp;
|
const pp = score.pp;
|
||||||
if (pp === 0) {
|
if (pp === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -23,12 +23,12 @@ const badges: ScoreBadge[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Accuracy",
|
name: "Accuracy",
|
||||||
color: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => {
|
color: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => {
|
||||||
const acc = (score.baseScore / leaderboard.maxScore) * 100;
|
const acc = (score.score / leaderboard.maxScore) * 100;
|
||||||
return getScoreBadgeFromAccuracy(acc).color;
|
return getScoreBadgeFromAccuracy(acc).color;
|
||||||
},
|
},
|
||||||
create: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => {
|
create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => {
|
||||||
const acc = (score.baseScore / leaderboard.maxScore) * 100;
|
const acc = (score.score / leaderboard.maxScore) * 100;
|
||||||
const scoreBadge = getScoreBadgeFromAccuracy(acc);
|
const scoreBadge = getScoreBadgeFromAccuracy(acc);
|
||||||
let accDetails = `Accuracy ${scoreBadge.name != "-" ? scoreBadge.name : ""}`;
|
let accDetails = `Accuracy ${scoreBadge.name != "-" ? scoreBadge.name : ""}`;
|
||||||
if (scoreBadge.max == null) {
|
if (scoreBadge.max == null) {
|
||||||
@ -56,12 +56,12 @@ const badges: ScoreBadge[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Full Combo",
|
name: "Full Combo",
|
||||||
create: (score: ScoreSaberScoreToken) => {
|
create: (score: ScoreSaberScore) => {
|
||||||
const fullCombo = score.missedNotes === 0;
|
const fullCombo = score.misses === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p>{fullCombo ? <span className="text-green-400">FC</span> : formatNumberWithCommas(score.missedNotes)}</p>
|
<p>{fullCombo ? <span className="text-green-400">FC</span> : formatNumberWithCommas(score.misses)}</p>
|
||||||
<XMarkIcon className={clsx("w-5 h-5", fullCombo ? "hidden" : "text-red-400")} />
|
<XMarkIcon className={clsx("w-5 h-5", fullCombo ? "hidden" : "text-red-400")} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -70,8 +70,8 @@ const badges: ScoreBadge[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
score: ScoreSaberScoreToken;
|
score: ScoreSaberScore;
|
||||||
leaderboard: ScoreSaberLeaderboardToken;
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LeaderboardScoreStats({ score, leaderboard }: Props) {
|
export default function LeaderboardScoreStats({ score, leaderboard }: Props) {
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import LeaderboardPlayer from "./leaderboard-player";
|
import LeaderboardPlayer from "./leaderboard-player";
|
||||||
import LeaderboardScoreStats from "./leaderboard-score-stats";
|
import LeaderboardScoreStats from "./leaderboard-score-stats";
|
||||||
import ScoreRankInfo from "@/components/score/score-rank-info";
|
import ScoreRankInfo from "@/components/score/score-rank-info";
|
||||||
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player";
|
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
||||||
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token";
|
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
||||||
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
@ -14,12 +14,12 @@ type Props = {
|
|||||||
/**
|
/**
|
||||||
* The score to display.
|
* The score to display.
|
||||||
*/
|
*/
|
||||||
score: ScoreSaberScoreToken;
|
score: ScoreSaberScore;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The leaderboard to display.
|
* The leaderboard to display.
|
||||||
*/
|
*/
|
||||||
leaderboard: ScoreSaberLeaderboardToken;
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LeaderboardScore({ player, score, leaderboard }: Props) {
|
export default function LeaderboardScore({ player, score, leaderboard }: Props) {
|
||||||
|
@ -11,10 +11,11 @@ import { scoreAnimation } from "@/components/score/score-animation";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { getDifficultyFromRawDifficulty } from "@/common/song-utils";
|
import { getDifficultyFromRawDifficulty } from "@/common/song-utils";
|
||||||
import ScoreSaberLeaderboardScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-scores-page-token";
|
import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils";
|
||||||
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player";
|
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
||||||
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
||||||
|
import PlayerScoresResponse from "../../../../common/src/response/player-scores-response.ts";
|
||||||
|
|
||||||
type LeaderboardScoresProps = {
|
type LeaderboardScoresProps = {
|
||||||
/**
|
/**
|
||||||
@ -25,18 +26,18 @@ type LeaderboardScoresProps = {
|
|||||||
/**
|
/**
|
||||||
* The initial scores to show.
|
* The initial scores to show.
|
||||||
*/
|
*/
|
||||||
initialScores?: ScoreSaberLeaderboardScoresPageToken;
|
initialScores?: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The leaderboard to display.
|
||||||
|
*/
|
||||||
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The player who set the score.
|
* The player who set the score.
|
||||||
*/
|
*/
|
||||||
player?: ScoreSaberPlayer;
|
player?: ScoreSaberPlayer;
|
||||||
|
|
||||||
/**
|
|
||||||
* The leaderboard to display.
|
|
||||||
*/
|
|
||||||
leaderboard: ScoreSaberLeaderboardToken;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether to show the difficulties.
|
* Whether to show the difficulties.
|
||||||
*/
|
*/
|
||||||
@ -73,17 +74,20 @@ export default function LeaderboardScores({
|
|||||||
const [selectedLeaderboardId, setSelectedLeaderboardId] = useState(leaderboard.id);
|
const [selectedLeaderboardId, setSelectedLeaderboardId] = useState(leaderboard.id);
|
||||||
const [previousPage, setPreviousPage] = useState(initialPage);
|
const [previousPage, setPreviousPage] = useState(initialPage);
|
||||||
const [currentPage, setCurrentPage] = useState(initialPage);
|
const [currentPage, setCurrentPage] = useState(initialPage);
|
||||||
const [currentScores, setCurrentScores] = useState<ScoreSaberLeaderboardScoresPageToken | undefined>(initialScores);
|
const [currentScores, setCurrentScores] = useState<
|
||||||
|
PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined
|
||||||
|
>(initialScores);
|
||||||
const topOfScoresRef = useRef<HTMLDivElement>(null);
|
const topOfScoresRef = useRef<HTMLDivElement>(null);
|
||||||
const [shouldFetch, setShouldFetch] = useState(false);
|
const [shouldFetch, setShouldFetch] = useState(true);
|
||||||
|
|
||||||
const {
|
const { data, isError, isLoading } = useQuery({
|
||||||
data: scores,
|
queryKey: ["leaderboardScores", selectedLeaderboardId, currentPage],
|
||||||
isError,
|
queryFn: () =>
|
||||||
isLoading,
|
fetchLeaderboardScores<ScoreSaberScore, ScoreSaberLeaderboard>(
|
||||||
} = useQuery({
|
"scoresaber",
|
||||||
queryKey: ["leaderboardScores-" + leaderboard.id, selectedLeaderboardId, currentPage],
|
selectedLeaderboardId + "",
|
||||||
queryFn: () => scoresaberService.lookupLeaderboardScores(selectedLeaderboardId + "", currentPage),
|
currentPage
|
||||||
|
),
|
||||||
staleTime: 30 * 1000,
|
staleTime: 30 * 1000,
|
||||||
enabled: (shouldFetch && isLeaderboardPage) || !isLeaderboardPage,
|
enabled: (shouldFetch && isLeaderboardPage) || !isLeaderboardPage,
|
||||||
});
|
});
|
||||||
@ -93,9 +97,9 @@ export default function LeaderboardScores({
|
|||||||
*/
|
*/
|
||||||
const handleScoreAnimation = useCallback(async () => {
|
const handleScoreAnimation = useCallback(async () => {
|
||||||
await controls.start(previousPage >= currentPage ? "hiddenRight" : "hiddenLeft");
|
await controls.start(previousPage >= currentPage ? "hiddenRight" : "hiddenLeft");
|
||||||
setCurrentScores(scores);
|
setCurrentScores(data);
|
||||||
await controls.start("visible");
|
await controls.start("visible");
|
||||||
}, [controls, currentPage, previousPage, scores]);
|
}, [controls, currentPage, previousPage, data]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the selected leaderboard.
|
* Set the selected leaderboard.
|
||||||
@ -118,10 +122,10 @@ export default function LeaderboardScores({
|
|||||||
* Set the current scores.
|
* Set the current scores.
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scores) {
|
if (data) {
|
||||||
handleScoreAnimation();
|
handleScoreAnimation();
|
||||||
}
|
}
|
||||||
}, [scores, handleScoreAnimation]);
|
}, [data, handleScoreAnimation]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle scrolling to the top of the
|
* Handle scrolling to the top of the
|
||||||
@ -185,17 +189,19 @@ export default function LeaderboardScores({
|
|||||||
variants={scoreAnimation}
|
variants={scoreAnimation}
|
||||||
className="grid min-w-full grid-cols-1 divide-y divide-border"
|
className="grid min-w-full grid-cols-1 divide-y divide-border"
|
||||||
>
|
>
|
||||||
{currentScores.scores.map((playerScore, index) => (
|
{currentScores.scores.map((playerScore, index) => {
|
||||||
|
return (
|
||||||
<motion.div key={index} variants={scoreAnimation}>
|
<motion.div key={index} variants={scoreAnimation}>
|
||||||
<LeaderboardScore key={index} player={player} score={playerScore} leaderboard={leaderboard} />
|
<LeaderboardScore key={index} player={player} score={playerScore} leaderboard={leaderboard} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<Pagination
|
<Pagination
|
||||||
mobilePagination={width < 768}
|
mobilePagination={width < 768}
|
||||||
page={currentPage}
|
page={currentPage}
|
||||||
totalPages={Math.ceil(currentScores.metadata.total / currentScores.metadata.itemsPerPage)}
|
totalPages={currentScores.metadata.totalPages}
|
||||||
loadingPage={isLoading ? currentPage : undefined}
|
loadingPage={isLoading ? currentPage : undefined}
|
||||||
generatePageUrl={page => {
|
generatePageUrl={page => {
|
||||||
return `/leaderboard/${selectedLeaderboardId}/${page}`;
|
return `/leaderboard/${selectedLeaderboardId}/${page}`;
|
||||||
|
@ -1,13 +1,12 @@
|
|||||||
import { songDifficultyToColor } from "@/common/song-utils";
|
import { songDifficultyToColor } from "@/common/song-utils";
|
||||||
import { StarIcon } from "@heroicons/react/24/solid";
|
import { StarIcon } from "@heroicons/react/24/solid";
|
||||||
import { getDifficultyFromScoreSaberDifficulty } from "@ssr/common/utils/scoresaber-utils";
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
|
||||||
|
|
||||||
type LeaderboardSongStarCountProps = {
|
type LeaderboardSongStarCountProps = {
|
||||||
/**
|
/**
|
||||||
* The leaderboard for the song
|
* The leaderboard for the song
|
||||||
*/
|
*/
|
||||||
leaderboard: ScoreSaberLeaderboardToken;
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LeaderboardSongStarCount({ leaderboard }: LeaderboardSongStarCountProps) {
|
export function LeaderboardSongStarCount({ leaderboard }: LeaderboardSongStarCountProps) {
|
||||||
@ -15,12 +14,11 @@ export function LeaderboardSongStarCount({ leaderboard }: LeaderboardSongStarCou
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const diff = getDifficultyFromScoreSaberDifficulty(leaderboard.difficulty.difficulty);
|
|
||||||
return (
|
return (
|
||||||
<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(diff) + "f0", // Transparency value (in hex 0-255)
|
backgroundColor: songDifficultyToColor(leaderboard.difficulty.difficultyRaw) + "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">
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import GenericChart, { DatasetConfig } from "@/components/chart/generic-chart";
|
import GenericChart, { DatasetConfig } from "@/components/chart/generic-chart";
|
||||||
import { getValueFromHistory } from "@/common/player-utils";
|
import { getValueFromHistory } from "@/common/player-utils";
|
||||||
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player";
|
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
||||||
import { parseDate } from "@ssr/common/utils/time-utils";
|
import { parseDate } from "@ssr/common/utils/time-utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { DatasetConfig } from "@/components/chart/generic-chart";
|
import { DatasetConfig } from "@/components/chart/generic-chart";
|
||||||
import GenericPlayerChart from "@/components/player/chart/generic-player-chart";
|
import GenericPlayerChart from "@/components/player/chart/generic-player-chart";
|
||||||
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player";
|
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
||||||
import { isWholeNumber } from "@ssr/common/utils/number-utils";
|
import { isWholeNumber } from "@ssr/common/utils/number-utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -6,7 +6,7 @@ import Tooltip from "@/components/tooltip";
|
|||||||
import PlayerAccuracyChart from "@/components/player/chart/player-accuracy-chart";
|
import PlayerAccuracyChart from "@/components/player/chart/player-accuracy-chart";
|
||||||
import { GlobeAmericasIcon } from "@heroicons/react/24/solid";
|
import { GlobeAmericasIcon } from "@heroicons/react/24/solid";
|
||||||
import { TrendingUpIcon } from "lucide-react";
|
import { TrendingUpIcon } from "lucide-react";
|
||||||
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player";
|
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
||||||
|
|
||||||
type PlayerChartsProps = {
|
type PlayerChartsProps = {
|
||||||
/**
|
/**
|
||||||
|
@ -4,7 +4,7 @@ import { formatNumberWithCommas, isWholeNumber } from "@ssr/common/utils/number-
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { DatasetConfig } from "@/components/chart/generic-chart";
|
import { DatasetConfig } from "@/components/chart/generic-chart";
|
||||||
import GenericPlayerChart from "@/components/player/chart/generic-player-chart";
|
import GenericPlayerChart from "@/components/player/chart/generic-player-chart";
|
||||||
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player";
|
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
player: ScoreSaberPlayer;
|
player: ScoreSaberPlayer;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Tooltip from "@/components/tooltip";
|
import Tooltip from "@/components/tooltip";
|
||||||
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player";
|
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
player: ScoreSaberPlayer;
|
player: ScoreSaberPlayer;
|
||||||
|
@ -10,30 +10,26 @@ import { useIsMobile } from "@/hooks/use-is-mobile";
|
|||||||
import { useIsVisible } from "@/hooks/use-is-visible";
|
import { useIsVisible } from "@/hooks/use-is-visible";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import PlayerCharts from "@/components/player/chart/player-charts";
|
import PlayerCharts from "@/components/player/chart/player-charts";
|
||||||
import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/types/player/impl/scoresaber-player";
|
|
||||||
import ScoreSaberPlayerScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-player-scores-page-token";
|
|
||||||
import { ScoreSort } from "@ssr/common/types/score/score-sort";
|
|
||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||||
import useDatabase from "@/hooks/use-database";
|
import useDatabase from "@/hooks/use-database";
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
|
import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/player/impl/scoresaber-player";
|
||||||
|
import { ScoreSort } from "@ssr/common/score/score-sort";
|
||||||
|
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
||||||
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
|
import PlayerScoresResponse from "../../../../common/src/response/player-scores-response.ts";
|
||||||
|
|
||||||
const REFRESH_INTERVAL = 1000 * 60 * 5;
|
const REFRESH_INTERVAL = 1000 * 60 * 5;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
initialPlayerData: ScoreSaberPlayer;
|
initialPlayerData: ScoreSaberPlayer;
|
||||||
initialScoreData?: ScoreSaberPlayerScoresPageToken;
|
initialScoreData?: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
|
||||||
initialSearch?: string;
|
initialSearch?: string;
|
||||||
sort: ScoreSort;
|
sort: ScoreSort;
|
||||||
page: number;
|
page: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PlayerData({
|
export default function PlayerData({ initialPlayerData, initialScoreData, initialSearch, sort, page }: Props) {
|
||||||
initialPlayerData: initialPlayerData,
|
|
||||||
initialScoreData,
|
|
||||||
initialSearch,
|
|
||||||
sort,
|
|
||||||
page,
|
|
||||||
}: Props) {
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const miniRankingsRef = useRef<HTMLDivElement>(null);
|
const miniRankingsRef = useRef<HTMLDivElement>(null);
|
||||||
const isMiniRankingsVisible = useIsVisible(miniRankingsRef);
|
const isMiniRankingsVisible = useIsVisible(miniRankingsRef);
|
||||||
|
@ -8,7 +8,7 @@ import PlayerStats from "./player-stats";
|
|||||||
import Tooltip from "@/components/tooltip";
|
import Tooltip from "@/components/tooltip";
|
||||||
import { ReactElement } from "react";
|
import { ReactElement } from "react";
|
||||||
import PlayerTrackedStatus from "@/components/player/player-tracked-status";
|
import PlayerTrackedStatus from "@/components/player/player-tracked-status";
|
||||||
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player";
|
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { capitalizeFirstLetter } from "@/common/string-utils";
|
import { capitalizeFirstLetter } from "@/common/string-utils";
|
||||||
import AddFriend from "@/components/friend/add-friend";
|
import AddFriend from "@/components/friend/add-friend";
|
||||||
|
@ -12,14 +12,16 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { useDebounce } from "@uidotdev/usehooks";
|
import { useDebounce } from "@uidotdev/usehooks";
|
||||||
import { scoreAnimation } from "@/components/score/score-animation";
|
import { scoreAnimation } from "@/components/score/score-animation";
|
||||||
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player";
|
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
||||||
import ScoreSaberPlayerScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-player-scores-page-token";
|
import { ScoreSort } from "@ssr/common/score/score-sort";
|
||||||
import { ScoreSort } from "@ssr/common/types/score/score-sort";
|
|
||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
|
||||||
import { setCookieValue } from "@ssr/common/utils/cookie-utils";
|
import { setCookieValue } from "@ssr/common/utils/cookie-utils";
|
||||||
|
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
||||||
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
|
import { fetchPlayerScores } from "@ssr/common/utils/score-utils";
|
||||||
|
import PlayerScoresResponse from "@ssr/common/response/player-scores-response";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
initialScoreData?: ScoreSaberPlayerScoresPageToken;
|
initialScoreData?: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
|
||||||
initialSearch?: string;
|
initialSearch?: string;
|
||||||
player: ScoreSaberPlayer;
|
player: ScoreSaberPlayer;
|
||||||
sort: ScoreSort;
|
sort: ScoreSort;
|
||||||
@ -50,27 +52,25 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
|
|||||||
|
|
||||||
const [pageState, setPageState] = useState<PageState>({ page, sort });
|
const [pageState, setPageState] = useState<PageState>({ page, sort });
|
||||||
const [previousPage, setPreviousPage] = useState(page);
|
const [previousPage, setPreviousPage] = useState(page);
|
||||||
const [currentScores, setCurrentScores] = useState<ScoreSaberPlayerScoresPageToken | undefined>(initialScoreData);
|
const [scores, setScores] = useState<PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined>(
|
||||||
|
initialScoreData
|
||||||
|
);
|
||||||
const [searchTerm, setSearchTerm] = useState(initialSearch || "");
|
const [searchTerm, setSearchTerm] = useState(initialSearch || "");
|
||||||
const debouncedSearchTerm = useDebounce(searchTerm, 250);
|
const debouncedSearchTerm = useDebounce(searchTerm, 250);
|
||||||
const [shouldFetch, setShouldFetch] = useState(false);
|
const [shouldFetch, setShouldFetch] = useState(false);
|
||||||
const topOfScoresRef = useRef<HTMLDivElement>(null);
|
const topOfScoresRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const isSearchActive = debouncedSearchTerm.length >= 3;
|
const isSearchActive = debouncedSearchTerm.length >= 3;
|
||||||
const {
|
const { data, isError, isLoading } = useQuery({
|
||||||
data: scores,
|
|
||||||
isError,
|
|
||||||
isLoading,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["playerScores", player.id, pageState, debouncedSearchTerm],
|
queryKey: ["playerScores", player.id, pageState, debouncedSearchTerm],
|
||||||
queryFn: () => {
|
queryFn: () =>
|
||||||
return scoresaberService.lookupPlayerScores({
|
fetchPlayerScores<ScoreSaberScore, ScoreSaberLeaderboard>(
|
||||||
playerId: player.id,
|
"scoresaber",
|
||||||
page: pageState.page,
|
player.id,
|
||||||
sort: pageState.sort,
|
pageState.page,
|
||||||
...(isSearchActive && { search: debouncedSearchTerm }),
|
pageState.sort,
|
||||||
});
|
debouncedSearchTerm
|
||||||
},
|
),
|
||||||
staleTime: 30 * 1000, // 30 seconds
|
staleTime: 30 * 1000, // 30 seconds
|
||||||
enabled: shouldFetch && (debouncedSearchTerm.length >= 3 || debouncedSearchTerm.length === 0),
|
enabled: shouldFetch && (debouncedSearchTerm.length >= 3 || debouncedSearchTerm.length === 0),
|
||||||
});
|
});
|
||||||
@ -80,9 +80,9 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
|
|||||||
*/
|
*/
|
||||||
const handleScoreAnimation = useCallback(async () => {
|
const handleScoreAnimation = useCallback(async () => {
|
||||||
await controls.start(previousPage >= pageState.page ? "hiddenRight" : "hiddenLeft");
|
await controls.start(previousPage >= pageState.page ? "hiddenRight" : "hiddenLeft");
|
||||||
setCurrentScores(scores);
|
setScores(data);
|
||||||
await controls.start("visible");
|
await controls.start("visible");
|
||||||
}, [scores, controls, previousPage, pageState.page]);
|
}, [controls, previousPage, pageState.page, data]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change the score sort.
|
* Change the score sort.
|
||||||
@ -122,8 +122,10 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
|
|||||||
* Handle score animation.
|
* Handle score animation.
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scores) handleScoreAnimation();
|
if (data) {
|
||||||
}, [scores, handleScoreAnimation]);
|
handleScoreAnimation();
|
||||||
|
}
|
||||||
|
}, [data, handleScoreAnimation]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the URL to the page.
|
* Gets the URL to the page.
|
||||||
@ -203,10 +205,10 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currentScores && (
|
{scores !== undefined && (
|
||||||
<>
|
<>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
{isError || (currentScores.playerScores.length === 0 && <p>No scores found. Invalid Page or Search?</p>)}
|
{isError || (scores.scores.length === 0 && <p>No scores found. Invalid Page or Search?</p>)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -215,9 +217,14 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
|
|||||||
variants={scoreAnimation}
|
variants={scoreAnimation}
|
||||||
className="grid min-w-full grid-cols-1 divide-y divide-border"
|
className="grid min-w-full grid-cols-1 divide-y divide-border"
|
||||||
>
|
>
|
||||||
{currentScores.playerScores.map((playerScore, index) => (
|
{scores.scores.map((score, index) => (
|
||||||
<motion.div key={index} variants={scoreAnimation}>
|
<motion.div key={score.score.id} variants={scoreAnimation}>
|
||||||
<Score player={player} playerScore={playerScore} />
|
<Score
|
||||||
|
player={player}
|
||||||
|
score={score.score}
|
||||||
|
leaderboard={score.leaderboard}
|
||||||
|
beatSaverMap={score.beatSaver}
|
||||||
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@ -225,7 +232,7 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
|
|||||||
<Pagination
|
<Pagination
|
||||||
mobilePagination={width < 768}
|
mobilePagination={width < 768}
|
||||||
page={pageState.page}
|
page={pageState.page}
|
||||||
totalPages={Math.ceil(currentScores.metadata.total / currentScores.metadata.itemsPerPage)}
|
totalPages={scores.metadata.totalPages}
|
||||||
loadingPage={isLoading ? pageState.page : undefined}
|
loadingPage={isLoading ? pageState.page : undefined}
|
||||||
generatePageUrl={page => {
|
generatePageUrl={page => {
|
||||||
return getUrl(page);
|
return getUrl(page);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { formatNumberWithCommas } from "@ssr/common/utils/number-utils";
|
import { formatNumberWithCommas } from "@ssr/common/utils/number-utils";
|
||||||
import StatValue from "@/components/stat-value";
|
import StatValue from "@/components/stat-value";
|
||||||
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player";
|
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
||||||
import { formatDate } from "@ssr/common/utils/time-utils";
|
import { formatDate } from "@ssr/common/utils/time-utils";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import Tooltip from "@/components/tooltip";
|
import Tooltip from "@/components/tooltip";
|
||||||
|
@ -6,7 +6,7 @@ import Tooltip from "@/components/tooltip";
|
|||||||
import { InformationCircleIcon } from "@heroicons/react/16/solid";
|
import { InformationCircleIcon } from "@heroicons/react/16/solid";
|
||||||
import { formatNumberWithCommas } from "@ssr/common/utils/number-utils";
|
import { formatNumberWithCommas } from "@ssr/common/utils/number-utils";
|
||||||
import { PlayerTrackedSince } from "@ssr/common/types/player/player-tracked-since";
|
import { PlayerTrackedSince } from "@ssr/common/types/player/player-tracked-since";
|
||||||
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player";
|
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
||||||
import { Config } from "@ssr/common/config";
|
import { Config } from "@ssr/common/config";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -7,7 +7,7 @@ import Card from "../card";
|
|||||||
import CountryFlag from "../country-flag";
|
import CountryFlag from "../country-flag";
|
||||||
import { Avatar, AvatarImage } from "../ui/avatar";
|
import { Avatar, AvatarImage } from "../ui/avatar";
|
||||||
import { PlayerRankingSkeleton } from "@/components/ranking/player-ranking-skeleton";
|
import { PlayerRankingSkeleton } from "@/components/ranking/player-ranking-skeleton";
|
||||||
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player";
|
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
||||||
import { ScoreSaberPlayersPageToken } from "@ssr/common/types/token/scoresaber/score-saber-players-page-token";
|
import { ScoreSaberPlayersPageToken } from "@ssr/common/types/token/scoresaber/score-saber-players-page-token";
|
||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||||
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
|
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
|
||||||
|
@ -1,17 +1,14 @@
|
|||||||
import StatValue from "@/components/stat-value";
|
import StatValue from "@/components/stat-value";
|
||||||
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token";
|
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
||||||
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A badge to display in the score stats.
|
* A badge to display in the score stats.
|
||||||
*/
|
*/
|
||||||
export type ScoreBadge = {
|
export type ScoreBadge = {
|
||||||
name: string;
|
name: string;
|
||||||
color?: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => string | undefined;
|
color?: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => string | undefined;
|
||||||
create: (
|
create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => string | React.ReactNode | undefined;
|
||||||
score: ScoreSaberScoreToken,
|
|
||||||
leaderboard: ScoreSaberLeaderboardToken
|
|
||||||
) => string | React.ReactNode | undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -19,8 +16,8 @@ export type ScoreBadge = {
|
|||||||
*/
|
*/
|
||||||
type ScoreBadgeProps = {
|
type ScoreBadgeProps = {
|
||||||
badges: ScoreBadge[];
|
badges: ScoreBadge[];
|
||||||
score: ScoreSaberScoreToken;
|
score: ScoreSaberScore;
|
||||||
leaderboard: ScoreSaberLeaderboardToken;
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ScoreBadges({ badges, score, leaderboard }: ScoreBadgeProps) {
|
export function ScoreBadges({ badges, score, leaderboard }: ScoreBadgeProps) {
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import BeatSaverMap from "@/common/database/types/beatsaver-map";
|
|
||||||
import { songNameToYouTubeLink } from "@/common/youtube-utils";
|
import { songNameToYouTubeLink } from "@/common/youtube-utils";
|
||||||
import BeatSaverLogo from "@/components/logos/beatsaver-logo";
|
import BeatSaverLogo from "@/components/logos/beatsaver-logo";
|
||||||
import YouTubeLogo from "@/components/logos/youtube-logo";
|
import YouTubeLogo from "@/components/logos/youtube-logo";
|
||||||
@ -8,19 +7,20 @@ import { useToast } from "@/hooks/use-toast";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import ScoreButton from "./score-button";
|
import ScoreButton from "./score-button";
|
||||||
import { copyToClipboard } from "@/common/browser-utils";
|
import { copyToClipboard } from "@/common/browser-utils";
|
||||||
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
|
||||||
import { ArrowDownIcon } from "@heroicons/react/24/solid";
|
import { ArrowDownIcon } from "@heroicons/react/24/solid";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import ScoreEditorButton from "@/components/score/score-editor-button";
|
import ScoreEditorButton from "@/components/score/score-editor-button";
|
||||||
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token";
|
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";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
score?: ScoreSaberScoreToken;
|
score?: ScoreSaberScore;
|
||||||
leaderboard: ScoreSaberLeaderboardToken;
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
beatSaverMap?: BeatSaverMap;
|
beatSaverMap?: BeatSaverMap;
|
||||||
alwaysSingleLine?: boolean;
|
alwaysSingleLine?: boolean;
|
||||||
setIsLeaderboardExpanded?: (isExpanded: boolean) => void;
|
setIsLeaderboardExpanded?: (isExpanded: boolean) => void;
|
||||||
updateScore?: (score: ScoreSaberScoreToken) => void;
|
updateScore?: (score: ScoreSaberScore) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ScoreButtons({
|
export default function ScoreButtons({
|
||||||
@ -35,7 +35,7 @@ export default function ScoreButtons({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end gap-2 h-[64px]">
|
<div className={`flex justify-end gap-2 h-[${alwaysSingleLine ? "32" : "64"}px]`}>
|
||||||
<div
|
<div
|
||||||
className={`flex ${alwaysSingleLine ? "flex-nowrap" : "flex-wrap"} items-center lg:items-start justify-center lg:justify-end gap-1`}
|
className={`flex ${alwaysSingleLine ? "flex-nowrap" : "flex-wrap"} items-center lg:items-start justify-center lg:justify-end gap-1`}
|
||||||
>
|
>
|
||||||
@ -90,7 +90,7 @@ export default function ScoreButtons({
|
|||||||
|
|
||||||
{/* View Leaderboard button */}
|
{/* View Leaderboard button */}
|
||||||
{leaderboardExpanded != undefined && setIsLeaderboardExpanded != undefined && (
|
{leaderboardExpanded != undefined && setIsLeaderboardExpanded != undefined && (
|
||||||
<div className="pr-2 flex items-center justify-center cursor-default">
|
<div className="flex items-center justify-center cursor-default">
|
||||||
<ArrowDownIcon
|
<ArrowDownIcon
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-6 h-6 transition-all transform-gpu cursor-pointer",
|
"w-6 h-6 transition-all transform-gpu cursor-pointer",
|
||||||
|
@ -1,44 +1,44 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CogIcon } from "@heroicons/react/24/solid";
|
import { CogIcon } from "@heroicons/react/24/solid";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { ResetIcon } from "@radix-ui/react-icons";
|
import { ResetIcon } from "@radix-ui/react-icons";
|
||||||
import Tooltip from "@/components/tooltip";
|
import Tooltip from "@/components/tooltip";
|
||||||
|
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
||||||
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
|
|
||||||
type ScoreEditorButtonProps = {
|
type ScoreEditorButtonProps = {
|
||||||
score: ScoreSaberScoreToken;
|
score: ScoreSaberScore;
|
||||||
leaderboard: ScoreSaberLeaderboardToken;
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
updateScore: (score: ScoreSaberScoreToken) => void;
|
updateScore: (score: ScoreSaberScore) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ScoreEditorButton({ score, leaderboard, updateScore }: ScoreEditorButtonProps) {
|
export default function ScoreEditorButton({ score, leaderboard, updateScore }: ScoreEditorButtonProps) {
|
||||||
const [isScoreEditMode, setIsScoreEditMode] = useState(false);
|
const [isScoreEditMode, setIsScoreEditMode] = useState(false);
|
||||||
|
|
||||||
const maxScore = leaderboard.maxScore || 1; // Use 1 to prevent division by zero
|
const maxScore = leaderboard.maxScore || 1; // Use 1 to prevent division by zero
|
||||||
const accuracy = (score.baseScore / maxScore) * 100;
|
const accuracy = (score.score / maxScore) * 100;
|
||||||
|
|
||||||
const handleSliderChange = (value: number[]) => {
|
const handleSliderChange = (value: number[]) => {
|
||||||
const newAccuracy = Math.max(0, Math.min(value[0], 100)); // Ensure the accuracy stays within 0-100
|
const newAccuracy = Math.max(0, Math.min(value[0], 100)); // Ensure the accuracy stays within 0-100
|
||||||
const newBaseScore = (newAccuracy / 100) * maxScore;
|
const newBaseScore = (newAccuracy / 100) * maxScore;
|
||||||
updateScore({
|
updateScore({
|
||||||
...score,
|
...score,
|
||||||
baseScore: newBaseScore,
|
score: newBaseScore,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSliderReset = () => {
|
const handleSliderReset = () => {
|
||||||
updateScore({
|
updateScore({
|
||||||
...score,
|
...score,
|
||||||
baseScore: (accuracy / 100) * maxScore,
|
score: (accuracy / 100) * maxScore,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pr-2 flex items-center justify-center cursor-default relative">
|
<div className="flex items-center justify-center cursor-default relative">
|
||||||
<Popover
|
<Popover
|
||||||
onOpenChange={open => {
|
onOpenChange={open => {
|
||||||
setIsScoreEditMode(open);
|
setIsScoreEditMode(open);
|
||||||
|
@ -49,7 +49,7 @@ export default function ScoreFeed() {
|
|||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
<Score
|
<Score
|
||||||
playerScore={score}
|
score={score}
|
||||||
settings={{
|
settings={{
|
||||||
noScoreButtons: true,
|
noScoreButtons: true,
|
||||||
}}
|
}}
|
||||||
|
@ -1,31 +1,30 @@
|
|||||||
import BeatSaverMap from "@/common/database/types/beatsaver-map";
|
|
||||||
import { getDifficultyFromScoreSaberDifficulty } from "@ssr/common/utils/scoresaber-utils";
|
|
||||||
import FallbackLink from "@/components/fallback-link";
|
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 { songDifficultyToColor } from "@/common/song-utils";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
|
import { BeatSaverMap } from "@ssr/common/model/beatsaver-map";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
leaderboard: ScoreSaberLeaderboardToken;
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
beatSaverMap?: BeatSaverMap;
|
beatSaverMap?: BeatSaverMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
|
export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
|
||||||
const diff = getDifficultyFromScoreSaberDifficulty(leaderboard.difficulty.difficulty);
|
|
||||||
const mappersProfile =
|
const mappersProfile =
|
||||||
beatSaverMap != undefined ? `https://beatsaver.com/profile/${beatSaverMap?.fullData.uploader.id}` : undefined;
|
beatSaverMap != undefined ? `https://beatsaver.com/profile/${beatSaverMap?.author.authorId}` : undefined;
|
||||||
|
|
||||||
const starCount = leaderboard.stars;
|
const starCount = leaderboard.stars;
|
||||||
|
const difficulty = leaderboard.difficulty;
|
||||||
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={
|
||||||
<>
|
<>
|
||||||
<p>Difficulty: {diff}</p>
|
<p>Difficulty: {difficulty.difficulty}</p>
|
||||||
{starCount > 0 && <p>Stars: {starCount.toFixed(2)}</p>}
|
{starCount > 0 && <p>Stars: {starCount.toFixed(2)}</p>}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@ -33,7 +32,7 @@ export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
|
|||||||
<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(diff) + "f0", // Transparency value (in hex 0-255)
|
backgroundColor: songDifficultyToColor(difficulty.difficultyRaw) + "f0", // Transparency value (in hex 0-255)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{starCount > 0 ? (
|
{starCount > 0 ? (
|
||||||
@ -42,13 +41,13 @@ export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
|
|||||||
<StarIcon className="w-[14px] h-[14px]" />
|
<StarIcon className="w-[14px] h-[14px]" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p>{diff}</p>
|
<p>{difficulty.difficulty}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Image
|
<Image
|
||||||
unoptimized
|
unoptimized
|
||||||
src={`https://img.fascinated.cc/upload/w_64,h_64/${leaderboard.coverImage}`}
|
src={`https://img.fascinated.cc/upload/w_64,h_64/${leaderboard.songArt}`}
|
||||||
width={64}
|
width={64}
|
||||||
height={64}
|
height={64}
|
||||||
alt="Song Artwork"
|
alt="Song Artwork"
|
||||||
|
@ -2,15 +2,15 @@ import { formatNumberWithCommas } from "@ssr/common/utils/number-utils";
|
|||||||
import { format } from "@formkit/tempo";
|
import { format } from "@formkit/tempo";
|
||||||
import { GlobeAmericasIcon } from "@heroicons/react/24/solid";
|
import { GlobeAmericasIcon } from "@heroicons/react/24/solid";
|
||||||
import Tooltip from "../tooltip";
|
import Tooltip from "../tooltip";
|
||||||
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token";
|
|
||||||
import { timeAgo } from "@ssr/common/utils/time-utils";
|
import { timeAgo } from "@ssr/common/utils/time-utils";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getPageFromRank } from "@ssr/common/utils/utils";
|
import { getPageFromRank } from "@ssr/common/utils/utils";
|
||||||
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
||||||
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
score: ScoreSaberScoreToken;
|
score: ScoreSaberScore;
|
||||||
leaderboard: ScoreSaberLeaderboardToken;
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ScoreRankInfo({ score, leaderboard }: Props) {
|
export default function ScoreRankInfo({ score, leaderboard }: Props) {
|
||||||
@ -29,13 +29,13 @@ export default function ScoreRankInfo({ score, leaderboard }: Props) {
|
|||||||
display={
|
display={
|
||||||
<p>
|
<p>
|
||||||
{format({
|
{format({
|
||||||
date: new Date(score.timeSet),
|
date: new Date(score.timestamp),
|
||||||
format: "DD MMMM YYYY HH:mm a",
|
format: "DD MMMM YYYY HH:mm a",
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className="text-sm cursor-default select-none">{timeAgo(new Date(score.timeSet))}</p>
|
<p className="text-sm cursor-default select-none">{timeAgo(new Date(score.timestamp))}</p>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -4,8 +4,8 @@ import { XMarkIcon } from "@heroicons/react/24/solid";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Tooltip from "@/components/tooltip";
|
import Tooltip from "@/components/tooltip";
|
||||||
import { ScoreBadge, ScoreBadges } from "@/components/score/score-badge";
|
import { ScoreBadge, ScoreBadges } from "@/components/score/score-badge";
|
||||||
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token";
|
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
||||||
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
|
|
||||||
const badges: ScoreBadge[] = [
|
const badges: ScoreBadge[] = [
|
||||||
{
|
{
|
||||||
@ -13,12 +13,13 @@ const badges: ScoreBadge[] = [
|
|||||||
color: () => {
|
color: () => {
|
||||||
return "bg-pp";
|
return "bg-pp";
|
||||||
},
|
},
|
||||||
create: (score: ScoreSaberScoreToken) => {
|
create: (score: ScoreSaberScore) => {
|
||||||
const pp = score.pp;
|
const pp = score.pp;
|
||||||
if (pp === 0) {
|
const weight = score.weight;
|
||||||
|
if (pp === 0 || pp === undefined || weight === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const weightedPp = pp * score.weight;
|
const weightedPp = pp * weight;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -26,7 +27,7 @@ const badges: ScoreBadge[] = [
|
|||||||
display={
|
display={
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
Weighted: {formatPp(weightedPp)}pp ({(100 * score.weight).toFixed(2)}%)
|
Weighted: {formatPp(weightedPp)}pp ({(100 * weight).toFixed(2)}%)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@ -39,12 +40,12 @@ const badges: ScoreBadge[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Accuracy",
|
name: "Accuracy",
|
||||||
color: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => {
|
color: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => {
|
||||||
const acc = (score.baseScore / leaderboard.maxScore) * 100;
|
const acc = (score.score / leaderboard.maxScore) * 100;
|
||||||
return getScoreBadgeFromAccuracy(acc).color;
|
return getScoreBadgeFromAccuracy(acc).color;
|
||||||
},
|
},
|
||||||
create: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => {
|
create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => {
|
||||||
const acc = (score.baseScore / leaderboard.maxScore) * 100;
|
const acc = (score.score / leaderboard.maxScore) * 100;
|
||||||
const scoreBadge = getScoreBadgeFromAccuracy(acc);
|
const scoreBadge = getScoreBadgeFromAccuracy(acc);
|
||||||
let accDetails = `Accuracy ${scoreBadge.name != "-" ? scoreBadge.name : ""}`;
|
let accDetails = `Accuracy ${scoreBadge.name != "-" ? scoreBadge.name : ""}`;
|
||||||
if (scoreBadge.max == null) {
|
if (scoreBadge.max == null) {
|
||||||
@ -72,8 +73,8 @@ const badges: ScoreBadge[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Score",
|
name: "Score",
|
||||||
create: (score: ScoreSaberScoreToken) => {
|
create: (score: ScoreSaberScore) => {
|
||||||
return `${formatNumberWithCommas(Number(score.baseScore.toFixed(0)))}`;
|
return `${formatNumberWithCommas(Number(score.score.toFixed(0)))}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -86,14 +87,14 @@ const badges: ScoreBadge[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Full Combo",
|
name: "Full Combo",
|
||||||
create: (score: ScoreSaberScoreToken) => {
|
create: (score: ScoreSaberScore) => {
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
display={
|
display={
|
||||||
<div className="flex flex-col justify-center items-center">
|
<div className="flex flex-col justify-center items-center">
|
||||||
{!score.fullCombo ? (
|
{!score.fullCombo ? (
|
||||||
<>
|
<>
|
||||||
<p>Missed Notes: {formatNumberWithCommas(score.missedNotes)}</p>
|
<p>Missed Notes: {formatNumberWithCommas(score.misses)}</p>
|
||||||
<p>Bad Cuts: {formatNumberWithCommas(score.badCuts)}</p>
|
<p>Bad Cuts: {formatNumberWithCommas(score.badCuts)}</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -107,7 +108,7 @@ const badges: ScoreBadge[] = [
|
|||||||
{score.fullCombo ? (
|
{score.fullCombo ? (
|
||||||
<span className="text-green-400">FC</span>
|
<span className="text-green-400">FC</span>
|
||||||
) : (
|
) : (
|
||||||
formatNumberWithCommas(score.missedNotes + score.badCuts)
|
formatNumberWithCommas(score.misses + score.badCuts)
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<XMarkIcon className={clsx("w-5 h-5", score.fullCombo ? "hidden" : "text-red-400")} />
|
<XMarkIcon className={clsx("w-5 h-5", score.fullCombo ? "hidden" : "text-red-400")} />
|
||||||
@ -119,8 +120,8 @@ const badges: ScoreBadge[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
score: ScoreSaberScoreToken;
|
score: ScoreSaberScore;
|
||||||
leaderboard: ScoreSaberLeaderboardToken;
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ScoreStats({ score, leaderboard }: Props) {
|
export default function ScoreStats({ score, leaderboard }: Props) {
|
||||||
|
@ -1,18 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import BeatSaverMap from "@/common/database/types/beatsaver-map";
|
|
||||||
import LeaderboardScores from "@/components/leaderboard/leaderboard-scores";
|
import LeaderboardScores from "@/components/leaderboard/leaderboard-scores";
|
||||||
import { useCallback, 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-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";
|
||||||
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player";
|
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
||||||
import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token";
|
|
||||||
import { lookupBeatSaverMap } from "@/common/beatsaver-utils";
|
|
||||||
import { getPageFromRank } from "@ssr/common/utils/utils";
|
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 ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
|
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
@ -20,10 +20,20 @@ type Props = {
|
|||||||
*/
|
*/
|
||||||
player?: ScoreSaberPlayer;
|
player?: ScoreSaberPlayer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The leaderboard.
|
||||||
|
*/
|
||||||
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The beat saver map for this song.
|
||||||
|
*/
|
||||||
|
beatSaverMap?: BeatSaverMap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The score to display.
|
* The score to display.
|
||||||
*/
|
*/
|
||||||
playerScore: ScoreSaberPlayerScoreToken;
|
score: ScoreSaberScore;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Score settings
|
* Score settings
|
||||||
@ -33,36 +43,18 @@ type Props = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Score({ player, playerScore, settings }: Props) {
|
export default function Score({ player, leaderboard, beatSaverMap, score, settings }: Props) {
|
||||||
const { score, leaderboard } = playerScore;
|
const [baseScore, setBaseScore] = useState<number>(score.score);
|
||||||
const [baseScore, setBaseScore] = useState<number>(score.baseScore);
|
|
||||||
const [beatSaverMap, setBeatSaverMap] = useState<BeatSaverMap | undefined>();
|
|
||||||
const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false);
|
const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false);
|
||||||
|
|
||||||
const fetchBeatSaverData = useCallback(async () => {
|
|
||||||
// No need to fetch if no buttons
|
|
||||||
if (settings?.noScoreButtons == true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const beatSaverMapData = await lookupBeatSaverMap(leaderboard.songHash);
|
|
||||||
setBeatSaverMap(beatSaverMapData);
|
|
||||||
}, [leaderboard.songHash, settings?.noScoreButtons]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the base score
|
* Set the base score
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (playerScore?.score?.baseScore) {
|
if (score?.score) {
|
||||||
setBaseScore(playerScore.score.baseScore);
|
setBaseScore(score.score);
|
||||||
}
|
}
|
||||||
}, [playerScore]);
|
}, [score]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the beatSaver data on page load
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
fetchBeatSaverData();
|
|
||||||
}, [fetchBeatSaverData]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close the leaderboard when the score changes
|
* Close the leaderboard when the score changes
|
||||||
@ -72,7 +64,7 @@ export default function Score({ player, playerScore, settings }: Props) {
|
|||||||
}, [score]);
|
}, [score]);
|
||||||
|
|
||||||
const accuracy = (baseScore / leaderboard.maxScore) * 100;
|
const accuracy = (baseScore / leaderboard.maxScore) * 100;
|
||||||
const pp = baseScore === score.baseScore ? score.pp : scoresaberService.getPp(leaderboard.stars, accuracy);
|
const pp = baseScore === score.score ? score.pp : scoresaberService.getPp(leaderboard.stars, accuracy);
|
||||||
|
|
||||||
// Dynamic grid column classes
|
// Dynamic grid column classes
|
||||||
const gridColsClass = settings?.noScoreButtons
|
const gridColsClass = settings?.noScoreButtons
|
||||||
@ -92,14 +84,14 @@ export default function Score({ player, playerScore, settings }: Props) {
|
|||||||
score={score}
|
score={score}
|
||||||
setIsLeaderboardExpanded={setIsLeaderboardExpanded}
|
setIsLeaderboardExpanded={setIsLeaderboardExpanded}
|
||||||
updateScore={score => {
|
updateScore={score => {
|
||||||
setBaseScore(score.baseScore);
|
setBaseScore(score.score);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ScoreStats
|
<ScoreStats
|
||||||
score={{
|
score={{
|
||||||
...score,
|
...score,
|
||||||
baseScore,
|
score: baseScore,
|
||||||
pp: pp ? pp : score.pp,
|
pp: pp ? pp : score.pp,
|
||||||
}}
|
}}
|
||||||
leaderboard={leaderboard}
|
leaderboard={leaderboard}
|
||||||
|
Reference in New Issue
Block a user