testing
This commit is contained in:
parent
b911072a47
commit
2edb5c04c9
@ -2,34 +2,38 @@ import { Controller, Get } from "elysia-decorators";
|
|||||||
import { t } from "elysia";
|
import { t } from "elysia";
|
||||||
import { Leaderboards } from "@ssr/common/leaderboard";
|
import { Leaderboards } from "@ssr/common/leaderboard";
|
||||||
import { ScoreService } from "../service/score.service";
|
import { ScoreService } from "../service/score.service";
|
||||||
|
import { ScoreSortType } from "@ssr/common/sorter/sort-type";
|
||||||
|
import { SortDirection } from "@ssr/common/sorter/sort-direction";
|
||||||
|
|
||||||
@Controller("/scores")
|
@Controller("/scores")
|
||||||
export default class ScoresController {
|
export default class ScoresController {
|
||||||
@Get("/player/:leaderboard/:id/:page/:sort", {
|
@Get("/player/:leaderboard/:id/:page/:sort/:direction", {
|
||||||
config: {},
|
config: {},
|
||||||
params: t.Object({
|
params: t.Object({
|
||||||
leaderboard: t.String({ required: true }),
|
leaderboard: t.String({ required: true }),
|
||||||
id: t.String({ required: true }),
|
id: t.String({ required: true }),
|
||||||
page: t.Number({ required: true }),
|
page: t.Number({ required: true }),
|
||||||
sort: t.String({ required: true }),
|
sort: t.String({ required: true }),
|
||||||
|
direction: t.String({ required: true }),
|
||||||
}),
|
}),
|
||||||
query: t.Object({
|
query: t.Object({
|
||||||
search: t.Optional(t.String()),
|
search: t.Optional(t.String()),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
public async getScores({
|
public async getScores({
|
||||||
params: { leaderboard, id, page, sort },
|
params: { leaderboard, id, page, sort, direction },
|
||||||
query: { search },
|
query: { search },
|
||||||
}: {
|
}: {
|
||||||
params: {
|
params: {
|
||||||
leaderboard: Leaderboards;
|
leaderboard: Leaderboards;
|
||||||
id: string;
|
id: string;
|
||||||
page: number;
|
page: number;
|
||||||
sort: string;
|
sort: ScoreSortType;
|
||||||
|
direction: SortDirection;
|
||||||
};
|
};
|
||||||
query: { search?: string };
|
query: { search?: string };
|
||||||
}): Promise<unknown> {
|
}): Promise<unknown> {
|
||||||
return await ScoreService.getPlayerScores(leaderboard, id, page, sort, search);
|
return await ScoreService.getPlayerScores(leaderboard, id, page, sort, direction, search);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("/leaderboard/:leaderboard/:id/:page", {
|
@Get("/leaderboard/:leaderboard/:id/:page", {
|
||||||
|
@ -24,6 +24,7 @@ import { connectScoresaberWebsocket } from "@ssr/common/websocket/scoresaber-web
|
|||||||
import { connectBeatLeaderWebsocket } from "@ssr/common/websocket/beatleader-websocket";
|
import { connectBeatLeaderWebsocket } from "@ssr/common/websocket/beatleader-websocket";
|
||||||
import { DiscordChannels, initDiscordBot, logToChannel } from "./bot/bot";
|
import { DiscordChannels, initDiscordBot, logToChannel } from "./bot/bot";
|
||||||
import { EmbedBuilder } from "discord.js";
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import { ScoreSort } from "@ssr/common/score/score-sort";
|
||||||
|
|
||||||
// Load .env file
|
// Load .env file
|
||||||
dotenv.config({
|
dotenv.config({
|
||||||
@ -38,7 +39,9 @@ await mongoose.connect(Config.mongoUri!); // Connect to MongoDB
|
|||||||
// Connect to websockets
|
// Connect to websockets
|
||||||
connectScoresaberWebsocket({
|
connectScoresaberWebsocket({
|
||||||
onScore: async score => {
|
onScore: async score => {
|
||||||
await ScoreService.trackScoreSaberScore(score);
|
await ScoreService.trackScoreSaberScore(score.score, score.leaderboard);
|
||||||
|
await ScoreService.updatePlayerScoresSet(score);
|
||||||
|
|
||||||
await ScoreService.notifyNumberOne(score);
|
await ScoreService.notifyNumberOne(score);
|
||||||
},
|
},
|
||||||
onDisconnect: async error => {
|
onDisconnect: async error => {
|
||||||
@ -106,6 +109,46 @@ app.use(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
cron({
|
||||||
|
name: "scores-background-refresh",
|
||||||
|
pattern: "*/1 * * * *",
|
||||||
|
protect: true,
|
||||||
|
run: async () => {
|
||||||
|
console.log(`Refreshing player score data...`);
|
||||||
|
const players = await PlayerModel.find({});
|
||||||
|
console.log(`Found ${players.length} players to refresh.`);
|
||||||
|
|
||||||
|
for (const player of players) {
|
||||||
|
console.log(`Refreshing scores for ${player.id}...`);
|
||||||
|
let page = 1;
|
||||||
|
let hasMorePages = true;
|
||||||
|
|
||||||
|
while (hasMorePages) {
|
||||||
|
const scoresPage = await scoresaberService.lookupPlayerScores({
|
||||||
|
playerId: player.id,
|
||||||
|
page: page,
|
||||||
|
limit: 100,
|
||||||
|
sort: ScoreSort.recent,
|
||||||
|
});
|
||||||
|
if (!scoresPage) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (scoresPage.metadata.total <= page * 100) {
|
||||||
|
hasMorePages = false;
|
||||||
|
}
|
||||||
|
page++;
|
||||||
|
|
||||||
|
for (const score of scoresPage.playerScores) {
|
||||||
|
await ScoreService.trackScoreSaberScore(score.score, score.leaderboard, player.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Finished refreshing scores for ${player.id}, total pages refreshed: ${page - 1}.`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom error handler
|
* Custom error handler
|
||||||
*/
|
*/
|
||||||
|
@ -3,9 +3,7 @@ import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils
|
|||||||
import { isProduction } from "@ssr/common/utils/utils";
|
import { isProduction } from "@ssr/common/utils/utils";
|
||||||
import { Metadata } from "@ssr/common/types/metadata";
|
import { Metadata } from "@ssr/common/types/metadata";
|
||||||
import { NotFoundError } from "elysia";
|
import { NotFoundError } from "elysia";
|
||||||
import BeatSaverService from "./beatsaver.service";
|
|
||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||||
import { ScoreSort } from "@ssr/common/score/score-sort";
|
|
||||||
import { Leaderboards } from "@ssr/common/leaderboard";
|
import { Leaderboards } from "@ssr/common/leaderboard";
|
||||||
import Leaderboard from "@ssr/common/leaderboard/leaderboard";
|
import Leaderboard from "@ssr/common/leaderboard/leaderboard";
|
||||||
import LeaderboardService from "./leaderboard.service";
|
import LeaderboardService from "./leaderboard.service";
|
||||||
@ -25,10 +23,24 @@ import {
|
|||||||
AdditionalScoreDataModel,
|
AdditionalScoreDataModel,
|
||||||
} from "@ssr/common/model/additional-score-data/additional-score-data";
|
} from "@ssr/common/model/additional-score-data/additional-score-data";
|
||||||
import { BeatLeaderScoreImprovementToken } from "@ssr/common/types/token/beatleader/score/score-improvement";
|
import { BeatLeaderScoreImprovementToken } from "@ssr/common/types/token/beatleader/score/score-improvement";
|
||||||
import { ScoreType } from "@ssr/common/model/score/score";
|
import Score, { ScoreType } from "@ssr/common/model/score/score";
|
||||||
import { getScoreSaberLeaderboardFromToken, getScoreSaberScoreFromToken } from "@ssr/common/token-creators";
|
import { getScoreSaberLeaderboardFromToken, getScoreSaberScoreFromToken } from "@ssr/common/token-creators";
|
||||||
import { ScoreSaberScoreModel } from "@ssr/common/model/score/impl/scoresaber-score";
|
import {
|
||||||
|
ScoreSaberScore,
|
||||||
|
ScoreSaberScoreInternal,
|
||||||
|
ScoreSaberScoreModel,
|
||||||
|
} from "@ssr/common/model/score/impl/scoresaber-score";
|
||||||
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
|
import { ScoreSorters } from "@ssr/common/sorter/sorters";
|
||||||
|
import { ScoreSortType } from "@ssr/common/sorter/sort-type";
|
||||||
|
import { SortDirection } from "@ssr/common/sorter/sort-direction";
|
||||||
|
import { Pagination } from "../../../common/src/pagination";
|
||||||
|
import { PlayerService } from "./player.service";
|
||||||
|
import { ScoreSort } from "@ssr/common/score/score-sort";
|
||||||
|
import BeatSaverService from "./beatsaver.service";
|
||||||
|
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
|
||||||
|
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token";
|
||||||
|
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
||||||
|
|
||||||
const playerScoresCache = new SSRCache({
|
const playerScoresCache = new SSRCache({
|
||||||
ttl: 1000 * 60, // 1 minute
|
ttl: 1000 * 60, // 1 minute
|
||||||
@ -38,6 +50,8 @@ const leaderboardScoresCache = new SSRCache({
|
|||||||
ttl: 1000 * 60, // 1 minute
|
ttl: 1000 * 60, // 1 minute
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 8;
|
||||||
|
|
||||||
export class ScoreService {
|
export class ScoreService {
|
||||||
/**
|
/**
|
||||||
* Notifies the number one score in Discord.
|
* Notifies the number one score in Discord.
|
||||||
@ -118,15 +132,17 @@ export class ScoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracks ScoreSaber score.
|
* Updates the players set scores count for today.
|
||||||
*
|
*
|
||||||
* @param score the score to track
|
* @param score the score
|
||||||
* @param leaderboard the leaderboard to track
|
|
||||||
*/
|
*/
|
||||||
public static async trackScoreSaberScore({ score, leaderboard: leaderboardToken }: ScoreSaberPlayerScoreToken) {
|
public static async updatePlayerScoresSet({
|
||||||
|
score: scoreToken,
|
||||||
|
leaderboard: leaderboardToken,
|
||||||
|
}: ScoreSaberPlayerScoreToken) {
|
||||||
|
const playerId = scoreToken.leaderboardPlayerInfo.id;
|
||||||
|
|
||||||
const leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken);
|
const leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken);
|
||||||
const playerId = score.leaderboardPlayerInfo.id;
|
|
||||||
const playerName = score.leaderboardPlayerInfo.name;
|
|
||||||
const player: PlayerDocument | null = await PlayerModel.findById(playerId);
|
const player: PlayerDocument | null = await PlayerModel.findById(playerId);
|
||||||
// Player is not tracked, so ignore the score.
|
// Player is not tracked, so ignore the score.
|
||||||
if (player == undefined) {
|
if (player == undefined) {
|
||||||
@ -147,37 +163,36 @@ export class ScoreService {
|
|||||||
|
|
||||||
history.scores = scores;
|
history.scores = scores;
|
||||||
player.setStatisticHistory(today, history);
|
player.setStatisticHistory(today, history);
|
||||||
player.sortStatisticHistory();
|
|
||||||
|
|
||||||
// Save the changes
|
|
||||||
player.markModified("statisticHistory");
|
|
||||||
await player.save();
|
await player.save();
|
||||||
|
}
|
||||||
|
|
||||||
const scoreToken = getScoreSaberScoreFromToken(score, leaderboard, playerId);
|
/**
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
* Tracks ScoreSaber score.
|
||||||
// @ts-expect-error
|
*
|
||||||
delete scoreToken.playerInfo;
|
* @param scoreToken the score to track
|
||||||
|
* @param leaderboardToken the leaderboard for the score
|
||||||
// Check if the score already exists
|
* @param playerId the id of the player
|
||||||
if (
|
*/
|
||||||
await ScoreSaberScoreModel.exists({
|
public static async trackScoreSaberScore(
|
||||||
playerId: playerId,
|
scoreToken: ScoreSaberScoreToken,
|
||||||
leaderboardId: leaderboard.id,
|
leaderboardToken: ScoreSaberLeaderboardToken,
|
||||||
score: scoreToken.score,
|
playerId?: string
|
||||||
difficulty: leaderboard.difficulty.difficulty,
|
|
||||||
characteristic: leaderboard.difficulty.characteristic,
|
|
||||||
})
|
|
||||||
) {
|
) {
|
||||||
console.log(
|
playerId = playerId || scoreToken.leaderboardPlayerInfo.id;
|
||||||
`Score already exists for "${playerName}"(${playerId}), scoreId=${scoreToken.scoreId}, score=${scoreToken.score}`
|
|
||||||
);
|
const leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken);
|
||||||
|
const score = getScoreSaberScoreFromToken(scoreToken, leaderboard, playerId);
|
||||||
|
const player: PlayerDocument | null = await PlayerModel.findById(playerId);
|
||||||
|
// Player is not tracked, so ignore the score.
|
||||||
|
if (player == undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ScoreSaberScoreModel.create(scoreToken);
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
console.log(
|
// @ts-expect-error
|
||||||
`Tracked score and updated scores set statistic for "${playerName}"(${playerId}), scores today: ${scores.rankedScores} ranked, ${scores.unrankedScores} unranked`
|
delete score.playerInfo;
|
||||||
);
|
|
||||||
|
await ScoreSaberScoreModel.create(score);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -292,7 +307,8 @@ export class ScoreService {
|
|||||||
* @param leaderboardName the leaderboard to get the scores from
|
* @param leaderboardName the leaderboard to get the scores from
|
||||||
* @param playerId the players id
|
* @param playerId the players id
|
||||||
* @param page the page to get
|
* @param page the page to get
|
||||||
* @param sort the sort to use
|
* @param sort the sort type to use
|
||||||
|
* @param direction the direction to sort the scores
|
||||||
* @param search the search to use
|
* @param search the search to use
|
||||||
* @returns the scores
|
* @returns the scores
|
||||||
*/
|
*/
|
||||||
@ -300,36 +316,104 @@ export class ScoreService {
|
|||||||
leaderboardName: Leaderboards,
|
leaderboardName: Leaderboards,
|
||||||
playerId: string,
|
playerId: string,
|
||||||
page: number,
|
page: number,
|
||||||
sort: string,
|
sort: ScoreSortType,
|
||||||
|
direction: SortDirection,
|
||||||
search?: string
|
search?: string
|
||||||
): Promise<PlayerScoresResponse<unknown, unknown> | undefined> {
|
): Promise<PlayerScoresResponse<unknown, unknown> | undefined> {
|
||||||
|
console.log(
|
||||||
|
`Fetching scores for ${playerId} on ${leaderboardName}, page: ${page}, sort: ${sort}, direction: ${direction}, search: ${search}`
|
||||||
|
);
|
||||||
|
|
||||||
return fetchWithCache(
|
return fetchWithCache(
|
||||||
playerScoresCache,
|
playerScoresCache,
|
||||||
`player-scores-${leaderboardName}-${playerId}-${page}-${sort}-${search}`,
|
`player-scores-${leaderboardName}-${playerId}-${page}-${sort}-${search}`,
|
||||||
async () => {
|
async () => {
|
||||||
const scores: PlayerScore<unknown, unknown>[] | undefined = [];
|
const toReturn: PlayerScore<unknown, unknown>[] | undefined = [];
|
||||||
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
|
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
|
||||||
|
|
||||||
switch (leaderboardName) {
|
switch (leaderboardName) {
|
||||||
case "scoresaber": {
|
case "scoresaber": {
|
||||||
const leaderboardScores = await scoresaberService.lookupPlayerScores({
|
let isPlayerTracked = false;
|
||||||
|
try {
|
||||||
|
isPlayerTracked = (await PlayerService.getPlayer(playerId, false)) != undefined;
|
||||||
|
} catch {
|
||||||
|
/* ignored */
|
||||||
|
}
|
||||||
|
if (isPlayerTracked) {
|
||||||
|
const rawScores = ScoreSorters.scoreSaber.sort(
|
||||||
|
sort,
|
||||||
|
direction,
|
||||||
|
(await ScoreSaberScoreModel.find({ playerId: playerId }).exec()) as unknown as ScoreSaberScore[]
|
||||||
|
);
|
||||||
|
if (!rawScores || rawScores.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagination = new Pagination<ScoreSaberScore>().setItemsPerPage(ITEMS_PER_PAGE).setItems(rawScores);
|
||||||
|
const paginatedPage = pagination.getPage(page);
|
||||||
|
metadata = paginatedPage.metadata;
|
||||||
|
|
||||||
|
for (const score of paginatedPage.items) {
|
||||||
|
const { leaderboard, beatsaver } = await LeaderboardService.getLeaderboard<ScoreSaberLeaderboard>(
|
||||||
|
"scoresaber",
|
||||||
|
String(score.leaderboardId)
|
||||||
|
);
|
||||||
|
if (leaderboard == undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const additionalData = await this.getAdditionalScoreData(
|
||||||
|
playerId,
|
||||||
|
leaderboard.songHash,
|
||||||
|
`${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`,
|
||||||
|
score.score
|
||||||
|
);
|
||||||
|
if (additionalData == undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
toReturn.push({
|
||||||
|
score: score,
|
||||||
|
leaderboard: leaderboard,
|
||||||
|
beatSaver: beatsaver,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Convert the sort type
|
||||||
|
let scoreSaberSort: ScoreSort;
|
||||||
|
switch (sort) {
|
||||||
|
case ScoreSortType.date: {
|
||||||
|
scoreSaberSort = ScoreSort.recent;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ScoreSortType.pp: {
|
||||||
|
scoreSaberSort = ScoreSort.top;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
scoreSaberSort = ScoreSort.recent;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawScores = await scoresaberService.lookupPlayerScores({
|
||||||
playerId: playerId,
|
playerId: playerId,
|
||||||
page: page,
|
page: page,
|
||||||
sort: sort as ScoreSort,
|
sort: scoreSaberSort,
|
||||||
search: search,
|
search: search,
|
||||||
});
|
});
|
||||||
if (leaderboardScores == undefined) {
|
if (!rawScores || rawScores.playerScores.length === 0) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata = new Metadata(
|
metadata = new Metadata(
|
||||||
Math.ceil(leaderboardScores.metadata.total / leaderboardScores.metadata.itemsPerPage),
|
Math.ceil(rawScores.metadata.total / rawScores.metadata.itemsPerPage),
|
||||||
leaderboardScores.metadata.total,
|
rawScores.metadata.total,
|
||||||
leaderboardScores.metadata.page,
|
rawScores.metadata.page,
|
||||||
leaderboardScores.metadata.itemsPerPage
|
rawScores.metadata.itemsPerPage
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const token of leaderboardScores.playerScores) {
|
for (const token of rawScores.playerScores) {
|
||||||
const leaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard);
|
const leaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard);
|
||||||
if (leaderboard == undefined) {
|
if (leaderboard == undefined) {
|
||||||
continue;
|
continue;
|
||||||
@ -350,12 +434,14 @@ export class ScoreService {
|
|||||||
score.additionalData = additionalData;
|
score.additionalData = additionalData;
|
||||||
}
|
}
|
||||||
|
|
||||||
scores.push({
|
toReturn.push({
|
||||||
score: score,
|
score: score,
|
||||||
leaderboard: leaderboard,
|
leaderboard: leaderboard,
|
||||||
beatSaver: await BeatSaverService.getMap(leaderboard.songHash),
|
beatSaver: await BeatSaverService.getMap(leaderboard.songHash),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
@ -363,8 +449,9 @@ export class ScoreService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(metadata);
|
||||||
return {
|
return {
|
||||||
scores: scores,
|
scores: toReturn,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
73
projects/common/src/pagination.ts
Normal file
73
projects/common/src/pagination.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { NotFoundError } from "backend/src/error/not-found-error";
|
||||||
|
import { Metadata } from "./types/metadata";
|
||||||
|
|
||||||
|
export class Pagination<T> {
|
||||||
|
/**
|
||||||
|
* The amount of items per page.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private itemsPerPage: number = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of items in total.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private totalItems: number = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The items to paginate.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private items: T[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the number of items per page.
|
||||||
|
*
|
||||||
|
* @param itemsPerPage - The number of items per page.
|
||||||
|
* @returns the pagination
|
||||||
|
*/
|
||||||
|
setItemsPerPage(itemsPerPage: number): Pagination<T> {
|
||||||
|
this.itemsPerPage = itemsPerPage;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the items to paginate.
|
||||||
|
*
|
||||||
|
* @param items the items to paginate
|
||||||
|
* @returns the pagination
|
||||||
|
*/
|
||||||
|
setItems(items: T[]): Pagination<T> {
|
||||||
|
this.items = items;
|
||||||
|
this.totalItems = items.length;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a page of items.
|
||||||
|
*
|
||||||
|
* @param page the page number to retrieve.
|
||||||
|
* @returns the page of items.
|
||||||
|
* @throws throws an error if the page number is invalid.
|
||||||
|
*/
|
||||||
|
getPage(page: number): Page<T> {
|
||||||
|
const totalPages = Math.ceil(this.totalItems / this.itemsPerPage);
|
||||||
|
|
||||||
|
if (page < 1 || page > totalPages) {
|
||||||
|
throw new NotFoundError("Invalid page number");
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = this.items.slice((page - 1) * this.itemsPerPage, page * this.itemsPerPage);
|
||||||
|
return new Page<T>(items, new Metadata(totalPages, this.totalItems, page, this.itemsPerPage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Page<T> {
|
||||||
|
readonly items: T[];
|
||||||
|
readonly metadata: Metadata;
|
||||||
|
|
||||||
|
constructor(items: T[], metadata: Metadata) {
|
||||||
|
this.items = items;
|
||||||
|
this.metadata = metadata;
|
||||||
|
}
|
||||||
|
}
|
@ -168,6 +168,7 @@ class ScoreSaberService extends Service {
|
|||||||
* @param playerId the ID of the player to look up
|
* @param playerId the ID of the player to look up
|
||||||
* @param sort the sort to use
|
* @param sort the sort to use
|
||||||
* @param page the page to get scores for
|
* @param page the page to get scores for
|
||||||
|
* @param limit the amount of scores to fetch
|
||||||
* @param search
|
* @param search
|
||||||
* @returns the scores of the player, or undefined
|
* @returns the scores of the player, or undefined
|
||||||
*/
|
*/
|
||||||
@ -175,11 +176,13 @@ class ScoreSaberService extends Service {
|
|||||||
playerId,
|
playerId,
|
||||||
sort,
|
sort,
|
||||||
page,
|
page,
|
||||||
|
limit = 8,
|
||||||
search,
|
search,
|
||||||
}: {
|
}: {
|
||||||
playerId: string;
|
playerId: string;
|
||||||
sort: ScoreSort;
|
sort: ScoreSort;
|
||||||
page: number;
|
page: number;
|
||||||
|
limit?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
useProxy?: boolean;
|
useProxy?: boolean;
|
||||||
}): Promise<ScoreSaberPlayerScoresPageToken | undefined> {
|
}): Promise<ScoreSaberPlayerScoresPageToken | undefined> {
|
||||||
@ -189,7 +192,7 @@ class ScoreSaberService extends Service {
|
|||||||
);
|
);
|
||||||
const response = await this.fetch<ScoreSaberPlayerScoresPageToken>(
|
const response = await this.fetch<ScoreSaberPlayerScoresPageToken>(
|
||||||
LOOKUP_PLAYER_SCORES_ENDPOINT.replace(":id", playerId)
|
LOOKUP_PLAYER_SCORES_ENDPOINT.replace(":id", playerId)
|
||||||
.replace(":limit", 8 + "")
|
.replace(":limit", limit + "")
|
||||||
.replace(":sort", sort)
|
.replace(":sort", sort)
|
||||||
.replace(":page", page + "") + (search ? `&search=${search}` : "")
|
.replace(":page", page + "") + (search ? `&search=${search}` : "")
|
||||||
);
|
);
|
||||||
|
69
projects/common/src/sorter/impl/scoresaber-sorter.ts
Normal file
69
projects/common/src/sorter/impl/scoresaber-sorter.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { ScoreSorter } from "../score-sorter";
|
||||||
|
import { ScoreSaberScore } from "../../model/score/impl/scoresaber-score";
|
||||||
|
import { ScoreSortType } from "../sort-type";
|
||||||
|
import { SortDirection } from "../sort-direction";
|
||||||
|
|
||||||
|
export class ScoreSaberScoreSorter extends ScoreSorter<ScoreSaberScore> {
|
||||||
|
sort(type: ScoreSortType, direction: SortDirection, items: ScoreSaberScore[]): ScoreSaberScore[] {
|
||||||
|
switch (type) {
|
||||||
|
case ScoreSortType.date:
|
||||||
|
return this.sortRecent(direction, items);
|
||||||
|
case ScoreSortType.pp:
|
||||||
|
return this.sortPp(direction, items);
|
||||||
|
case ScoreSortType.accuracy:
|
||||||
|
return this.sortAccuracy(direction, items);
|
||||||
|
case ScoreSortType.misses:
|
||||||
|
return this.sortMisses(direction, items);
|
||||||
|
default:
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts the scores by the time they were set.
|
||||||
|
*
|
||||||
|
* @param direction the direction to sort the scores
|
||||||
|
* @param items the scores to sort
|
||||||
|
* @returns the sorted scores
|
||||||
|
*/
|
||||||
|
sortRecent(direction: SortDirection, items: ScoreSaberScore[]): ScoreSaberScore[] {
|
||||||
|
return items.sort((a, b) =>
|
||||||
|
direction === SortDirection.ASC
|
||||||
|
? a.timestamp.getTime() - b.timestamp.getTime()
|
||||||
|
: b.timestamp.getTime() - a.timestamp.getTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts the scores by their pp value
|
||||||
|
*
|
||||||
|
* @param direction the direction to sort the scores
|
||||||
|
* @param items the scores to sort
|
||||||
|
* @returns the sorted scores
|
||||||
|
*/
|
||||||
|
sortPp(direction: SortDirection, items: ScoreSaberScore[]): ScoreSaberScore[] {
|
||||||
|
return items.sort((a, b) => (direction === SortDirection.ASC ? a.pp - b.pp : b.pp - a.pp));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts the scores by their accuracy value
|
||||||
|
*
|
||||||
|
* @param direction the direction to sort the scores
|
||||||
|
* @param items the scores to sort
|
||||||
|
* @returns the sorted scores
|
||||||
|
*/
|
||||||
|
sortAccuracy(direction: SortDirection, items: ScoreSaberScore[]): ScoreSaberScore[] {
|
||||||
|
return items.sort((a, b) => (direction === SortDirection.ASC ? a.accuracy - b.accuracy : b.accuracy - a.accuracy));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts the scores by their misses
|
||||||
|
*
|
||||||
|
* @param direction the direction to sort the scores
|
||||||
|
* @param items the scores to sort
|
||||||
|
* @returns the sorted scores
|
||||||
|
*/
|
||||||
|
sortMisses(direction: SortDirection, items: ScoreSaberScore[]): ScoreSaberScore[] {
|
||||||
|
return items.sort((a, b) => (direction === SortDirection.ASC ? a.misses - b.misses : b.misses - a.misses));
|
||||||
|
}
|
||||||
|
}
|
14
projects/common/src/sorter/score-sorter.ts
Normal file
14
projects/common/src/sorter/score-sorter.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { ScoreSortType } from "./sort-type";
|
||||||
|
import { SortDirection } from "./sort-direction";
|
||||||
|
|
||||||
|
export abstract class ScoreSorter<T> {
|
||||||
|
/**
|
||||||
|
* Sorts the items
|
||||||
|
*
|
||||||
|
* @param type the type of sort
|
||||||
|
* @param direction the direction of the sort
|
||||||
|
* @param items the items to sort
|
||||||
|
* @returns the sorted items
|
||||||
|
*/
|
||||||
|
public abstract sort(type: ScoreSortType, direction: SortDirection, items: T[]): T[];
|
||||||
|
}
|
4
projects/common/src/sorter/sort-direction.ts
Normal file
4
projects/common/src/sorter/sort-direction.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export enum SortDirection {
|
||||||
|
ASC = "asc",
|
||||||
|
DESC = "desc",
|
||||||
|
}
|
6
projects/common/src/sorter/sort-type.ts
Normal file
6
projects/common/src/sorter/sort-type.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export enum ScoreSortType {
|
||||||
|
date = "date",
|
||||||
|
pp = "pp",
|
||||||
|
accuracy = "accuracy",
|
||||||
|
misses = "misses",
|
||||||
|
}
|
5
projects/common/src/sorter/sorters.ts
Normal file
5
projects/common/src/sorter/sorters.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { ScoreSaberScoreSorter } from "./impl/scoresaber-sorter";
|
||||||
|
|
||||||
|
export const ScoreSorters = {
|
||||||
|
scoreSaber: new ScoreSaberScoreSorter(),
|
||||||
|
};
|
@ -1,6 +1,6 @@
|
|||||||
import { isServer } from "./utils";
|
import { isServer } from "./utils";
|
||||||
|
|
||||||
export type CookieName = "playerId" | "lastScoreSort";
|
export type CookieName = "playerId" | "lastScoreSort" | "lastScoreSortDirection";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the value of a cookie
|
* Gets the value of a cookie
|
||||||
|
@ -4,6 +4,8 @@ import PlayerScoresResponse from "../response/player-scores-response";
|
|||||||
import { Config } from "../config";
|
import { Config } from "../config";
|
||||||
import { ScoreSort } from "../score/score-sort";
|
import { ScoreSort } from "../score/score-sort";
|
||||||
import LeaderboardScoresResponse from "../response/leaderboard-scores-response";
|
import LeaderboardScoresResponse from "../response/leaderboard-scores-response";
|
||||||
|
import { ScoreSortType } from "../sorter/sort-type";
|
||||||
|
import { SortDirection } from "../sorter/sort-direction";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the player's scores
|
* Fetches the player's scores
|
||||||
@ -12,17 +14,19 @@ import LeaderboardScoresResponse from "../response/leaderboard-scores-response";
|
|||||||
* @param id the player id
|
* @param id the player id
|
||||||
* @param page the page
|
* @param page the page
|
||||||
* @param sort the sort
|
* @param sort the sort
|
||||||
|
* @param direction the direction to sort
|
||||||
* @param search the search
|
* @param search the search
|
||||||
*/
|
*/
|
||||||
export async function fetchPlayerScores<S, L>(
|
export async function fetchPlayerScores<S, L>(
|
||||||
leaderboard: Leaderboards,
|
leaderboard: Leaderboards,
|
||||||
id: string,
|
id: string,
|
||||||
page: number,
|
page: number,
|
||||||
sort: ScoreSort,
|
sort: ScoreSortType,
|
||||||
|
direction: SortDirection,
|
||||||
search?: string
|
search?: string
|
||||||
) {
|
) {
|
||||||
return kyFetch<PlayerScoresResponse<S, L>>(
|
return kyFetch<PlayerScoresResponse<S, L>>(
|
||||||
`${Config.apiUrl}/scores/player/${leaderboard}/${id}/${page}/${sort}${search ? `?search=${search}` : ""}`
|
`${Config.apiUrl}/scores/player/${leaderboard}/${id}/${page}/${sort}/${direction}${search ? `?search=${search}` : ""}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,13 +7,14 @@ import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
|||||||
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 from "@ssr/common/player/impl/scoresaber-player";
|
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
||||||
import { ScoreSort } from "@ssr/common/score/score-sort";
|
|
||||||
import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score";
|
import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score";
|
||||||
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
import { fetchPlayerScores } from "@ssr/common/utils/score-utils";
|
import { fetchPlayerScores } from "@ssr/common/utils/score-utils";
|
||||||
import PlayerScoresResponse from "@ssr/common/response/player-scores-response";
|
import PlayerScoresResponse from "@ssr/common/response/player-scores-response";
|
||||||
import { SSRCache } from "@ssr/common/cache";
|
import { SSRCache } from "@ssr/common/cache";
|
||||||
import { getScoreSaberPlayerFromToken } from "@ssr/common/token-creators";
|
import { getScoreSaberPlayerFromToken } from "@ssr/common/token-creators";
|
||||||
|
import { ScoreSortType } from "@ssr/common/sorter/sort-type";
|
||||||
|
import { SortDirection } from "@ssr/common/sorter/sort-direction";
|
||||||
|
|
||||||
const UNKNOWN_PLAYER = {
|
const UNKNOWN_PLAYER = {
|
||||||
title: "ScoreSaber Reloaded - Unknown Player",
|
title: "ScoreSaber Reloaded - Unknown Player",
|
||||||
@ -32,7 +33,8 @@ type Props = {
|
|||||||
type PlayerData = {
|
type PlayerData = {
|
||||||
player: ScoreSaberPlayer | undefined;
|
player: ScoreSaberPlayer | undefined;
|
||||||
scores: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined;
|
scores: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined;
|
||||||
sort: ScoreSort;
|
sort: ScoreSortType;
|
||||||
|
direction: SortDirection;
|
||||||
page: number;
|
page: number;
|
||||||
search: string;
|
search: string;
|
||||||
};
|
};
|
||||||
@ -51,9 +53,11 @@ const playerCache = new SSRCache({
|
|||||||
const getPlayerData = async ({ params }: Props, fetchScores: boolean = true): Promise<PlayerData> => {
|
const getPlayerData = async ({ params }: Props, fetchScores: boolean = true): Promise<PlayerData> => {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const id = slug[0]; // The players id
|
const id = slug[0]; // The players id
|
||||||
const sort: ScoreSort = (slug[1] as ScoreSort) || (await getCookieValue("lastScoreSort", "recent")); // The sorting method
|
const sort: ScoreSortType = (slug[1] as ScoreSortType) || (await getCookieValue("lastScoreSort", ScoreSortType.date)); // The sorting method
|
||||||
const page = parseInt(slug[2]) || 1; // The page number
|
const direction: SortDirection =
|
||||||
const search = (slug[3] as string) || ""; // The search query
|
(slug[2] as SortDirection) || (await getCookieValue("lastScoreSortDirection", SortDirection.DESC)); // The sorting direction
|
||||||
|
const page = parseInt(slug[3]) || 1; // The page number
|
||||||
|
const search = (slug[4] as string) || ""; // The search query
|
||||||
|
|
||||||
const cacheId = `${id}-${sort}-${page}-${search}-${fetchScores}`;
|
const cacheId = `${id}-${sort}-${page}-${search}-${fetchScores}`;
|
||||||
if (playerCache.has(cacheId)) {
|
if (playerCache.has(cacheId)) {
|
||||||
@ -64,11 +68,19 @@ const getPlayerData = async ({ params }: Props, fetchScores: boolean = true): Pr
|
|||||||
const player = playerToken && (await getScoreSaberPlayerFromToken(playerToken, await getCookieValue("playerId")));
|
const player = playerToken && (await getScoreSaberPlayerFromToken(playerToken, await getCookieValue("playerId")));
|
||||||
let scores: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined;
|
let scores: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined;
|
||||||
if (fetchScores) {
|
if (fetchScores) {
|
||||||
scores = await fetchPlayerScores<ScoreSaberScore, ScoreSaberLeaderboard>("scoresaber", id, page, sort, search);
|
scores = await fetchPlayerScores<ScoreSaberScore, ScoreSaberLeaderboard>(
|
||||||
|
"scoresaber",
|
||||||
|
id,
|
||||||
|
page,
|
||||||
|
sort,
|
||||||
|
direction,
|
||||||
|
search
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const playerData = {
|
const playerData = {
|
||||||
sort: sort,
|
sort: sort,
|
||||||
|
direction: direction,
|
||||||
page: page,
|
page: page,
|
||||||
search: search,
|
search: search,
|
||||||
player: player,
|
player: player,
|
||||||
@ -123,14 +135,21 @@ export async function generateViewport(props: Props): Promise<Viewport> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function PlayerPage(props: Props) {
|
export default async function PlayerPage(props: Props) {
|
||||||
const { player, scores, sort, page, search } = await getPlayerData(props);
|
const { player, scores, sort, direction, page, search } = await getPlayerData(props);
|
||||||
if (player == undefined) {
|
if (player == undefined) {
|
||||||
return redirect("/");
|
return redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full">
|
<div className="flex flex-col h-full w-full">
|
||||||
<PlayerData initialPlayerData={player} initialScoreData={scores} initialSearch={search} sort={sort} page={page} />
|
<PlayerData
|
||||||
|
initialPlayerData={player}
|
||||||
|
initialScoreData={scores}
|
||||||
|
initialSearch={search}
|
||||||
|
sort={sort}
|
||||||
|
direction={direction}
|
||||||
|
page={page}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -19,16 +19,26 @@ import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score";
|
|||||||
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
import PlayerScoresResponse from "@ssr/common/response/player-scores-response";
|
import PlayerScoresResponse from "@ssr/common/response/player-scores-response";
|
||||||
import { getScoreSaberPlayerFromToken } from "@ssr/common/token-creators";
|
import { getScoreSaberPlayerFromToken } from "@ssr/common/token-creators";
|
||||||
|
import { ScoreSortType } from "@ssr/common/sorter/sort-type";
|
||||||
|
import { SortDirection } from "@ssr/common/sorter/sort-direction";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
initialPlayerData: ScoreSaberPlayer;
|
initialPlayerData: ScoreSaberPlayer;
|
||||||
initialScoreData?: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
|
initialScoreData?: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
|
||||||
initialSearch?: string;
|
initialSearch?: string;
|
||||||
sort: ScoreSort;
|
sort: ScoreSortType;
|
||||||
|
direction: SortDirection;
|
||||||
page: number;
|
page: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PlayerData({ initialPlayerData, initialScoreData, initialSearch, sort, page }: Props) {
|
export default function PlayerData({
|
||||||
|
initialPlayerData,
|
||||||
|
initialScoreData,
|
||||||
|
initialSearch,
|
||||||
|
sort,
|
||||||
|
direction,
|
||||||
|
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);
|
||||||
@ -65,6 +75,7 @@ export default function PlayerData({ initialPlayerData, initialScoreData, initia
|
|||||||
initialSearch={initialSearch}
|
initialSearch={initialSearch}
|
||||||
player={player}
|
player={player}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
|
direction={direction}
|
||||||
page={page}
|
page={page}
|
||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { capitalizeFirstLetter } from "@/common/string-utils";
|
import { capitalizeFirstLetter } from "@/common/string-utils";
|
||||||
import useWindowDimensions from "@/hooks/use-window-dimensions";
|
import useWindowDimensions from "@/hooks/use-window-dimensions";
|
||||||
import { ClockIcon, TrophyIcon, XMarkIcon } from "@heroicons/react/24/solid";
|
import { ArrowDownIcon, ClockIcon, TrophyIcon, XMarkIcon } from "@heroicons/react/24/solid";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { motion, useAnimation } from "framer-motion";
|
import { motion, useAnimation } from "framer-motion";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
@ -13,44 +13,55 @@ 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/player/impl/scoresaber-player";
|
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
||||||
import { ScoreSort } from "@ssr/common/score/score-sort";
|
|
||||||
import { setCookieValue } from "@ssr/common/utils/cookie-utils";
|
import { setCookieValue } from "@ssr/common/utils/cookie-utils";
|
||||||
import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score";
|
import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score";
|
||||||
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
import { fetchPlayerScores } from "@ssr/common/utils/score-utils";
|
import { fetchPlayerScores } from "@ssr/common/utils/score-utils";
|
||||||
import PlayerScoresResponse from "@ssr/common/response/player-scores-response";
|
import PlayerScoresResponse from "@ssr/common/response/player-scores-response";
|
||||||
|
import { SortDirection } from "@ssr/common/sorter/sort-direction";
|
||||||
|
import { ScoreSortType } from "@ssr/common/sorter/sort-type";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
initialScoreData?: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
|
initialScoreData?: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
|
||||||
initialSearch?: string;
|
initialSearch?: string;
|
||||||
player: ScoreSaberPlayer;
|
player: ScoreSaberPlayer;
|
||||||
sort: ScoreSort;
|
sort: ScoreSortType;
|
||||||
|
direction: SortDirection;
|
||||||
page: number;
|
page: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PageState = {
|
type PageState = {
|
||||||
page: number;
|
page: number;
|
||||||
sort: ScoreSort;
|
sort: ScoreSortType;
|
||||||
|
direction: SortDirection;
|
||||||
};
|
};
|
||||||
|
|
||||||
const scoreSort = [
|
const scoreSort = [
|
||||||
{
|
{
|
||||||
name: "Top",
|
name: "PP",
|
||||||
value: ScoreSort.top,
|
value: ScoreSortType.pp,
|
||||||
icon: <TrophyIcon className="w-5 h-5" />,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Recent",
|
name: "Date",
|
||||||
value: ScoreSort.recent,
|
value: ScoreSortType.date,
|
||||||
icon: <ClockIcon className="w-5 h-5" />,
|
},
|
||||||
|
{
|
||||||
|
name: "Acc",
|
||||||
|
value: ScoreSortType.accuracy,
|
||||||
|
requiresTrackedPlayer: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Misses",
|
||||||
|
value: ScoreSortType.misses,
|
||||||
|
requiresTrackedPlayer: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function PlayerScores({ initialScoreData, initialSearch, player, sort, page }: Props) {
|
export default function PlayerScores({ initialScoreData, initialSearch, player, sort, direction, page }: Props) {
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const controls = useAnimation();
|
const controls = useAnimation();
|
||||||
|
|
||||||
const [pageState, setPageState] = useState<PageState>({ page, sort });
|
const [pageState, setPageState] = useState<PageState>({ page, sort, direction });
|
||||||
const [previousPage, setPreviousPage] = useState(page);
|
const [previousPage, setPreviousPage] = useState(page);
|
||||||
const [scores, setScores] = useState<PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined>(
|
const [scores, setScores] = useState<PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined>(
|
||||||
initialScoreData
|
initialScoreData
|
||||||
@ -69,6 +80,7 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
|
|||||||
player.id,
|
player.id,
|
||||||
pageState.page,
|
pageState.page,
|
||||||
pageState.sort,
|
pageState.sort,
|
||||||
|
pageState.direction,
|
||||||
debouncedSearchTerm
|
debouncedSearchTerm
|
||||||
),
|
),
|
||||||
enabled: shouldFetch && (debouncedSearchTerm.length >= 3 || debouncedSearchTerm.length === 0),
|
enabled: shouldFetch && (debouncedSearchTerm.length >= 3 || debouncedSearchTerm.length === 0),
|
||||||
@ -88,14 +100,30 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
|
|||||||
*
|
*
|
||||||
* @param newSort the new sort
|
* @param newSort the new sort
|
||||||
*/
|
*/
|
||||||
const handleSortChange = async (newSort: ScoreSort) => {
|
const handleSortChange = async (newSort: ScoreSortType) => {
|
||||||
if (newSort !== pageState.sort) {
|
if (newSort !== pageState.sort) {
|
||||||
setPageState({ page: 1, sort: newSort });
|
setPageState({ ...pageState, page: 1, sort: newSort, direction: SortDirection.DESC });
|
||||||
setShouldFetch(true); // Set to true to trigger fetch
|
setShouldFetch(true); // Set to true to trigger fetch
|
||||||
await setCookieValue("lastScoreSort", newSort); // Set the default score sort
|
await setCookieValue("lastScoreSort", newSort); // Set the default score sort
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the score sort direction.
|
||||||
|
*
|
||||||
|
* @param newDirection the new sort direction
|
||||||
|
*/
|
||||||
|
const handleSortDirectionChange = async (newDirection: SortDirection) => {
|
||||||
|
// Player doesn't have scores tracked anyway, so no need to change this.
|
||||||
|
if (!player.isBeingTracked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPageState({ ...pageState, page: 1, direction: newDirection });
|
||||||
|
setShouldFetch(true); // Set to true to trigger fetch
|
||||||
|
await setCookieValue("lastScoreSortDirection", newDirection); // Set the default score sort
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change the score search term.
|
* Change the score search term.
|
||||||
*
|
*
|
||||||
@ -131,9 +159,9 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
|
|||||||
*/
|
*/
|
||||||
const getUrl = useCallback(
|
const getUrl = useCallback(
|
||||||
(page: number) => {
|
(page: number) => {
|
||||||
return `/player/${player.id}/${pageState.sort}/${page}${isSearchActive ? `?search=${debouncedSearchTerm}` : ""}`;
|
return `/player/${player.id}/${pageState.sort}/${pageState.direction}/${page}${isSearchActive ? `?search=${debouncedSearchTerm}` : ""}`;
|
||||||
},
|
},
|
||||||
[debouncedSearchTerm, player.id, pageState.sort, isSearchActive]
|
[player.id, pageState.sort, pageState.direction, isSearchActive, debouncedSearchTerm]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -167,16 +195,30 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
|
|||||||
<div ref={topOfScoresRef} className="absolute" />
|
<div ref={topOfScoresRef} className="absolute" />
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{scoreSort.map(sortOption => (
|
{scoreSort
|
||||||
|
.filter(sort => !(!player.isBeingTracked && sort.requiresTrackedPlayer))
|
||||||
|
.map(sortOption => (
|
||||||
<Button
|
<Button
|
||||||
key={sortOption.value}
|
key={sortOption.value}
|
||||||
variant={sortOption.value === pageState.sort ? "default" : "outline"}
|
variant={sortOption.value === pageState.sort ? "default" : "outline"}
|
||||||
onClick={() => handleSortChange(sortOption.value)}
|
onClick={async () => {
|
||||||
|
if (sortOption.value !== pageState.sort) {
|
||||||
|
await handleSortChange(sortOption.value);
|
||||||
|
} else {
|
||||||
|
await handleSortDirectionChange(
|
||||||
|
pageState.direction === SortDirection.ASC ? SortDirection.DESC : SortDirection.ASC
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex items-center gap-1"
|
className="flex items-center justify-center gap-1"
|
||||||
>
|
>
|
||||||
{sortOption.icon}
|
{`${capitalizeFirstLetter(sortOption.name)}`}
|
||||||
{`${capitalizeFirstLetter(sortOption.name)} Scores`}
|
{player.isBeingTracked && (
|
||||||
|
<ArrowDownIcon
|
||||||
|
className={`w-4 h-4 ${pageState.direction === SortDirection.ASC && pageState.sort === sortOption.value ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -223,7 +265,7 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
|
|||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{scores.metadata.totalPages > 1 && (
|
{scores.metadata.totalPages >= 1 && (
|
||||||
<Pagination
|
<Pagination
|
||||||
mobilePagination={width < 768}
|
mobilePagination={width < 768}
|
||||||
page={pageState.page}
|
page={pageState.page}
|
||||||
|
Reference in New Issue
Block a user