add score "edit" mode
This commit is contained in:
@ -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",
|
||||
|
@ -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 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>
|
||||
);
|
||||
}
|
||||
|
@ -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",
|
||||
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 { 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 }}
|
||||
|
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