add score history viewing
This commit is contained in:
parent
9fb5317bc8
commit
97fba47fd8
@ -54,13 +54,17 @@ export async function initDiscordBot() {
|
|||||||
* @param message the message to log
|
* @param message the message to log
|
||||||
*/
|
*/
|
||||||
export async function logToChannel(channelId: DiscordChannels, message: EmbedBuilder) {
|
export async function logToChannel(channelId: DiscordChannels, message: EmbedBuilder) {
|
||||||
const channel = await client.channels.fetch(channelId);
|
try {
|
||||||
if (channel == undefined) {
|
const channel = await client.channels.fetch(channelId);
|
||||||
throw new Error(`Channel "${channelId}" not found`);
|
if (channel == undefined) {
|
||||||
}
|
throw new Error(`Channel "${channelId}" not found`);
|
||||||
if (!channel.isSendable()) {
|
}
|
||||||
throw new Error(`Channel "${channelId}" is not sendable`);
|
if (!channel.isSendable()) {
|
||||||
}
|
throw new Error(`Channel "${channelId}" is not sendable`);
|
||||||
|
}
|
||||||
|
|
||||||
channel.send({ embeds: [message] });
|
channel.send({ embeds: [message] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -52,4 +52,25 @@ export default class ScoresController {
|
|||||||
}): Promise<unknown> {
|
}): Promise<unknown> {
|
||||||
return await ScoreService.getLeaderboardScores(leaderboard, id, page);
|
return await ScoreService.getLeaderboardScores(leaderboard, id, page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("/history/:playerId/:leaderboardId/:page", {
|
||||||
|
config: {},
|
||||||
|
params: t.Object({
|
||||||
|
playerId: t.String({ required: true }),
|
||||||
|
leaderboardId: t.String({ required: true }),
|
||||||
|
page: t.Number({ required: true }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
public async getScoreHistory({
|
||||||
|
params: { playerId, leaderboardId, page },
|
||||||
|
}: {
|
||||||
|
params: {
|
||||||
|
playerId: string;
|
||||||
|
leaderboardId: string;
|
||||||
|
page: number;
|
||||||
|
};
|
||||||
|
query: { search?: string };
|
||||||
|
}): Promise<unknown> {
|
||||||
|
return (await ScoreService.getPreviousScores(playerId, leaderboardId, page)).toJSON();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -27,12 +27,13 @@ import {
|
|||||||
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 { 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, 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 ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-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";
|
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
||||||
import { MapDifficulty } from "@ssr/common/score/map-difficulty";
|
import { MapDifficulty } from "@ssr/common/score/map-difficulty";
|
||||||
import { MapCharacteristic } from "@ssr/common/types/map-characteristic";
|
import { MapCharacteristic } from "@ssr/common/types/map-characteristic";
|
||||||
|
import { Page, Pagination } from "@ssr/common/pagination";
|
||||||
|
|
||||||
const playerScoresCache = new SSRCache({
|
const playerScoresCache = new SSRCache({
|
||||||
ttl: 1000 * 60, // 1 minute
|
ttl: 1000 * 60, // 1 minute
|
||||||
@ -487,4 +488,59 @@ export class ScoreService {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the previous scores for a player.
|
||||||
|
*
|
||||||
|
* @param playerId the player's id to get the previous scores for
|
||||||
|
* @param leaderboardId the leaderboard to get the previous scores on
|
||||||
|
* @param page the page to get
|
||||||
|
*/
|
||||||
|
public static async getPreviousScores(
|
||||||
|
playerId: string,
|
||||||
|
leaderboardId: string,
|
||||||
|
page: number
|
||||||
|
): Promise<Page<PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>>> {
|
||||||
|
const scores = await ScoreSaberScoreModel.find({ playerId: playerId, leaderboardId: leaderboardId })
|
||||||
|
.sort({ timestamp: -1 })
|
||||||
|
.skip(1);
|
||||||
|
if (scores == null || scores.length == 0) {
|
||||||
|
throw new NotFoundError(`No previous scores found for ${playerId} in ${leaderboardId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Pagination<PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>>()
|
||||||
|
.setItemsPerPage(8)
|
||||||
|
.setTotalItems(scores.length)
|
||||||
|
.getPage(page, async () => {
|
||||||
|
const toReturn: PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>[] = [];
|
||||||
|
for (const score of scores) {
|
||||||
|
const leaderboardResponse = await LeaderboardService.getLeaderboard<ScoreSaberLeaderboard>(
|
||||||
|
"scoresaber",
|
||||||
|
leaderboardId
|
||||||
|
);
|
||||||
|
if (leaderboardResponse == undefined) {
|
||||||
|
throw new NotFoundError(`Leaderboard "${leaderboardId}" not found`);
|
||||||
|
}
|
||||||
|
const { leaderboard, beatsaver } = leaderboardResponse;
|
||||||
|
|
||||||
|
const additionalData = await this.getAdditionalScoreData(
|
||||||
|
playerId,
|
||||||
|
leaderboard.songHash,
|
||||||
|
`${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`,
|
||||||
|
score.score
|
||||||
|
);
|
||||||
|
if (additionalData !== undefined) {
|
||||||
|
score.additionalData = additionalData;
|
||||||
|
}
|
||||||
|
|
||||||
|
toReturn.push({
|
||||||
|
score: score as unknown as ScoreSaberScore,
|
||||||
|
leaderboard: leaderboard,
|
||||||
|
beatSaver: beatsaver,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return toReturn;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@ export default class Score {
|
|||||||
* The internal score id.
|
* The internal score id.
|
||||||
*/
|
*/
|
||||||
@prop()
|
@prop()
|
||||||
private _id?: number;
|
public _id?: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The id of the player who set the score.
|
* The id of the player who set the score.
|
||||||
|
103
projects/common/src/pagination.ts
Normal file
103
projects/common/src/pagination.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { NotFoundError } from "backend/src/error/not-found-error";
|
||||||
|
import { Metadata } from "./types/metadata";
|
||||||
|
|
||||||
|
type FetchItemsFunction<T> = (fetchItems: FetchItems) => Promise<T[]>;
|
||||||
|
|
||||||
|
export class Pagination<T> {
|
||||||
|
private itemsPerPage: number = 0;
|
||||||
|
private totalItems: number = 0;
|
||||||
|
private items: T[] | null = null; // Optional array to hold set items
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the total number of items.
|
||||||
|
* @param totalItems - Total number of items.
|
||||||
|
* @returns the pagination
|
||||||
|
*/
|
||||||
|
setTotalItems(totalItems: number): Pagination<T> {
|
||||||
|
this.totalItems = totalItems;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a page of items, using either static items or a dynamic fetchItems callback.
|
||||||
|
* @param page - The page number to retrieve.
|
||||||
|
* @param fetchItems - The async function to fetch items if setItems was not used.
|
||||||
|
* @returns A promise resolving to the page of items.
|
||||||
|
* @throws throws an error if the page number is invalid.
|
||||||
|
*/
|
||||||
|
async getPage(page: number, fetchItems?: FetchItemsFunction<T>): Promise<Page<T>> {
|
||||||
|
const totalPages = Math.ceil(this.totalItems / this.itemsPerPage);
|
||||||
|
|
||||||
|
if (page < 1 || page > totalPages) {
|
||||||
|
throw new NotFoundError("Invalid page number");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the range of items to fetch for the current page
|
||||||
|
const start = (page - 1) * this.itemsPerPage;
|
||||||
|
const end = start + this.itemsPerPage;
|
||||||
|
|
||||||
|
let pageItems: T[];
|
||||||
|
|
||||||
|
// Use set items if they are present, otherwise use fetchItems callback
|
||||||
|
if (this.items) {
|
||||||
|
pageItems = this.items.slice(start, end);
|
||||||
|
} else if (fetchItems) {
|
||||||
|
pageItems = await fetchItems(new FetchItems(start, end));
|
||||||
|
} else {
|
||||||
|
throw new Error("Items function is not set and no fetchItems callback provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Page<T>(pageItems, new Metadata(totalPages, this.totalItems, page, this.itemsPerPage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FetchItems {
|
||||||
|
readonly start: number;
|
||||||
|
readonly end: number;
|
||||||
|
|
||||||
|
constructor(start: number, end: number) {
|
||||||
|
this.start = start;
|
||||||
|
this.end = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Page<T> {
|
||||||
|
readonly items: T[];
|
||||||
|
readonly metadata: Metadata;
|
||||||
|
|
||||||
|
constructor(items: T[], metadata: Metadata) {
|
||||||
|
this.items = items;
|
||||||
|
this.metadata = metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts the page to a JSON object.
|
||||||
|
*/
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
items: this.items,
|
||||||
|
metadata: this.metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -4,6 +4,23 @@ 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 { Page } from "../pagination";
|
||||||
|
import { ScoreSaberScore } from "src/model/score/impl/scoresaber-score";
|
||||||
|
import { PlayerScore } from "../score/player-score";
|
||||||
|
import ScoreSaberLeaderboard from "../leaderboard/impl/scoresaber-leaderboard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the player's scores
|
||||||
|
*
|
||||||
|
* @param playerId the id of the player
|
||||||
|
* @param leaderboardId the id of the leaderboard
|
||||||
|
* @param page the page
|
||||||
|
*/
|
||||||
|
export async function fetchPlayerScoresHistory(playerId: string, leaderboardId: string, page: number) {
|
||||||
|
return kyFetch<Page<PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>>>(
|
||||||
|
`${Config.apiUrl}/scores/history/${playerId}/${leaderboardId}/${page}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the player's scores
|
* Fetches the player's scores
|
||||||
|
@ -21,6 +21,8 @@ type Props = {
|
|||||||
leaderboard: ScoreSaberLeaderboard;
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
beatSaverMap?: BeatSaverMap;
|
beatSaverMap?: BeatSaverMap;
|
||||||
alwaysSingleLine?: boolean;
|
alwaysSingleLine?: boolean;
|
||||||
|
hideLeaderboardDropdown?: boolean;
|
||||||
|
hideAccuracyChanger?: boolean;
|
||||||
isLeaderboardLoading?: boolean;
|
isLeaderboardLoading?: boolean;
|
||||||
setIsLeaderboardExpanded?: (isExpanded: boolean) => void;
|
setIsLeaderboardExpanded?: (isExpanded: boolean) => void;
|
||||||
updateScore?: (score: ScoreSaberScore) => void;
|
updateScore?: (score: ScoreSaberScore) => void;
|
||||||
@ -31,6 +33,8 @@ export default function ScoreButtons({
|
|||||||
leaderboard,
|
leaderboard,
|
||||||
beatSaverMap,
|
beatSaverMap,
|
||||||
alwaysSingleLine,
|
alwaysSingleLine,
|
||||||
|
hideLeaderboardDropdown,
|
||||||
|
hideAccuracyChanger,
|
||||||
isLeaderboardLoading,
|
isLeaderboardLoading,
|
||||||
setIsLeaderboardExpanded,
|
setIsLeaderboardExpanded,
|
||||||
updateScore,
|
updateScore,
|
||||||
@ -103,12 +107,12 @@ export default function ScoreButtons({
|
|||||||
className={`flex gap-2 ${alwaysSingleLine ? "flex-row" : "flex-row lg:flex-col"} items-center justify-center`}
|
className={`flex gap-2 ${alwaysSingleLine ? "flex-row" : "flex-row lg:flex-col"} items-center justify-center`}
|
||||||
>
|
>
|
||||||
{/* Edit score button */}
|
{/* Edit score button */}
|
||||||
{score && leaderboard && updateScore && (
|
{score && leaderboard && updateScore && !hideAccuracyChanger && (
|
||||||
<ScoreEditorButton score={score} leaderboard={leaderboard} updateScore={updateScore} />
|
<ScoreEditorButton score={score} leaderboard={leaderboard} updateScore={updateScore} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* View Leaderboard button */}
|
{/* View Leaderboard button */}
|
||||||
{leaderboardExpanded != undefined && setIsLeaderboardExpanded != undefined && (
|
{leaderboardExpanded != undefined && setIsLeaderboardExpanded != undefined && !hideLeaderboardDropdown && (
|
||||||
<div className="flex items-center justify-center cursor-default">
|
<div className="flex items-center justify-center cursor-default">
|
||||||
{isLeaderboardLoading ? (
|
{isLeaderboardLoading ? (
|
||||||
<ArrowPathIcon className="w-5 h-5 animate-spin" />
|
<ArrowPathIcon className="w-5 h-5 animate-spin" />
|
||||||
|
@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
|
import Score from "@/components/score/score";
|
||||||
|
import { fetchPlayerScoresHistory } from "@ssr/common/utils/score-utils";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import Pagination from "@/components/input/pagination";
|
||||||
|
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||||
|
|
||||||
|
type ScoreHistoryProps = {
|
||||||
|
/**
|
||||||
|
* The player who set this score.
|
||||||
|
*/
|
||||||
|
playerId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The leaderboard the score was set on.
|
||||||
|
*/
|
||||||
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ScoreHistory({ playerId, leaderboard }: ScoreHistoryProps) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const { data, isError, isLoading } = useQuery({
|
||||||
|
queryKey: [`scoresHistory:${leaderboard.id}`, leaderboard.id, page],
|
||||||
|
queryFn: async () => fetchPlayerScoresHistory(playerId, leaderboard.id + "", page),
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data || isError) {
|
||||||
|
return <p className="text-center">No score history found.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{data.items.map(({ score, leaderboard, beatSaver }) => (
|
||||||
|
<Score
|
||||||
|
key={score.scoreId}
|
||||||
|
score={score}
|
||||||
|
leaderboard={leaderboard}
|
||||||
|
beatSaverMap={beatSaver}
|
||||||
|
settings={{
|
||||||
|
hideLeaderboardDropdown: true,
|
||||||
|
hideAccuracyChanger: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
mobilePagination={isMobile}
|
||||||
|
page={page}
|
||||||
|
totalPages={data.metadata.totalPages}
|
||||||
|
loadingPage={isLoading ? page : undefined}
|
||||||
|
onPageChange={newPage => {
|
||||||
|
setPage(newPage);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
import PlayerScoreAccuracyChart from "@/components/leaderboard/chart/player-score-accuracy-chart";
|
||||||
|
import LeaderboardScores from "@/components/leaderboard/leaderboard-scores";
|
||||||
|
import { ScoreStatsToken } from "@ssr/common/types/token/beatleader/score-stats/score-stats";
|
||||||
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
|
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
|
||||||
|
import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score";
|
||||||
|
|
||||||
|
type ScoreOverviewProps = {
|
||||||
|
/**
|
||||||
|
* The initial page to show
|
||||||
|
*/
|
||||||
|
initialPage: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The score stats for this score.
|
||||||
|
*/
|
||||||
|
scoreStats?: ScoreStatsToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The leaderboard the score was set on.
|
||||||
|
*/
|
||||||
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scores so show.
|
||||||
|
*/
|
||||||
|
scores?: LeaderboardScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ScoreOverview({ scoreStats, initialPage, leaderboard, scores }: ScoreOverviewProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{scoreStats && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<PlayerScoreAccuracyChart scoreStats={scoreStats} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LeaderboardScores
|
||||||
|
initialPage={initialPage}
|
||||||
|
initialScores={scores}
|
||||||
|
leaderboard={leaderboard}
|
||||||
|
disableUrlChanging
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -1,104 +1,104 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import LeaderboardScores from "@/components/leaderboard/leaderboard-scores";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { CubeIcon } from "@heroicons/react/24/solid";
|
||||||
|
import { GitGraph } from "lucide-react";
|
||||||
import ScoreButtons from "./score-buttons";
|
import ScoreButtons from "./score-buttons";
|
||||||
import ScoreSongInfo from "./score-song-info";
|
import ScoreSongInfo from "./score-song-info";
|
||||||
import ScoreRankInfo from "./score-rank-info";
|
import ScoreRankInfo from "./score-rank-info";
|
||||||
import ScoreStats from "./score-stats";
|
import ScoreStats from "./score-stats";
|
||||||
import { motion } from "framer-motion";
|
import Card from "@/components/card";
|
||||||
|
import { MapStats } from "@/components/score/map-stats";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScoreOverview } from "@/components/score/score-views/score-overview";
|
||||||
|
import { ScoreHistory } from "@/components/score/score-views/score-history";
|
||||||
|
|
||||||
import { getPageFromRank } from "@ssr/common/utils/utils";
|
import { getPageFromRank } from "@ssr/common/utils/utils";
|
||||||
|
import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils";
|
||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||||
|
import { beatLeaderService } from "@ssr/common/service/impl/beatleader";
|
||||||
|
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||||
|
|
||||||
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 { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
|
import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
|
||||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
|
||||||
import Card from "@/components/card";
|
|
||||||
import { MapStats } from "@/components/score/map-stats";
|
|
||||||
import PlayerScoreAccuracyChart from "@/components/leaderboard/chart/player-score-accuracy-chart";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils";
|
|
||||||
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
|
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
|
||||||
import { ScoreStatsToken } from "@ssr/common/types/token/beatleader/score-stats/score-stats";
|
import { ScoreStatsToken } from "@ssr/common/types/token/beatleader/score-stats/score-stats";
|
||||||
import { beatLeaderService } from "@ssr/common/service/impl/beatleader";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
|
||||||
* The score to display.
|
|
||||||
*/
|
|
||||||
score: ScoreSaberScore;
|
score: ScoreSaberScore;
|
||||||
|
|
||||||
/**
|
|
||||||
* The leaderboard.
|
|
||||||
*/
|
|
||||||
leaderboard: ScoreSaberLeaderboard;
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
|
|
||||||
/**
|
|
||||||
* The beat saver map for this song.
|
|
||||||
*/
|
|
||||||
beatSaverMap?: BeatSaverMap;
|
beatSaverMap?: BeatSaverMap;
|
||||||
|
|
||||||
/**
|
|
||||||
* Score settings
|
|
||||||
*/
|
|
||||||
settings?: {
|
settings?: {
|
||||||
noScoreButtons: boolean;
|
noScoreButtons?: boolean;
|
||||||
|
hideLeaderboardDropdown?: boolean;
|
||||||
|
hideAccuracyChanger?: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type LeaderboardDropdownData = {
|
type LeaderboardDropdownData = {
|
||||||
/**
|
|
||||||
* The initial scores.
|
|
||||||
*/
|
|
||||||
scores?: LeaderboardScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
|
scores?: LeaderboardScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
|
||||||
|
|
||||||
/**
|
|
||||||
* The score stats for this score,
|
|
||||||
*/
|
|
||||||
scoreStats?: ScoreStatsToken;
|
scoreStats?: ScoreStatsToken;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Score({ leaderboard, beatSaverMap, score, settings }: Props) {
|
type Mode = {
|
||||||
const scoresPage = getPageFromRank(score.rank, 12);
|
name: string;
|
||||||
|
icon: React.ReactElement;
|
||||||
|
};
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const modes: Mode[] = [
|
||||||
const [baseScore, setBaseScore] = useState<number>(score.score);
|
{ name: "Overview", icon: <CubeIcon className="w-4 h-4" /> },
|
||||||
|
{ name: "Score History", icon: <GitGraph className="w-4 h-4" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Score({ leaderboard, beatSaverMap, score, settings }: Props) {
|
||||||
|
const [baseScore, setBaseScore] = useState(score.score);
|
||||||
const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false);
|
const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [leaderboardDropdownData, setLeaderboardDropdownData] = useState<LeaderboardDropdownData | undefined>();
|
const [leaderboardDropdownData, setLeaderboardDropdownData] = useState<LeaderboardDropdownData | undefined>();
|
||||||
|
const [selectedMode, setSelectedMode] = useState<Mode>(modes[0]);
|
||||||
|
|
||||||
const { data, isError, isLoading } = useQuery<LeaderboardDropdownData>({
|
const scoresPage = getPageFromRank(score.rank, 12);
|
||||||
queryKey: ["leaderboardDropdownData", leaderboard.id, score.scoreId, isLeaderboardExpanded],
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<LeaderboardDropdownData>({
|
||||||
|
queryKey: [`leaderboardDropdownData:${leaderboard.id}`, leaderboard.id, score.scoreId, isLeaderboardExpanded],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const scores = await fetchLeaderboardScores<ScoreSaberScore, ScoreSaberLeaderboard>(
|
const scores = await fetchLeaderboardScores<ScoreSaberScore, ScoreSaberLeaderboard>(
|
||||||
"scoresaber",
|
"scoresaber",
|
||||||
leaderboard.id + "",
|
leaderboard.id.toString(),
|
||||||
scoresPage
|
scoresPage
|
||||||
);
|
);
|
||||||
const scoreStats = score.additionalData
|
const scoreStats = score.additionalData
|
||||||
? await beatLeaderService.lookupScoreStats(score.additionalData.scoreId)
|
? await beatLeaderService.lookupScoreStats(score.additionalData.scoreId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
return { scores, scoreStats };
|
||||||
return {
|
|
||||||
scores: scores,
|
|
||||||
scoreStats: scoreStats,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
staleTime: 30 * 1000,
|
staleTime: 30000,
|
||||||
enabled: loading,
|
enabled: loading,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
setLeaderboardDropdownData({
|
setLeaderboardDropdownData(data);
|
||||||
...data,
|
|
||||||
scores: data.scores,
|
|
||||||
scoreStats: data.scoreStats,
|
|
||||||
});
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLeaderboardExpanded(false);
|
||||||
|
setLeaderboardDropdownData(undefined);
|
||||||
|
}, [score.scoreId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBaseScore(score.score);
|
||||||
|
}, [score]);
|
||||||
|
|
||||||
|
const accuracy = (baseScore / leaderboard.maxScore) * 100;
|
||||||
|
const pp = baseScore === score.score ? score.pp : scoresaberService.getPp(leaderboard.stars, accuracy);
|
||||||
|
|
||||||
const handleLeaderboardOpen = (isExpanded: boolean) => {
|
const handleLeaderboardOpen = (isExpanded: boolean) => {
|
||||||
if (!isExpanded) {
|
if (!isExpanded) {
|
||||||
setLeaderboardDropdownData(undefined);
|
setLeaderboardDropdownData(undefined);
|
||||||
@ -108,63 +108,35 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr
|
|||||||
setIsLeaderboardExpanded(isExpanded);
|
setIsLeaderboardExpanded(isExpanded);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const handleModeChange = (mode: Mode) => {
|
||||||
* Set the base score
|
setSelectedMode(mode);
|
||||||
*/
|
};
|
||||||
useEffect(() => {
|
|
||||||
if (score?.score) {
|
|
||||||
setBaseScore(score.score);
|
|
||||||
}
|
|
||||||
}, [score]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the leaderboard when the score changes
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
setIsLeaderboardExpanded(false);
|
|
||||||
setLeaderboardDropdownData(undefined);
|
|
||||||
}, [score.scoreId]);
|
|
||||||
|
|
||||||
const accuracy = (baseScore / leaderboard.maxScore) * 100;
|
|
||||||
const pp = baseScore === score.score ? score.pp : scoresaberService.getPp(leaderboard.stars, accuracy);
|
|
||||||
|
|
||||||
// Dynamic grid column classes
|
|
||||||
const gridColsClass = settings?.noScoreButtons
|
const gridColsClass = settings?.noScoreButtons
|
||||||
? "grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_300px]" // Fewer columns if no buttons
|
? "grid-cols-[20px_1fr_1fr] lg:grid-cols-[0.5fr_4fr_300px]"
|
||||||
: "grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_1fr_300px]"; // Original with buttons
|
: "grid-cols-[20px_1fr_1fr] lg:grid-cols-[0.5fr_4fr_1fr_300px]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-2 pt-2">
|
<div className="pb-2 pt-2">
|
||||||
{/* Score Info */}
|
|
||||||
<div className={`grid w-full gap-2 lg:gap-0 ${gridColsClass}`}>
|
<div className={`grid w-full gap-2 lg:gap-0 ${gridColsClass}`}>
|
||||||
<ScoreRankInfo score={score} leaderboard={leaderboard} />
|
<ScoreRankInfo score={score} leaderboard={leaderboard} />
|
||||||
<ScoreSongInfo leaderboard={leaderboard} beatSaverMap={beatSaverMap} />
|
<ScoreSongInfo leaderboard={leaderboard} beatSaverMap={beatSaverMap} />
|
||||||
{settings?.noScoreButtons !== true && (
|
{!settings?.noScoreButtons && (
|
||||||
<ScoreButtons
|
<ScoreButtons
|
||||||
leaderboard={leaderboard}
|
leaderboard={leaderboard}
|
||||||
beatSaverMap={beatSaverMap}
|
beatSaverMap={beatSaverMap}
|
||||||
score={score}
|
score={score}
|
||||||
alwaysSingleLine={isMobile}
|
alwaysSingleLine={isMobile}
|
||||||
setIsLeaderboardExpanded={(isExpanded: boolean) => {
|
hideLeaderboardDropdown={settings?.hideLeaderboardDropdown}
|
||||||
handleLeaderboardOpen(isExpanded);
|
hideAccuracyChanger={settings?.hideAccuracyChanger}
|
||||||
}}
|
setIsLeaderboardExpanded={handleLeaderboardOpen}
|
||||||
isLeaderboardLoading={isLoading}
|
isLeaderboardLoading={isLoading}
|
||||||
updateScore={score => {
|
updateScore={updatedScore => setBaseScore(updatedScore.score)}
|
||||||
setBaseScore(score.score);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ScoreStats
|
<ScoreStats score={{ ...score, accuracy, pp }} leaderboard={leaderboard} />
|
||||||
score={{
|
|
||||||
...score,
|
|
||||||
accuracy: accuracy ? accuracy : score.accuracy,
|
|
||||||
pp: pp ? pp : score.pp,
|
|
||||||
}}
|
|
||||||
leaderboard={leaderboard}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Leaderboard */}
|
|
||||||
{isLeaderboardExpanded && leaderboardDropdownData && !loading && (
|
{isLeaderboardExpanded && leaderboardDropdownData && !loading && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -50 }}
|
initial={{ opacity: 0, y: -50 }}
|
||||||
@ -173,20 +145,38 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr
|
|||||||
className="w-full mt-2"
|
className="w-full mt-2"
|
||||||
>
|
>
|
||||||
<Card className="flex gap-4 w-full relative border border-input">
|
<Card className="flex gap-4 w-full relative border border-input">
|
||||||
<MapStats leaderboard={leaderboard} beatSaver={beatSaverMap} />
|
<div className="flex flex-col lg:flex-row w-full gap-2 justify-center">
|
||||||
|
<div className="flex clex-col justify-center lg:justify-start gap-2">
|
||||||
{leaderboardDropdownData.scoreStats && (
|
{modes.map((mode, i) => (
|
||||||
<div className="flex gap-2">
|
<Button
|
||||||
<PlayerScoreAccuracyChart scoreStats={leaderboardDropdownData.scoreStats} />
|
key={i}
|
||||||
|
variant={mode.name === selectedMode.name ? "default" : "outline"}
|
||||||
|
onClick={() => handleModeChange(mode)}
|
||||||
|
className="flex gap-2"
|
||||||
|
>
|
||||||
|
{mode.icon}
|
||||||
|
<p>{mode.name}</p>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<MapStats leaderboard={leaderboard} beatSaver={beatSaverMap} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedMode.name === "Overview" && (
|
||||||
|
<ScoreOverview
|
||||||
|
scores={leaderboardDropdownData.scores}
|
||||||
|
leaderboard={leaderboard}
|
||||||
|
initialPage={scoresPage}
|
||||||
|
scoreStats={leaderboardDropdownData.scoreStats}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<LeaderboardScores
|
{selectedMode.name === "Score History" && (
|
||||||
initialPage={scoresPage}
|
<ScoreHistory playerId={score.playerId} leaderboard={leaderboard} />
|
||||||
initialScores={leaderboardDropdownData.scores}
|
)}
|
||||||
leaderboard={leaderboard}
|
|
||||||
disableUrlChanging
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
Reference in New Issue
Block a user