add score "edit" mode
Some checks are pending
Deploy Website / deploy (push) Waiting to run
Deploy Backend / deploy (push) Successful in 2m32s

This commit is contained in:
Lee
2024-10-12 07:21:55 +01:00
parent f26b997fbb
commit 98e8273c07
12 changed files with 328 additions and 51 deletions

View File

@ -15,7 +15,9 @@
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-icons": "^1.3.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-slider": "^1.2.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.1.2",

View File

@ -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>
);
}

View File

@ -5,27 +5,34 @@ import { songNameToYouTubeLink } from "@/common/youtube-utils";
import BeatSaverLogo from "@/components/logos/beatsaver-logo";
import YouTubeLogo from "@/components/logos/youtube-logo";
import { useToast } from "@/hooks/use-toast";
import { Dispatch, SetStateAction } from "react";
import LeaderboardButton from "./leaderboard-button";
import { useState } from "react";
import ScoreButton from "./score-button";
import { copyToClipboard } from "@/common/browser-utils";
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 = {
score: ScoreSaberScoreToken;
leaderboard: ScoreSaberLeaderboardToken;
beatSaverMap?: BeatSaverMap;
alwaysSingleLine?: boolean;
isLeaderboardExpanded?: boolean;
setIsLeaderboardExpanded?: Dispatch<SetStateAction<boolean>>;
setIsLeaderboardExpanded: (isExpanded: boolean) => void;
setScore: (score: ScoreSaberScoreToken) => void;
};
export default function ScoreButtons({
score,
leaderboard,
beatSaverMap,
alwaysSingleLine,
isLeaderboardExpanded,
setIsLeaderboardExpanded,
setScore,
}: Props) {
const [leaderboardExpanded, setLeaderboardExpanded] = useState(false);
const { toast } = useToast();
return (
@ -74,12 +81,30 @@ export default function ScoreButtons({
<YouTubeLogo />
</ScoreButton>
</div>
{isLeaderboardExpanded != undefined && setIsLeaderboardExpanded != undefined && (
<LeaderboardButton
isLeaderboardExpanded={isLeaderboardExpanded}
setIsLeaderboardExpanded={setIsLeaderboardExpanded}
/>
)}
<div className={`flex ${alwaysSingleLine ? "flex-row" : "flex-row lg:flex-col"} items-center justify-center`}>
{/* Edit score button */}
{score && leaderboard && setScore && (
<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>
);
}

View File

@ -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>
);
}

View File

@ -73,7 +73,7 @@ const badges: ScoreBadge[] = [
{
name: "Score",
create: (score: ScoreSaberScoreToken) => {
return `${formatNumberWithCommas(score.baseScore)}`;
return `${formatNumberWithCommas(Number(score.baseScore.toFixed(0)))}`;
},
},
{

View File

@ -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 { lookupBeatSaverMap } from "@/common/beatsaver-utils";
import { getPageFromRank } from "@ssr/common/utils/utils";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
type Props = {
/**
@ -27,33 +28,47 @@ type Props = {
export default function Score({ player, playerScore }: Props) {
const { score, leaderboard } = playerScore;
const [baseScore, setBaseScore] = useState<number>(score.baseScore);
const [beatSaverMap, setBeatSaverMap] = useState<BeatSaverMap | undefined>();
const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false);
const fetchBeatSaverData = useCallback(async () => {
const beatSaverMap = await lookupBeatSaverMap(leaderboard.songHash);
setBeatSaverMap(beatSaverMap);
const beatSaverMapData = await lookupBeatSaverMap(leaderboard.songHash);
setBeatSaverMap(beatSaverMapData);
}, [leaderboard.songHash]);
useEffect(() => {
fetchBeatSaverData();
}, [fetchBeatSaverData]);
const accuracy = (baseScore / leaderboard.maxScore) * 100;
const pp = scoresaberService.getPp(leaderboard.stars, accuracy);
return (
<div className="pb-2 pt-2">
<div
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]`}
>
{/* Score Info */}
<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} />
<ScoreSongInfo leaderboard={leaderboard} beatSaverMap={beatSaverMap} />
<ScoreButtons
leaderboard={leaderboard}
beatSaverMap={beatSaverMap}
isLeaderboardExpanded={isLeaderboardExpanded}
score={score}
setIsLeaderboardExpanded={setIsLeaderboardExpanded}
setScore={score => {
setBaseScore(score.baseScore);
}}
/>
<ScoreStats
score={{
...score,
baseScore,
pp: pp ? pp : score.pp,
}}
leaderboard={leaderboard}
/>
<ScoreStats score={score} leaderboard={leaderboard} />
</div>
{/* Leaderboard */}
{isLeaderboardExpanded && (
<motion.div
initial={{ opacity: 0, y: -50 }}

View 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 };

View 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 };