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 ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber-leaderboard-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";
|
||||
|
||||
@ -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_SCORES_ENDPOINT = `${API_BASE}/leaderboard/by-id/:id/scores?page=:page`;
|
||||
|
||||
const STAR_MULTIPLIER = 42.117208413;
|
||||
|
||||
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() {
|
||||
super("ScoreSaber");
|
||||
}
|
||||
@ -35,7 +79,7 @@ class ScoreSaberService extends Service {
|
||||
* @param query the query to search for
|
||||
* @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();
|
||||
this.log(`Searching for players matching "${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
|
||||
* @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();
|
||||
this.log(`Looking up player "${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
|
||||
* @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();
|
||||
this.log(`Looking up players on page "${page}"...`);
|
||||
const response = await this.fetch<ScoreSaberPlayersPageToken>(
|
||||
@ -93,7 +137,7 @@ class ScoreSaberService extends Service {
|
||||
* @param country the country to get players for
|
||||
* @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();
|
||||
this.log(`Looking up players on page "${page}" for country "${country}"...`);
|
||||
const response = await this.fetch<ScoreSaberPlayersPageToken>(
|
||||
@ -115,7 +159,7 @@ class ScoreSaberService extends Service {
|
||||
* @param search
|
||||
* @returns the scores of the player, or undefined
|
||||
*/
|
||||
async lookupPlayerScores({
|
||||
public async lookupPlayerScores({
|
||||
playerId,
|
||||
sort,
|
||||
page,
|
||||
@ -151,7 +195,7 @@ class ScoreSaberService extends Service {
|
||||
*
|
||||
* @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();
|
||||
this.log(`Looking up leaderboard "${leaderboardId}"...`);
|
||||
const response = await this.fetch<ScoreSaberLeaderboardToken>(
|
||||
@ -171,7 +215,7 @@ class ScoreSaberService extends Service {
|
||||
* @param page the page to get scores for
|
||||
* @returns the scores of the leaderboard, or undefined
|
||||
*/
|
||||
async lookupLeaderboardScores(
|
||||
public async lookupLeaderboardScores(
|
||||
leaderboardId: string,
|
||||
page: number
|
||||
): Promise<ScoreSaberLeaderboardScoresPageToken | undefined> {
|
||||
@ -188,6 +232,53 @@ class ScoreSaberService extends Service {
|
||||
);
|
||||
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();
|
||||
|
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-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