cleanup score component and add finish score stats
All checks were successful
Deploy SSR / deploy (push) Successful in 1m12s
All checks were successful
Deploy SSR / deploy (push) Successful in 1m12s
This commit is contained in:
parent
1f4b1d10af
commit
9e3c670a9e
@ -25,7 +25,7 @@ class BeatSaverFetcher extends DataFetcher {
|
|||||||
let map = await db.beatSaverMaps.get(query);
|
let map = await db.beatSaverMaps.get(query);
|
||||||
// The map is cached
|
// The map is cached
|
||||||
if (map != undefined) {
|
if (map != undefined) {
|
||||||
this.log(`Found cached map "${query}" in ${(performance.now() - before).toFixed(2)}ms`);
|
this.log(`Found cached map "${query}" in ${(performance.now() - before).toFixed(0)}ms`);
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,7 +47,7 @@ class BeatSaverFetcher extends DataFetcher {
|
|||||||
fullData: response,
|
fullData: response,
|
||||||
});
|
});
|
||||||
map = await db.beatSaverMaps.get(query);
|
map = await db.beatSaverMaps.get(query);
|
||||||
this.log(`Found map "${query}" in ${(performance.now() - before).toFixed(2)}ms`);
|
this.log(`Found map "${query}" in ${(performance.now() - before).toFixed(0)}ms`);
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -35,7 +35,7 @@ class ScoreSaberFetcher extends DataFetcher {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
results.players.sort((a, b) => a.rank - b.rank);
|
results.players.sort((a, b) => a.rank - b.rank);
|
||||||
this.log(`Found ${results.players.length} players in ${(performance.now() - before).toFixed(2)}ms`);
|
this.log(`Found ${results.players.length} players in ${(performance.now() - before).toFixed(0)}ms`);
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,7 +53,7 @@ class ScoreSaberFetcher extends DataFetcher {
|
|||||||
if (response === undefined) {
|
if (response === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
this.log(`Found player "${playerId}" in ${(performance.now() - before).toFixed(2)}ms`);
|
this.log(`Found player "${playerId}" in ${(performance.now() - before).toFixed(0)}ms`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,7 +84,7 @@ class ScoreSaberFetcher extends DataFetcher {
|
|||||||
if (response === undefined) {
|
if (response === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
this.log(`Found scores for player "${playerId}" in ${(performance.now() - before).toFixed(2)}ms`);
|
this.log(`Found scores for player "${playerId}" in ${(performance.now() - before).toFixed(0)}ms`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
65
src/components/player/score/score-buttons.tsx
Normal file
65
src/components/player/score/score-buttons.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { copyToClipboard } from "@/common/browser-utils";
|
||||||
|
import ScoreSaberPlayerScore from "@/common/data-fetcher/types/scoresaber/scoresaber-player-score";
|
||||||
|
import BeatSaverMap from "@/common/database/types/beatsaver-map";
|
||||||
|
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 ScoreButton from "./score-button";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
playerScore: ScoreSaberPlayerScore;
|
||||||
|
beatSaverMap?: BeatSaverMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ScoreButtons({ playerScore, beatSaverMap }: Props) {
|
||||||
|
const { leaderboard } = playerScore;
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hidden lg:flex flex-row flex-wrap gap-1 justify-end">
|
||||||
|
{beatSaverMap != undefined && (
|
||||||
|
<>
|
||||||
|
{/* Copy BSR */}
|
||||||
|
<ScoreButton
|
||||||
|
onClick={() => {
|
||||||
|
toast({
|
||||||
|
title: "Copied!",
|
||||||
|
description: `Copied "!bsr ${beatSaverMap}" to your clipboard!`,
|
||||||
|
});
|
||||||
|
copyToClipboard(`!bsr ${beatSaverMap.bsr}`);
|
||||||
|
}}
|
||||||
|
tooltip={<p>Click to copy the bsr code</p>}
|
||||||
|
>
|
||||||
|
<p>!</p>
|
||||||
|
</ScoreButton>
|
||||||
|
|
||||||
|
{/* Open map in BeatSaver */}
|
||||||
|
<ScoreButton
|
||||||
|
onClick={() => {
|
||||||
|
window.open(`https://beatsaver.com/maps/${beatSaverMap.bsr}`, "_blank");
|
||||||
|
}}
|
||||||
|
tooltip={<p>Click to open the map</p>}
|
||||||
|
>
|
||||||
|
<BeatSaverLogo />
|
||||||
|
</ScoreButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Open song in YouTube */}
|
||||||
|
<ScoreButton
|
||||||
|
onClick={() => {
|
||||||
|
window.open(
|
||||||
|
songNameToYouTubeLink(leaderboard.songName, leaderboard.songSubName, leaderboard.songAuthorName),
|
||||||
|
"_blank"
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
tooltip={<p>Click to open the song in YouTube</p>}
|
||||||
|
>
|
||||||
|
<YouTubeLogo />
|
||||||
|
</ScoreButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
78
src/components/player/score/score-info.tsx
Normal file
78
src/components/player/score/score-info.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import ScoreSaberPlayerScore from "@/common/data-fetcher/types/scoresaber/scoresaber-player-score";
|
||||||
|
import BeatSaverMap from "@/common/database/types/beatsaver-map";
|
||||||
|
import { getDifficultyFromScoreSaberDifficulty } from "@/common/scoresaber-utils";
|
||||||
|
import { songDifficultyToColor } from "@/common/song-utils";
|
||||||
|
import FallbackLink from "@/components/fallback-link";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { StarIcon } from "@radix-ui/react-icons";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
playerScore: ScoreSaberPlayerScore;
|
||||||
|
beatSaverMap?: BeatSaverMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ScoreSongInfo({ playerScore, beatSaverMap }: Props) {
|
||||||
|
const { leaderboard } = playerScore;
|
||||||
|
const diff = getDifficultyFromScoreSaberDifficulty(leaderboard.difficulty.difficulty);
|
||||||
|
const mappersProfile =
|
||||||
|
beatSaverMap != undefined ? `https://beatsaver.com/profile/${beatSaverMap?.fullData.uploader.id}` : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="relative flex justify-center h-[64px]">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
asChild
|
||||||
|
className="absolute w-full h-[20px] bottom-0 right-0 rounded-sm flex justify-center items-center text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: songDifficultyToColor(diff) + "f0", // Transparency value (in hex 0-255)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{leaderboard.stars > 0 ? (
|
||||||
|
<div className="flex gap-1 items-center justify-center">
|
||||||
|
<p>{leaderboard.stars}</p>
|
||||||
|
<StarIcon className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p>{diff}</p>
|
||||||
|
)}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Difficulty: <span className="font-bold">{diff}</span>
|
||||||
|
</p>
|
||||||
|
{leaderboard.stars > 0 && (
|
||||||
|
<p>
|
||||||
|
Stars: <span className="font-bold">{leaderboard.stars}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Image
|
||||||
|
unoptimized
|
||||||
|
src={leaderboard.coverImage}
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
alt="Song Artwork"
|
||||||
|
className="rounded-md min-w-[64px]"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<div className="overflow-y-clip">
|
||||||
|
<p className="text-pp">
|
||||||
|
{leaderboard.songName} {leaderboard.songSubName}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-400">{leaderboard.songAuthorName}</p>
|
||||||
|
<FallbackLink href={mappersProfile}>
|
||||||
|
<p className={clsx("text-sm", mappersProfile && "hover:brightness-75 transform-gpu transition-all")}>
|
||||||
|
{leaderboard.levelAuthorName}
|
||||||
|
</p>
|
||||||
|
</FallbackLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
22
src/components/player/score/score-rank-info.tsx
Normal file
22
src/components/player/score/score-rank-info.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import ScoreSaberPlayerScore from "@/common/data-fetcher/types/scoresaber/scoresaber-player-score";
|
||||||
|
import { formatNumberWithCommas } from "@/common/number-utils";
|
||||||
|
import { timeAgo } from "@/common/time-utils";
|
||||||
|
import { GlobeAmericasIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
playerScore: ScoreSaberPlayerScore;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ScoreRankInfo({ playerScore }: Props) {
|
||||||
|
const { score } = playerScore;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-row justify-between items-center lg:w-[125px] lg:justify-center lg:flex-col">
|
||||||
|
<div className="flex gap-1 items-center">
|
||||||
|
<GlobeAmericasIcon className="w-5 h-5" />
|
||||||
|
<p className="text-pp">#{formatNumberWithCommas(score.rank)}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm">{timeAgo(new Date(score.timeSet))}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
88
src/components/player/score/score-stats.tsx
Normal file
88
src/components/player/score/score-stats.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import ScoreSaberPlayerScore from "@/common/data-fetcher/types/scoresaber/scoresaber-player-score";
|
||||||
|
import { formatNumberWithCommas } from "@/common/number-utils";
|
||||||
|
import StatValue from "@/components/stat-value";
|
||||||
|
import { XMarkIcon } from "@heroicons/react/24/solid";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
name: "PP",
|
||||||
|
create: (playerScore: ScoreSaberPlayerScore) => {
|
||||||
|
const { score } = playerScore;
|
||||||
|
const pp = score.pp;
|
||||||
|
if (pp === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return <StatValue value={`${score.pp.toFixed(2)}pp`} />;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Accuracy",
|
||||||
|
create: (playerScore: ScoreSaberPlayerScore) => {
|
||||||
|
const { score, leaderboard } = playerScore;
|
||||||
|
const acc = (score.baseScore / leaderboard.maxScore) * 100;
|
||||||
|
return <StatValue value={`${acc.toFixed(2)}%`} />;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Score",
|
||||||
|
create: (playerScore: ScoreSaberPlayerScore) => {
|
||||||
|
const { score } = playerScore;
|
||||||
|
return <StatValue value={`${formatNumberWithCommas(score.baseScore)}`} />;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Full Combo",
|
||||||
|
create: (playerScore: ScoreSaberPlayerScore) => {
|
||||||
|
const { score } = playerScore;
|
||||||
|
const fullCombo = score.missedNotes === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatValue
|
||||||
|
value={
|
||||||
|
<>
|
||||||
|
<p>{fullCombo ? "FC" : formatNumberWithCommas(score.missedNotes)}</p>
|
||||||
|
<XMarkIcon className={clsx("w-5 h-5", fullCombo ? "hidden" : "text-red-400")} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
playerScore: ScoreSaberPlayerScore;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ScoreStats({ playerScore }: Props) {
|
||||||
|
const itemsPerRow = 3;
|
||||||
|
const totalStats = stats.length;
|
||||||
|
const remainingItems = totalStats % itemsPerRow;
|
||||||
|
const emptySpaces = remainingItems > 0 ? itemsPerRow - remainingItems : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2 pl-0 lg:pl-2">
|
||||||
|
{/* Render all but the last row of stats normally */}
|
||||||
|
{stats.slice(0, totalStats - remainingItems).map((stat) => (
|
||||||
|
<div key={stat.name} className="flex-1 min-w-[30%]">
|
||||||
|
{stat.create(playerScore)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Handle the last row - align right and push empty spaces to the left */}
|
||||||
|
<div className="flex justify-end w-full gap-2">
|
||||||
|
{Array(emptySpaces)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, index) => (
|
||||||
|
<div key={`empty-${index}`} className="flex-1 min-w-[30%]"></div>
|
||||||
|
))}
|
||||||
|
{stats.slice(totalStats - remainingItems).map((stat) => (
|
||||||
|
<div key={stat.name} className="flex-1 min-w-[30%]">
|
||||||
|
{stat.create(playerScore)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,24 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { copyToClipboard } from "@/common/browser-utils";
|
|
||||||
import { beatsaverFetcher } from "@/common/data-fetcher/impl/beatsaver";
|
import { beatsaverFetcher } from "@/common/data-fetcher/impl/beatsaver";
|
||||||
import ScoreSaberPlayerScore from "@/common/data-fetcher/types/scoresaber/scoresaber-player-score";
|
import ScoreSaberPlayerScore from "@/common/data-fetcher/types/scoresaber/scoresaber-player-score";
|
||||||
import BeatSaverMap from "@/common/database/types/beatsaver-map";
|
import BeatSaverMap from "@/common/database/types/beatsaver-map";
|
||||||
import { formatNumberWithCommas } from "@/common/number-utils";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { getDifficultyFromScoreSaberDifficulty } from "@/common/scoresaber-utils";
|
import ScoreButtons from "./score-buttons";
|
||||||
import { songDifficultyToColor } from "@/common/song-utils";
|
import ScoreSongInfo from "./score-info";
|
||||||
import { timeAgo } from "@/common/time-utils";
|
import ScoreRankInfo from "./score-rank-info";
|
||||||
import { songNameToYouTubeLink } from "@/common/youtube-utils";
|
import ScoreStats from "./score-stats";
|
||||||
import FallbackLink from "@/components/fallback-link";
|
|
||||||
import YouTubeLogo from "@/components/logos/youtube-logo";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { GlobeAmericasIcon, StarIcon } from "@heroicons/react/24/solid";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import BeatSaverLogo from "../../logos/beatsaver-logo";
|
|
||||||
import ScoreButton from "./score-button";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
@ -28,128 +17,24 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Score({ playerScore }: Props) {
|
export default function Score({ playerScore }: Props) {
|
||||||
const { score, leaderboard } = playerScore;
|
const { leaderboard } = playerScore;
|
||||||
const { toast } = useToast();
|
|
||||||
const [beatSaverMap, setBeatSaverMap] = useState<BeatSaverMap | undefined>();
|
const [beatSaverMap, setBeatSaverMap] = useState<BeatSaverMap | undefined>();
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchBeatSaverData = useCallback(async () => {
|
||||||
(async () => {
|
const beatSaverMap = await beatsaverFetcher.lookupMap(leaderboard.songHash);
|
||||||
const beatSaverMap = await beatsaverFetcher.lookupMap(leaderboard.songHash);
|
setBeatSaverMap(beatSaverMap);
|
||||||
setBeatSaverMap(beatSaverMap);
|
}, [leaderboard.songHash]);
|
||||||
})();
|
|
||||||
}, [playerScore, leaderboard.songHash]);
|
|
||||||
|
|
||||||
const diff = getDifficultyFromScoreSaberDifficulty(leaderboard.difficulty.difficulty);
|
useEffect(() => {
|
||||||
const mappersProfile =
|
fetchBeatSaverData();
|
||||||
beatSaverMap != undefined ? `https://beatsaver.com/profile/${beatSaverMap?.fullData.uploader.id}` : undefined;
|
}, [fetchBeatSaverData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-2 md:gap-0 pb-2 pt-2 first:pt-0 last:pb-0 grid-cols-[20px 1fr_1fr] md:grid-cols-[0.85fr_5fr_1fr_1.2fr]">
|
<div className="grid gap-2 lg:gap-0 pb-2 pt-2 first:pt-0 last:pb-0 grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.85fr_4fr_1fr_300px]">
|
||||||
<div className="flex w-full flex-row justify-between items-center md:w-[125px] md:justify-center md:flex-col">
|
<ScoreRankInfo playerScore={playerScore} />
|
||||||
<div className="flex gap-1 items-center">
|
<ScoreSongInfo playerScore={playerScore} beatSaverMap={beatSaverMap} />
|
||||||
<GlobeAmericasIcon className="w-5 h-5" />
|
<ScoreButtons playerScore={playerScore} beatSaverMap={beatSaverMap} />
|
||||||
<p className="text-pp">#{formatNumberWithCommas(score.rank)}</p>
|
<ScoreStats playerScore={playerScore} />
|
||||||
</div>
|
|
||||||
<p className="text-sm">{timeAgo(new Date(score.timeSet))}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<div className="relative flex justify-center h-[64px]">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
asChild
|
|
||||||
className="absolute w-[85%] h-[20px] bottom-0 mb-[-5px] rounded-sm flex justify-center items-center text-xs"
|
|
||||||
style={{
|
|
||||||
backgroundColor: songDifficultyToColor(diff) + "f0", // Transparency value (in hex 0-255)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{leaderboard.stars > 0 ? (
|
|
||||||
<div className="flex gap-1 items-center justify-center">
|
|
||||||
<p>{leaderboard.stars}</p>
|
|
||||||
<StarIcon className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p>{diff}</p>
|
|
||||||
)}
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>
|
|
||||||
Difficulty: <span className="font-bold">{diff}</span>
|
|
||||||
</p>
|
|
||||||
{leaderboard.stars > 0 && (
|
|
||||||
<p>
|
|
||||||
Stars: <span className="font-bold">{leaderboard.stars}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Image
|
|
||||||
unoptimized
|
|
||||||
src={leaderboard.coverImage}
|
|
||||||
width={64}
|
|
||||||
height={64}
|
|
||||||
alt="Song Artwork"
|
|
||||||
className="rounded-md min-w-[64px]"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex">
|
|
||||||
<div className="overflow-y-clip">
|
|
||||||
<p className="text-pp">
|
|
||||||
{leaderboard.songName} {leaderboard.songSubName}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-400">{leaderboard.songAuthorName}</p>
|
|
||||||
<FallbackLink href={mappersProfile}>
|
|
||||||
<p className={clsx("text-sm", mappersProfile && "hover:brightness-75 transform-gpu transition-all")}>
|
|
||||||
{leaderboard.levelAuthorName}
|
|
||||||
</p>
|
|
||||||
</FallbackLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="hidden md:flex flex-row flex-wrap gap-1 justify-end">
|
|
||||||
{beatSaverMap != undefined && (
|
|
||||||
<>
|
|
||||||
{/* Copy BSR */}
|
|
||||||
<ScoreButton
|
|
||||||
onClick={() => {
|
|
||||||
toast({
|
|
||||||
title: "Copied!",
|
|
||||||
description: `Copied "!bsr ${beatSaverMap}" to your clipboard!`,
|
|
||||||
});
|
|
||||||
copyToClipboard(`!bsr ${beatSaverMap.bsr}`);
|
|
||||||
}}
|
|
||||||
tooltip={<p>Click to copy the bsr code</p>}
|
|
||||||
>
|
|
||||||
<p>!</p>
|
|
||||||
</ScoreButton>
|
|
||||||
|
|
||||||
{/* Open map in BeatSaver */}
|
|
||||||
<ScoreButton
|
|
||||||
onClick={() => {
|
|
||||||
window.open(`https://beatsaver.com/maps/${beatSaverMap.bsr}`, "_blank");
|
|
||||||
}}
|
|
||||||
tooltip={<p>Click to open the map</p>}
|
|
||||||
>
|
|
||||||
<BeatSaverLogo />
|
|
||||||
</ScoreButton>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Open song in YouTube */}
|
|
||||||
<ScoreButton
|
|
||||||
onClick={() => {
|
|
||||||
window.open(
|
|
||||||
songNameToYouTubeLink(leaderboard.songName, leaderboard.songSubName, leaderboard.songAuthorName),
|
|
||||||
"_blank"
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
tooltip={<p>Click to open the song in YouTube</p>}
|
|
||||||
>
|
|
||||||
<YouTubeLogo />
|
|
||||||
</ScoreButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end">stats stuff</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
25
src/components/stat-value.tsx
Normal file
25
src/components/stat-value.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* The stat name.
|
||||||
|
*/
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value of the stat.
|
||||||
|
*/
|
||||||
|
value: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StatValue({ name, value }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-16 gap-2 bg-accent h-[28px] p-1 items-center justify-center rounded-md text-sm">
|
||||||
|
{name && (
|
||||||
|
<>
|
||||||
|
<p>{name}</p>
|
||||||
|
<div className="h-4 w-[1px] bg-primary" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-1 items-center">{typeof value === "string" ? <p>{value}</p> : value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
Reference in New Issue
Block a user