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
|
||||
*/
|
||||
export async function logToChannel(channelId: DiscordChannels, message: EmbedBuilder) {
|
||||
const channel = await client.channels.fetch(channelId);
|
||||
if (channel == undefined) {
|
||||
throw new Error(`Channel "${channelId}" not found`);
|
||||
}
|
||||
if (!channel.isSendable()) {
|
||||
throw new Error(`Channel "${channelId}" is not sendable`);
|
||||
}
|
||||
try {
|
||||
const channel = await client.channels.fetch(channelId);
|
||||
if (channel == undefined) {
|
||||
throw new Error(`Channel "${channelId}" not found`);
|
||||
}
|
||||
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> {
|
||||
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 { ScoreType } from "@ssr/common/model/score/score";
|
||||
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 ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token";
|
||||
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
||||
import { MapDifficulty } from "@ssr/common/score/map-difficulty";
|
||||
import { MapCharacteristic } from "@ssr/common/types/map-characteristic";
|
||||
import { Page, Pagination } from "@ssr/common/pagination";
|
||||
|
||||
const playerScoresCache = new SSRCache({
|
||||
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.
|
||||
*/
|
||||
@prop()
|
||||
private _id?: number;
|
||||
public _id?: number;
|
||||
|
||||
/**
|
||||
* 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 { ScoreSort } from "../score/score-sort";
|
||||
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
|
||||
|
@ -21,6 +21,8 @@ type Props = {
|
||||
leaderboard: ScoreSaberLeaderboard;
|
||||
beatSaverMap?: BeatSaverMap;
|
||||
alwaysSingleLine?: boolean;
|
||||
hideLeaderboardDropdown?: boolean;
|
||||
hideAccuracyChanger?: boolean;
|
||||
isLeaderboardLoading?: boolean;
|
||||
setIsLeaderboardExpanded?: (isExpanded: boolean) => void;
|
||||
updateScore?: (score: ScoreSaberScore) => void;
|
||||
@ -31,6 +33,8 @@ export default function ScoreButtons({
|
||||
leaderboard,
|
||||
beatSaverMap,
|
||||
alwaysSingleLine,
|
||||
hideLeaderboardDropdown,
|
||||
hideAccuracyChanger,
|
||||
isLeaderboardLoading,
|
||||
setIsLeaderboardExpanded,
|
||||
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`}
|
||||
>
|
||||
{/* Edit score button */}
|
||||
{score && leaderboard && updateScore && (
|
||||
{score && leaderboard && updateScore && !hideAccuracyChanger && (
|
||||
<ScoreEditorButton score={score} leaderboard={leaderboard} updateScore={updateScore} />
|
||||
)}
|
||||
|
||||
{/* View Leaderboard button */}
|
||||
{leaderboardExpanded != undefined && setIsLeaderboardExpanded != undefined && (
|
||||
{leaderboardExpanded != undefined && setIsLeaderboardExpanded != undefined && !hideLeaderboardDropdown && (
|
||||
<div className="flex items-center justify-center cursor-default">
|
||||
{isLeaderboardLoading ? (
|
||||
<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";
|
||||
|
||||
import LeaderboardScores from "@/components/leaderboard/leaderboard-scores";
|
||||
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 ScoreSongInfo from "./score-song-info";
|
||||
import ScoreRankInfo from "./score-rank-info";
|
||||
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 { fetchLeaderboardScores } from "@ssr/common/utils/score-utils";
|
||||
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 ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||
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 { ScoreStatsToken } from "@ssr/common/types/token/beatleader/score-stats/score-stats";
|
||||
import { beatLeaderService } from "@ssr/common/service/impl/beatleader";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The score to display.
|
||||
*/
|
||||
score: ScoreSaberScore;
|
||||
|
||||
/**
|
||||
* The leaderboard.
|
||||
*/
|
||||
leaderboard: ScoreSaberLeaderboard;
|
||||
|
||||
/**
|
||||
* The beat saver map for this song.
|
||||
*/
|
||||
beatSaverMap?: BeatSaverMap;
|
||||
|
||||
/**
|
||||
* Score settings
|
||||
*/
|
||||
settings?: {
|
||||
noScoreButtons: boolean;
|
||||
noScoreButtons?: boolean;
|
||||
hideLeaderboardDropdown?: boolean;
|
||||
hideAccuracyChanger?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type LeaderboardDropdownData = {
|
||||
/**
|
||||
* The initial scores.
|
||||
*/
|
||||
scores?: LeaderboardScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
|
||||
|
||||
/**
|
||||
* The score stats for this score,
|
||||
*/
|
||||
scoreStats?: ScoreStatsToken;
|
||||
};
|
||||
|
||||
export default function Score({ leaderboard, beatSaverMap, score, settings }: Props) {
|
||||
const scoresPage = getPageFromRank(score.rank, 12);
|
||||
type Mode = {
|
||||
name: string;
|
||||
icon: React.ReactElement;
|
||||
};
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const [baseScore, setBaseScore] = useState<number>(score.score);
|
||||
const modes: Mode[] = [
|
||||
{ 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 [loading, setLoading] = useState(false);
|
||||
const [leaderboardDropdownData, setLeaderboardDropdownData] = useState<LeaderboardDropdownData | undefined>();
|
||||
const [selectedMode, setSelectedMode] = useState<Mode>(modes[0]);
|
||||
|
||||
const { data, isError, isLoading } = useQuery<LeaderboardDropdownData>({
|
||||
queryKey: ["leaderboardDropdownData", leaderboard.id, score.scoreId, isLeaderboardExpanded],
|
||||
const scoresPage = getPageFromRank(score.rank, 12);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const { data, isLoading } = useQuery<LeaderboardDropdownData>({
|
||||
queryKey: [`leaderboardDropdownData:${leaderboard.id}`, leaderboard.id, score.scoreId, isLeaderboardExpanded],
|
||||
queryFn: async () => {
|
||||
const scores = await fetchLeaderboardScores<ScoreSaberScore, ScoreSaberLeaderboard>(
|
||||
"scoresaber",
|
||||
leaderboard.id + "",
|
||||
leaderboard.id.toString(),
|
||||
scoresPage
|
||||
);
|
||||
const scoreStats = score.additionalData
|
||||
? await beatLeaderService.lookupScoreStats(score.additionalData.scoreId)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
scores: scores,
|
||||
scoreStats: scoreStats,
|
||||
};
|
||||
return { scores, scoreStats };
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
staleTime: 30000,
|
||||
enabled: loading,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setLeaderboardDropdownData({
|
||||
...data,
|
||||
scores: data.scores,
|
||||
scoreStats: data.scoreStats,
|
||||
});
|
||||
setLeaderboardDropdownData(data);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [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) => {
|
||||
if (!isExpanded) {
|
||||
setLeaderboardDropdownData(undefined);
|
||||
@ -108,63 +108,35 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr
|
||||
setIsLeaderboardExpanded(isExpanded);
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the base score
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (score?.score) {
|
||||
setBaseScore(score.score);
|
||||
}
|
||||
}, [score]);
|
||||
const handleModeChange = (mode: Mode) => {
|
||||
setSelectedMode(mode);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
? "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_1fr_300px]"; // Original with 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]";
|
||||
|
||||
return (
|
||||
<div className="pb-2 pt-2">
|
||||
{/* Score Info */}
|
||||
<div className={`grid w-full gap-2 lg:gap-0 ${gridColsClass}`}>
|
||||
<ScoreRankInfo score={score} leaderboard={leaderboard} />
|
||||
<ScoreSongInfo leaderboard={leaderboard} beatSaverMap={beatSaverMap} />
|
||||
{settings?.noScoreButtons !== true && (
|
||||
{!settings?.noScoreButtons && (
|
||||
<ScoreButtons
|
||||
leaderboard={leaderboard}
|
||||
beatSaverMap={beatSaverMap}
|
||||
score={score}
|
||||
alwaysSingleLine={isMobile}
|
||||
setIsLeaderboardExpanded={(isExpanded: boolean) => {
|
||||
handleLeaderboardOpen(isExpanded);
|
||||
}}
|
||||
hideLeaderboardDropdown={settings?.hideLeaderboardDropdown}
|
||||
hideAccuracyChanger={settings?.hideAccuracyChanger}
|
||||
setIsLeaderboardExpanded={handleLeaderboardOpen}
|
||||
isLeaderboardLoading={isLoading}
|
||||
updateScore={score => {
|
||||
setBaseScore(score.score);
|
||||
}}
|
||||
updateScore={updatedScore => setBaseScore(updatedScore.score)}
|
||||
/>
|
||||
)}
|
||||
<ScoreStats
|
||||
score={{
|
||||
...score,
|
||||
accuracy: accuracy ? accuracy : score.accuracy,
|
||||
pp: pp ? pp : score.pp,
|
||||
}}
|
||||
leaderboard={leaderboard}
|
||||
/>
|
||||
<ScoreStats score={{ ...score, accuracy, pp }} leaderboard={leaderboard} />
|
||||
</div>
|
||||
|
||||
{/* Leaderboard */}
|
||||
{isLeaderboardExpanded && leaderboardDropdownData && !loading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
@ -173,20 +145,38 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr
|
||||
className="w-full mt-2"
|
||||
>
|
||||
<Card className="flex gap-4 w-full relative border border-input">
|
||||
<MapStats leaderboard={leaderboard} beatSaver={beatSaverMap} />
|
||||
|
||||
{leaderboardDropdownData.scoreStats && (
|
||||
<div className="flex gap-2">
|
||||
<PlayerScoreAccuracyChart scoreStats={leaderboardDropdownData.scoreStats} />
|
||||
<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">
|
||||
{modes.map((mode, i) => (
|
||||
<Button
|
||||
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>
|
||||
<MapStats leaderboard={leaderboard} beatSaver={beatSaverMap} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedMode.name === "Overview" && (
|
||||
<ScoreOverview
|
||||
scores={leaderboardDropdownData.scores}
|
||||
leaderboard={leaderboard}
|
||||
initialPage={scoresPage}
|
||||
scoreStats={leaderboardDropdownData.scoreStats}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LeaderboardScores
|
||||
initialPage={scoresPage}
|
||||
initialScores={leaderboardDropdownData.scores}
|
||||
leaderboard={leaderboard}
|
||||
disableUrlChanging
|
||||
/>
|
||||
{selectedMode.name === "Score History" && (
|
||||
<ScoreHistory playerId={score.playerId} leaderboard={leaderboard} />
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
Reference in New Issue
Block a user