add score "edit" mode
This commit is contained in:
parent
f26b997fbb
commit
98e8273c07
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
@ -6,6 +6,8 @@ import { ScoreSort } from "../../types/score/score-sort";
|
|||||||
import ScoreSaberPlayerScoresPageToken from "../../types/token/scoresaber/score-saber-player-scores-page-token";
|
import ScoreSaberPlayerScoresPageToken from "../../types/token/scoresaber/score-saber-player-scores-page-token";
|
||||||
import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber-leaderboard-token";
|
import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber-leaderboard-token";
|
||||||
import ScoreSaberLeaderboardScoresPageToken from "../../types/token/scoresaber/score-saber-leaderboard-scores-page-token";
|
import ScoreSaberLeaderboardScoresPageToken from "../../types/token/scoresaber/score-saber-leaderboard-scores-page-token";
|
||||||
|
import { clamp, lerp } from "../../utils/math-utils";
|
||||||
|
import { CurvePoint } from "../../utils/curve-point";
|
||||||
|
|
||||||
const API_BASE = "https://scoresaber.com/api";
|
const API_BASE = "https://scoresaber.com/api";
|
||||||
|
|
||||||
@ -24,7 +26,49 @@ const LOOKUP_PLAYER_SCORES_ENDPOINT = `${API_BASE}/player/:id/scores?limit=:limi
|
|||||||
const LOOKUP_LEADERBOARD_ENDPOINT = `${API_BASE}/leaderboard/by-id/:id/info`;
|
const LOOKUP_LEADERBOARD_ENDPOINT = `${API_BASE}/leaderboard/by-id/:id/info`;
|
||||||
const LOOKUP_LEADERBOARD_SCORES_ENDPOINT = `${API_BASE}/leaderboard/by-id/:id/scores?page=:page`;
|
const LOOKUP_LEADERBOARD_SCORES_ENDPOINT = `${API_BASE}/leaderboard/by-id/:id/scores?page=:page`;
|
||||||
|
|
||||||
|
const STAR_MULTIPLIER = 42.117208413;
|
||||||
|
|
||||||
class ScoreSaberService extends Service {
|
class ScoreSaberService extends Service {
|
||||||
|
private curvePoints = [
|
||||||
|
new CurvePoint(0, 0),
|
||||||
|
new CurvePoint(0.6, 0.18223233667439062),
|
||||||
|
new CurvePoint(0.65, 0.5866010012767576),
|
||||||
|
new CurvePoint(0.7, 0.6125565959114954),
|
||||||
|
new CurvePoint(0.75, 0.6451808210101443),
|
||||||
|
new CurvePoint(0.8, 0.6872268862950283),
|
||||||
|
new CurvePoint(0.825, 0.7150465663454271),
|
||||||
|
new CurvePoint(0.85, 0.7462290664143185),
|
||||||
|
new CurvePoint(0.875, 0.7816934560296046),
|
||||||
|
new CurvePoint(0.9, 0.825756123560842),
|
||||||
|
new CurvePoint(0.91, 0.8488375988124467),
|
||||||
|
new CurvePoint(0.92, 0.8728710341448851),
|
||||||
|
new CurvePoint(0.93, 0.9039994071865736),
|
||||||
|
new CurvePoint(0.94, 0.9417362980580238),
|
||||||
|
new CurvePoint(0.95, 1),
|
||||||
|
new CurvePoint(0.955, 1.0388633331418984),
|
||||||
|
new CurvePoint(0.96, 1.0871883573850478),
|
||||||
|
new CurvePoint(0.965, 1.1552120359501035),
|
||||||
|
new CurvePoint(0.97, 1.2485807759957321),
|
||||||
|
new CurvePoint(0.9725, 1.3090333065057616),
|
||||||
|
new CurvePoint(0.975, 1.3807102743105126),
|
||||||
|
new CurvePoint(0.9775, 1.4664726399289512),
|
||||||
|
new CurvePoint(0.98, 1.5702410055532239),
|
||||||
|
new CurvePoint(0.9825, 1.697536248647543),
|
||||||
|
new CurvePoint(0.985, 1.8563887693647105),
|
||||||
|
new CurvePoint(0.9875, 2.058947159052738),
|
||||||
|
new CurvePoint(0.99, 2.324506282149922),
|
||||||
|
new CurvePoint(0.99125, 2.4902905794106913),
|
||||||
|
new CurvePoint(0.9925, 2.685667856592722),
|
||||||
|
new CurvePoint(0.99375, 2.9190155639254955),
|
||||||
|
new CurvePoint(0.995, 3.2022017597337955),
|
||||||
|
new CurvePoint(0.99625, 3.5526145337555373),
|
||||||
|
new CurvePoint(0.9975, 3.996793606763322),
|
||||||
|
new CurvePoint(0.99825, 4.325027383589547),
|
||||||
|
new CurvePoint(0.999, 4.715470646416203),
|
||||||
|
new CurvePoint(0.9995, 5.019543595874787),
|
||||||
|
new CurvePoint(1, 5.367394282890631),
|
||||||
|
];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super("ScoreSaber");
|
super("ScoreSaber");
|
||||||
}
|
}
|
||||||
@ -35,7 +79,7 @@ class ScoreSaberService extends Service {
|
|||||||
* @param query the query to search for
|
* @param query the query to search for
|
||||||
* @returns the players that match the query, or undefined if no players were found
|
* @returns the players that match the query, or undefined if no players were found
|
||||||
*/
|
*/
|
||||||
async searchPlayers(query: string): Promise<ScoreSaberPlayerSearchToken | undefined> {
|
public async searchPlayers(query: string): Promise<ScoreSaberPlayerSearchToken | undefined> {
|
||||||
const before = performance.now();
|
const before = performance.now();
|
||||||
this.log(`Searching for players matching "${query}"...`);
|
this.log(`Searching for players matching "${query}"...`);
|
||||||
const results = await this.fetch<ScoreSaberPlayerSearchToken>(SEARCH_PLAYERS_ENDPOINT.replace(":query", query));
|
const results = await this.fetch<ScoreSaberPlayerSearchToken>(SEARCH_PLAYERS_ENDPOINT.replace(":query", query));
|
||||||
@ -56,7 +100,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
|
||||||
* @returns the player that matches the ID, or undefined
|
* @returns the player that matches the ID, or undefined
|
||||||
*/
|
*/
|
||||||
async lookupPlayer(playerId: string): Promise<ScoreSaberPlayerToken | undefined> {
|
public async lookupPlayer(playerId: string): Promise<ScoreSaberPlayerToken | undefined> {
|
||||||
const before = performance.now();
|
const before = performance.now();
|
||||||
this.log(`Looking up player "${playerId}"...`);
|
this.log(`Looking up player "${playerId}"...`);
|
||||||
const token = await this.fetch<ScoreSaberPlayerToken>(LOOKUP_PLAYER_ENDPOINT.replace(":id", playerId));
|
const token = await this.fetch<ScoreSaberPlayerToken>(LOOKUP_PLAYER_ENDPOINT.replace(":id", playerId));
|
||||||
@ -73,7 +117,7 @@ class ScoreSaberService extends Service {
|
|||||||
* @param page the page to get players for
|
* @param page the page to get players for
|
||||||
* @returns the players on the page, or undefined
|
* @returns the players on the page, or undefined
|
||||||
*/
|
*/
|
||||||
async lookupPlayers(page: number): Promise<ScoreSaberPlayersPageToken | undefined> {
|
public async lookupPlayers(page: number): Promise<ScoreSaberPlayersPageToken | undefined> {
|
||||||
const before = performance.now();
|
const before = performance.now();
|
||||||
this.log(`Looking up players on page "${page}"...`);
|
this.log(`Looking up players on page "${page}"...`);
|
||||||
const response = await this.fetch<ScoreSaberPlayersPageToken>(
|
const response = await this.fetch<ScoreSaberPlayersPageToken>(
|
||||||
@ -93,7 +137,7 @@ class ScoreSaberService extends Service {
|
|||||||
* @param country the country to get players for
|
* @param country the country to get players for
|
||||||
* @returns the players on the page, or undefined
|
* @returns the players on the page, or undefined
|
||||||
*/
|
*/
|
||||||
async lookupPlayersByCountry(page: number, country: string): Promise<ScoreSaberPlayersPageToken | undefined> {
|
public async lookupPlayersByCountry(page: number, country: string): Promise<ScoreSaberPlayersPageToken | undefined> {
|
||||||
const before = performance.now();
|
const before = performance.now();
|
||||||
this.log(`Looking up players on page "${page}" for country "${country}"...`);
|
this.log(`Looking up players on page "${page}" for country "${country}"...`);
|
||||||
const response = await this.fetch<ScoreSaberPlayersPageToken>(
|
const response = await this.fetch<ScoreSaberPlayersPageToken>(
|
||||||
@ -115,7 +159,7 @@ class ScoreSaberService extends Service {
|
|||||||
* @param search
|
* @param search
|
||||||
* @returns the scores of the player, or undefined
|
* @returns the scores of the player, or undefined
|
||||||
*/
|
*/
|
||||||
async lookupPlayerScores({
|
public async lookupPlayerScores({
|
||||||
playerId,
|
playerId,
|
||||||
sort,
|
sort,
|
||||||
page,
|
page,
|
||||||
@ -151,7 +195,7 @@ class ScoreSaberService extends Service {
|
|||||||
*
|
*
|
||||||
* @param leaderboardId the ID of the leaderboard to look up
|
* @param leaderboardId the ID of the leaderboard to look up
|
||||||
*/
|
*/
|
||||||
async lookupLeaderboard(leaderboardId: string): Promise<ScoreSaberLeaderboardToken | undefined> {
|
public async lookupLeaderboard(leaderboardId: string): Promise<ScoreSaberLeaderboardToken | undefined> {
|
||||||
const before = performance.now();
|
const before = performance.now();
|
||||||
this.log(`Looking up leaderboard "${leaderboardId}"...`);
|
this.log(`Looking up leaderboard "${leaderboardId}"...`);
|
||||||
const response = await this.fetch<ScoreSaberLeaderboardToken>(
|
const response = await this.fetch<ScoreSaberLeaderboardToken>(
|
||||||
@ -171,7 +215,7 @@ class ScoreSaberService extends Service {
|
|||||||
* @param page the page to get scores for
|
* @param page the page to get scores for
|
||||||
* @returns the scores of the leaderboard, or undefined
|
* @returns the scores of the leaderboard, or undefined
|
||||||
*/
|
*/
|
||||||
async lookupLeaderboardScores(
|
public async lookupLeaderboardScores(
|
||||||
leaderboardId: string,
|
leaderboardId: string,
|
||||||
page: number
|
page: number
|
||||||
): Promise<ScoreSaberLeaderboardScoresPageToken | undefined> {
|
): Promise<ScoreSaberLeaderboardScoresPageToken | undefined> {
|
||||||
@ -188,6 +232,53 @@ class ScoreSaberService extends Service {
|
|||||||
);
|
);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the modifier for the given accuracy.
|
||||||
|
*
|
||||||
|
* @param accuracy The accuracy.
|
||||||
|
* @return The modifier.
|
||||||
|
*/
|
||||||
|
public getModifier(accuracy: number): number {
|
||||||
|
accuracy = clamp(accuracy, 0, 100) / 100; // Normalize accuracy to a range of [0, 1]
|
||||||
|
|
||||||
|
if (accuracy <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accuracy >= 1) {
|
||||||
|
return this.curvePoints[this.curvePoints.length - 1].getMultiplier();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < this.curvePoints.length - 1; i++) {
|
||||||
|
const point = this.curvePoints[i];
|
||||||
|
const nextPoint = this.curvePoints[i + 1];
|
||||||
|
if (accuracy >= point.getAcc() && accuracy <= nextPoint.getAcc()) {
|
||||||
|
return lerp(
|
||||||
|
point.getMultiplier(),
|
||||||
|
nextPoint.getMultiplier(),
|
||||||
|
(accuracy - point.getAcc()) / (nextPoint.getAcc() - point.getAcc())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the performance points (PP) based on stars and accuracy.
|
||||||
|
*
|
||||||
|
* @param stars The star count.
|
||||||
|
* @param accuracy The accuracy.
|
||||||
|
* @returns The calculated PP.
|
||||||
|
*/
|
||||||
|
public getPp(stars: number, accuracy: number): number {
|
||||||
|
if (accuracy <= 1) {
|
||||||
|
accuracy *= 100; // Convert the accuracy to a percentage
|
||||||
|
}
|
||||||
|
const pp = stars * STAR_MULTIPLIER; // Calculate base PP value
|
||||||
|
return this.getModifier(accuracy) * pp; // Calculate and return final PP value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const scoresaberService = new ScoreSaberService();
|
export const scoresaberService = new ScoreSaberService();
|
||||||
|
14
projects/common/src/utils/curve-point.ts
Normal file
14
projects/common/src/utils/curve-point.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export class CurvePoint {
|
||||||
|
constructor(
|
||||||
|
private acc: number,
|
||||||
|
private multiplier: number
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getAcc(): number {
|
||||||
|
return this.acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMultiplier(): number {
|
||||||
|
return this.multiplier;
|
||||||
|
}
|
||||||
|
}
|
29
projects/common/src/utils/math-utils.ts
Normal file
29
projects/common/src/utils/math-utils.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Clamps a value between two values.
|
||||||
|
*
|
||||||
|
* @param value the value
|
||||||
|
* @param min the minimum
|
||||||
|
* @param max the maximum
|
||||||
|
*/
|
||||||
|
export function clamp(value: number, min: number, max: number) {
|
||||||
|
if (min !== null && value < min) {
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (max !== null && value > max) {
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lerps between two values.
|
||||||
|
*
|
||||||
|
* @param v0 value 0
|
||||||
|
* @param v1 value 1
|
||||||
|
* @param t the amount to lerp
|
||||||
|
*/
|
||||||
|
export function lerp(v0: number, v1: number, t: number) {
|
||||||
|
return v0 + t * (v1 - v0);
|
||||||
|
}
|
@ -15,7 +15,9 @@
|
|||||||
"@radix-ui/react-avatar": "^1.1.0",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
"@radix-ui/react-popover": "^1.1.2",
|
||||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||||
|
"@radix-ui/react-slider": "^1.2.1",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@radix-ui/react-toast": "^1.2.1",
|
"@radix-ui/react-toast": "^1.2.1",
|
||||||
"@radix-ui/react-tooltip": "^1.1.2",
|
"@radix-ui/react-tooltip": "^1.1.2",
|
||||||
|
@ -1,25 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ArrowDownIcon } from "@heroicons/react/24/solid";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { Dispatch, SetStateAction } from "react";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
isLeaderboardExpanded: boolean;
|
|
||||||
setIsLeaderboardExpanded: Dispatch<SetStateAction<boolean>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function LeaderboardButton({ isLeaderboardExpanded, setIsLeaderboardExpanded }: Props) {
|
|
||||||
return (
|
|
||||||
<div className="pr-2 flex items-center justify-center h-full cursor-default">
|
|
||||||
<Button
|
|
||||||
className="p-0 hover:bg-transparent"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setIsLeaderboardExpanded(!isLeaderboardExpanded)}
|
|
||||||
>
|
|
||||||
<ArrowDownIcon
|
|
||||||
className={clsx("w-6 h-6 transition-all transform-gpu", isLeaderboardExpanded ? "" : "rotate-180")}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -5,27 +5,34 @@ import { songNameToYouTubeLink } from "@/common/youtube-utils";
|
|||||||
import BeatSaverLogo from "@/components/logos/beatsaver-logo";
|
import BeatSaverLogo from "@/components/logos/beatsaver-logo";
|
||||||
import YouTubeLogo from "@/components/logos/youtube-logo";
|
import YouTubeLogo from "@/components/logos/youtube-logo";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Dispatch, SetStateAction } from "react";
|
import { useState } from "react";
|
||||||
import LeaderboardButton from "./leaderboard-button";
|
|
||||||
import ScoreButton from "./score-button";
|
import ScoreButton from "./score-button";
|
||||||
import { copyToClipboard } from "@/common/browser-utils";
|
import { copyToClipboard } from "@/common/browser-utils";
|
||||||
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ArrowDownIcon } from "@heroicons/react/24/solid";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import ScoreEditorButton from "@/components/score/score-editor-button";
|
||||||
|
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
score: ScoreSaberScoreToken;
|
||||||
leaderboard: ScoreSaberLeaderboardToken;
|
leaderboard: ScoreSaberLeaderboardToken;
|
||||||
beatSaverMap?: BeatSaverMap;
|
beatSaverMap?: BeatSaverMap;
|
||||||
alwaysSingleLine?: boolean;
|
alwaysSingleLine?: boolean;
|
||||||
isLeaderboardExpanded?: boolean;
|
setIsLeaderboardExpanded: (isExpanded: boolean) => void;
|
||||||
setIsLeaderboardExpanded?: Dispatch<SetStateAction<boolean>>;
|
setScore: (score: ScoreSaberScoreToken) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ScoreButtons({
|
export default function ScoreButtons({
|
||||||
|
score,
|
||||||
leaderboard,
|
leaderboard,
|
||||||
beatSaverMap,
|
beatSaverMap,
|
||||||
alwaysSingleLine,
|
alwaysSingleLine,
|
||||||
isLeaderboardExpanded,
|
|
||||||
setIsLeaderboardExpanded,
|
setIsLeaderboardExpanded,
|
||||||
|
setScore,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const [leaderboardExpanded, setLeaderboardExpanded] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -74,12 +81,30 @@ export default function ScoreButtons({
|
|||||||
<YouTubeLogo />
|
<YouTubeLogo />
|
||||||
</ScoreButton>
|
</ScoreButton>
|
||||||
</div>
|
</div>
|
||||||
{isLeaderboardExpanded != undefined && setIsLeaderboardExpanded != undefined && (
|
<div className={`flex ${alwaysSingleLine ? "flex-row" : "flex-row lg:flex-col"} items-center justify-center`}>
|
||||||
<LeaderboardButton
|
{/* Edit score button */}
|
||||||
isLeaderboardExpanded={isLeaderboardExpanded}
|
{score && leaderboard && setScore && (
|
||||||
setIsLeaderboardExpanded={setIsLeaderboardExpanded}
|
<ScoreEditorButton score={score} leaderboard={leaderboard} setScore={setScore} />
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* View Leaderboard button */}
|
||||||
|
{leaderboardExpanded != undefined && setIsLeaderboardExpanded != undefined && (
|
||||||
|
<div className="pr-2 flex items-center justify-center cursor-default">
|
||||||
|
<Button
|
||||||
|
className="p-0 hover:bg-transparent"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setLeaderboardExpanded(!leaderboardExpanded);
|
||||||
|
setIsLeaderboardExpanded?.(!leaderboardExpanded);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowDownIcon
|
||||||
|
className={clsx("w-6 h-6 transition-all transform-gpu", leaderboardExpanded ? "" : "rotate-180")}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,70 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CogIcon } from "@heroicons/react/24/solid";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { ResetIcon } from "@radix-ui/react-icons";
|
||||||
|
import Tooltip from "@/components/tooltip";
|
||||||
|
|
||||||
|
type ScoreEditorButtonProps = {
|
||||||
|
score: ScoreSaberScoreToken;
|
||||||
|
leaderboard: ScoreSaberLeaderboardToken;
|
||||||
|
setScore: (score: ScoreSaberScoreToken) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ScoreEditorButton({ score, leaderboard, setScore }: ScoreEditorButtonProps) {
|
||||||
|
const [isScoreEditMode, setIsScoreEditMode] = useState(false);
|
||||||
|
|
||||||
|
const maxScore = leaderboard.maxScore || 1; // Use 1 to prevent division by zero
|
||||||
|
const accuracy = (score.baseScore / maxScore) * 100;
|
||||||
|
|
||||||
|
const handleSliderChange = (value: number[]) => {
|
||||||
|
const newAccuracy = Math.max(0, Math.min(value[0], 100)); // Ensure the accuracy stays within 0-100
|
||||||
|
const newBaseScore = (newAccuracy / 100) * maxScore;
|
||||||
|
setScore({
|
||||||
|
...score,
|
||||||
|
baseScore: newBaseScore,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderReset = () => {
|
||||||
|
setScore({
|
||||||
|
...score,
|
||||||
|
baseScore: (accuracy / 100) * maxScore,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pr-2 flex items-center justify-center cursor-default relative">
|
||||||
|
<Popover
|
||||||
|
onOpenChange={open => {
|
||||||
|
setIsScoreEditMode(open);
|
||||||
|
handleSliderReset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<Button className="p-0 hover:bg-transparent" variant="ghost">
|
||||||
|
<CogIcon className={clsx("w-6 h-6 transition-all transform-gpu", isScoreEditMode ? "" : "rotate-180")} />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" side="left">
|
||||||
|
<div className="p-3 flex flex-col gap-2">
|
||||||
|
<p className="text-sm font-medium mb-1">Accuracy Changer</p>
|
||||||
|
{/* Accuracy Slider */}
|
||||||
|
<Slider className="w-full" min={accuracy} max={100} step={0.01} onValueChange={handleSliderChange} />
|
||||||
|
|
||||||
|
<Tooltip display={<p>Set accuracy to score accuracy</p>}>
|
||||||
|
{/* Reset Button (Changes accuracy back to the original accuracy) */}
|
||||||
|
<Button onClick={handleSliderReset} className="absolute top-1 right-1 p-1" variant="ghost">
|
||||||
|
<ResetIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -73,7 +73,7 @@ const badges: ScoreBadge[] = [
|
|||||||
{
|
{
|
||||||
name: "Score",
|
name: "Score",
|
||||||
create: (score: ScoreSaberScoreToken) => {
|
create: (score: ScoreSaberScoreToken) => {
|
||||||
return `${formatNumberWithCommas(score.baseScore)}`;
|
return `${formatNumberWithCommas(Number(score.baseScore.toFixed(0)))}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -12,6 +12,7 @@ import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player";
|
|||||||
import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token";
|
import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token";
|
||||||
import { lookupBeatSaverMap } from "@/common/beatsaver-utils";
|
import { lookupBeatSaverMap } from "@/common/beatsaver-utils";
|
||||||
import { getPageFromRank } from "@ssr/common/utils/utils";
|
import { getPageFromRank } from "@ssr/common/utils/utils";
|
||||||
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
@ -27,33 +28,47 @@ type Props = {
|
|||||||
|
|
||||||
export default function Score({ player, playerScore }: Props) {
|
export default function Score({ player, playerScore }: Props) {
|
||||||
const { score, leaderboard } = playerScore;
|
const { score, leaderboard } = playerScore;
|
||||||
|
const [baseScore, setBaseScore] = useState<number>(score.baseScore);
|
||||||
const [beatSaverMap, setBeatSaverMap] = useState<BeatSaverMap | undefined>();
|
const [beatSaverMap, setBeatSaverMap] = useState<BeatSaverMap | undefined>();
|
||||||
const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false);
|
const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false);
|
||||||
|
|
||||||
const fetchBeatSaverData = useCallback(async () => {
|
const fetchBeatSaverData = useCallback(async () => {
|
||||||
const beatSaverMap = await lookupBeatSaverMap(leaderboard.songHash);
|
const beatSaverMapData = await lookupBeatSaverMap(leaderboard.songHash);
|
||||||
setBeatSaverMap(beatSaverMap);
|
setBeatSaverMap(beatSaverMapData);
|
||||||
}, [leaderboard.songHash]);
|
}, [leaderboard.songHash]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchBeatSaverData();
|
fetchBeatSaverData();
|
||||||
}, [fetchBeatSaverData]);
|
}, [fetchBeatSaverData]);
|
||||||
|
|
||||||
|
const accuracy = (baseScore / leaderboard.maxScore) * 100;
|
||||||
|
const pp = scoresaberService.getPp(leaderboard.stars, accuracy);
|
||||||
return (
|
return (
|
||||||
<div className="pb-2 pt-2">
|
<div className="pb-2 pt-2">
|
||||||
<div
|
{/* Score Info */}
|
||||||
className={`grid w-full gap-2 lg:gap-0 first:pt-0 last:pb-0 grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_1fr_300px]`}
|
<div className="grid w-full gap-2 lg:gap-0 grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_1fr_300px]">
|
||||||
>
|
|
||||||
<ScoreRankInfo score={score} leaderboard={leaderboard} />
|
<ScoreRankInfo score={score} leaderboard={leaderboard} />
|
||||||
<ScoreSongInfo leaderboard={leaderboard} beatSaverMap={beatSaverMap} />
|
<ScoreSongInfo leaderboard={leaderboard} beatSaverMap={beatSaverMap} />
|
||||||
<ScoreButtons
|
<ScoreButtons
|
||||||
leaderboard={leaderboard}
|
leaderboard={leaderboard}
|
||||||
beatSaverMap={beatSaverMap}
|
beatSaverMap={beatSaverMap}
|
||||||
isLeaderboardExpanded={isLeaderboardExpanded}
|
score={score}
|
||||||
setIsLeaderboardExpanded={setIsLeaderboardExpanded}
|
setIsLeaderboardExpanded={setIsLeaderboardExpanded}
|
||||||
|
setScore={score => {
|
||||||
|
setBaseScore(score.baseScore);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ScoreStats
|
||||||
|
score={{
|
||||||
|
...score,
|
||||||
|
baseScore,
|
||||||
|
pp: pp ? pp : score.pp,
|
||||||
|
}}
|
||||||
|
leaderboard={leaderboard}
|
||||||
/>
|
/>
|
||||||
<ScoreStats score={score} leaderboard={leaderboard} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Leaderboard */}
|
||||||
{isLeaderboardExpanded && (
|
{isLeaderboardExpanded && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -50 }}
|
initial={{ opacity: 0, y: -50 }}
|
||||||
|
32
projects/website/src/components/ui/popover.tsx
Normal file
32
projects/website/src/components/ui/popover.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||||
|
import { cn } from "@/common/utils";
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root;
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||||
|
|
||||||
|
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
));
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
24
projects/website/src/components/ui/slider.tsx
Normal file
24
projects/website/src/components/ui/slider.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||||
|
import { cn } from "@/common/utils";
|
||||||
|
|
||||||
|
const Slider = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||||
|
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
));
|
||||||
|
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Slider };
|
Reference in New Issue
Block a user