add basic leaderboard dropdown on scores
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
b1a889421c
commit
14845c0377
@ -1,5 +1,6 @@
|
|||||||
import DataFetcher from "../data-fetcher";
|
import DataFetcher from "../data-fetcher";
|
||||||
import { ScoreSort } from "../sort";
|
import { ScoreSort } from "../sort";
|
||||||
|
import ScoreSaberLeaderboardScoresPage from "../types/scoresaber/scoresaber-leaderboard-scores-page";
|
||||||
import ScoreSaberPlayer from "../types/scoresaber/scoresaber-player";
|
import ScoreSaberPlayer from "../types/scoresaber/scoresaber-player";
|
||||||
import ScoreSaberPlayerScoresPage from "../types/scoresaber/scoresaber-player-scores-page";
|
import ScoreSaberPlayerScoresPage from "../types/scoresaber/scoresaber-player-scores-page";
|
||||||
import { ScoreSaberPlayerSearch } from "../types/scoresaber/scoresaber-player-search";
|
import { ScoreSaberPlayerSearch } from "../types/scoresaber/scoresaber-player-search";
|
||||||
@ -8,6 +9,7 @@ const API_BASE = "https://scoresaber.com/api";
|
|||||||
const SEARCH_PLAYERS_ENDPOINT = `${API_BASE}/players?search=:query`;
|
const SEARCH_PLAYERS_ENDPOINT = `${API_BASE}/players?search=:query`;
|
||||||
const LOOKUP_PLAYER_ENDPOINT = `${API_BASE}/player/:id/full`;
|
const LOOKUP_PLAYER_ENDPOINT = `${API_BASE}/player/:id/full`;
|
||||||
const LOOKUP_PLAYER_SCORES_ENDPOINT = `${API_BASE}/player/:id/scores?limit=:limit&sort=:sort&page=:page`;
|
const LOOKUP_PLAYER_SCORES_ENDPOINT = `${API_BASE}/player/:id/scores?limit=:limit&sort=:sort&page=:page`;
|
||||||
|
const LOOKUP_LEADERBOARD_SCORES_ENDPOINT = `${API_BASE}/leaderboard/by-id/:id/scores?page=:page`;
|
||||||
|
|
||||||
class ScoreSaberFetcher extends DataFetcher {
|
class ScoreSaberFetcher extends DataFetcher {
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -87,6 +89,33 @@ class ScoreSaberFetcher extends DataFetcher {
|
|||||||
this.log(`Found scores for player "${playerId}" in ${(performance.now() - before).toFixed(0)}ms`);
|
this.log(`Found scores for player "${playerId}" in ${(performance.now() - before).toFixed(0)}ms`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up a page of scores for a leaderboard
|
||||||
|
*
|
||||||
|
* @param leaderboardId the ID of the leaderboard to look up
|
||||||
|
* @param sort the sort to use
|
||||||
|
* @param page the page to get scores for
|
||||||
|
* @param useProxy whether to use the proxy or not
|
||||||
|
* @returns the scores of the leaderboard, or undefined
|
||||||
|
*/
|
||||||
|
async lookupLeaderboardScores(
|
||||||
|
leaderboardId: string,
|
||||||
|
page: number,
|
||||||
|
useProxy = true
|
||||||
|
): Promise<ScoreSaberLeaderboardScoresPage | undefined> {
|
||||||
|
const before = performance.now();
|
||||||
|
this.log(`Looking up scores for leaderboard "${leaderboardId}", page "${page}"...`);
|
||||||
|
const response = await this.fetch<ScoreSaberLeaderboardScoresPage>(
|
||||||
|
useProxy,
|
||||||
|
LOOKUP_LEADERBOARD_SCORES_ENDPOINT.replace(":id", leaderboardId).replace(":page", page.toString())
|
||||||
|
);
|
||||||
|
if (response === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
this.log(`Found scores for leaderboard "${leaderboardId}" in ${(performance.now() - before).toFixed(0)}ms`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const scoresaberFetcher = new ScoreSaberFetcher();
|
export const scoresaberFetcher = new ScoreSaberFetcher();
|
||||||
|
@ -0,0 +1,14 @@
|
|||||||
|
import ScoreSaberMetadata from "./scoresaber-metadata";
|
||||||
|
import ScoreSaberScore from "./scoresaber-score";
|
||||||
|
|
||||||
|
export default interface ScoreSaberLeaderboardScoresPage {
|
||||||
|
/**
|
||||||
|
* The scores on this page.
|
||||||
|
*/
|
||||||
|
scores: ScoreSaberScore[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The metadata for the page.
|
||||||
|
*/
|
||||||
|
metadata: ScoreSaberMetadata;
|
||||||
|
}
|
27
src/components/leaderboard/leaderboard-player.tsx
Normal file
27
src/components/leaderboard/leaderboard-player.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import ScoreSaberScore from "@/common/data-fetcher/types/scoresaber/scoresaber-score";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
score: ScoreSaberScore;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LeaderboardPlayer({ score }: Props) {
|
||||||
|
const player = score.leaderboardPlayerInfo;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Image
|
||||||
|
unoptimized
|
||||||
|
src={player.profilePicture}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
alt="Song Artwork"
|
||||||
|
className="rounded-md min-w-[32px]"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p>{player.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
64
src/components/leaderboard/leaderboard-score-stats.tsx
Normal file
64
src/components/leaderboard/leaderboard-score-stats.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import ScoreSaberLeaderboard from "@/common/data-fetcher/types/scoresaber/scoresaber-leaderboard";
|
||||||
|
import ScoreSaberScore from "@/common/data-fetcher/types/scoresaber/scoresaber-score";
|
||||||
|
import { formatNumberWithCommas } from "@/common/number-utils";
|
||||||
|
import StatValue from "@/components/stat-value";
|
||||||
|
import { XMarkIcon } from "@heroicons/react/24/solid";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
type Badge = {
|
||||||
|
name: string;
|
||||||
|
create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => string | React.ReactNode | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const badges: Badge[] = [
|
||||||
|
{
|
||||||
|
name: "PP",
|
||||||
|
create: (score: ScoreSaberScore) => {
|
||||||
|
const pp = score.pp;
|
||||||
|
if (pp === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return `${score.pp.toFixed(2)}pp`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Accuracy",
|
||||||
|
create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => {
|
||||||
|
const acc = (score.baseScore / leaderboard.maxScore) * 100;
|
||||||
|
return `${acc.toFixed(2)}%`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Full Combo",
|
||||||
|
create: (score: ScoreSaberScore) => {
|
||||||
|
const fullCombo = score.missedNotes === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>{fullCombo ? <span className="text-green-400">FC</span> : formatNumberWithCommas(score.missedNotes)}</p>
|
||||||
|
<XMarkIcon className={clsx("w-5 h-5", fullCombo ? "hidden" : "text-red-400")} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
score: ScoreSaberScore;
|
||||||
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LeaderboardScoreStats({ score, leaderboard }: Props) {
|
||||||
|
return (
|
||||||
|
<div className={`grid grid-cols-3 grid-rows-1 gap-1 ml-0 lg:ml-2`}>
|
||||||
|
{badges.map((badge, index) => {
|
||||||
|
const toRender = badge.create(score, leaderboard);
|
||||||
|
if (toRender === undefined) {
|
||||||
|
return <div key={index} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <StatValue key={index} value={toRender} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
31
src/components/leaderboard/leaderboard-score.tsx
Normal file
31
src/components/leaderboard/leaderboard-score.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import ScoreSaberLeaderboard from "@/common/data-fetcher/types/scoresaber/scoresaber-leaderboard";
|
||||||
|
import ScoreSaberScore from "@/common/data-fetcher/types/scoresaber/scoresaber-score";
|
||||||
|
import { timeAgo } from "@/common/time-utils";
|
||||||
|
import ScoreRankInfo from "../player/score/score-rank-info";
|
||||||
|
import LeaderboardPlayer from "./leaderboard-player";
|
||||||
|
import LeaderboardScoreStats from "./leaderboard-score-stats";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* The score to display.
|
||||||
|
*/
|
||||||
|
score: ScoreSaberScore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The leaderboard to display.
|
||||||
|
*/
|
||||||
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LeaderboardScore({ score, leaderboard }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="grid w-full pb-2 pt-2 gap-2 lg:gap-0 first:pt-0 last:pb-0 grid-cols-[20px 1fr_1fr] lg:grid-cols-[100px_4fr_0.8fr_300px]">
|
||||||
|
<ScoreRankInfo score={score} isLeaderboard />
|
||||||
|
<LeaderboardPlayer score={score} />
|
||||||
|
<p className="text-sm text-right">{timeAgo(new Date(score.timeSet))}</p>
|
||||||
|
<LeaderboardScoreStats score={score} leaderboard={leaderboard} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
85
src/components/leaderboard/leaderboard-scores.tsx
Normal file
85
src/components/leaderboard/leaderboard-scores.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { scoresaberFetcher } from "@/common/data-fetcher/impl/scoresaber";
|
||||||
|
import ScoreSaberLeaderboard from "@/common/data-fetcher/types/scoresaber/scoresaber-leaderboard";
|
||||||
|
import ScoreSaberLeaderboardScoresPage from "@/common/data-fetcher/types/scoresaber/scoresaber-leaderboard-scores-page";
|
||||||
|
import useWindowDimensions from "@/hooks/use-window-dimensions";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { motion, useAnimation } from "framer-motion";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import Card from "../card";
|
||||||
|
import Pagination from "../input/pagination";
|
||||||
|
import LeaderboardScore from "./leaderboard-score";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LeaderboardScores({ leaderboard }: Props) {
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
const controls = useAnimation();
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [currentScores, setCurrentScores] = useState<ScoreSaberLeaderboardScoresPage | undefined>();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: scores,
|
||||||
|
isError,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["playerScores", leaderboard.id, currentPage],
|
||||||
|
queryFn: () => scoresaberFetcher.lookupLeaderboardScores(leaderboard.id + "", currentPage),
|
||||||
|
staleTime: 30 * 1000, // Cache data for 30 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAnimation = useCallback(() => {
|
||||||
|
controls.set({ x: -50, opacity: 0 });
|
||||||
|
controls.start({ x: 0, opacity: 1, transition: { duration: 0.25 } });
|
||||||
|
}, [controls]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scores) {
|
||||||
|
setCurrentScores(scores);
|
||||||
|
}
|
||||||
|
}, [scores]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scores) {
|
||||||
|
handleAnimation();
|
||||||
|
}
|
||||||
|
}, [scores, handleAnimation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refetch();
|
||||||
|
}, [leaderboard, currentPage, refetch]);
|
||||||
|
|
||||||
|
if (currentScores === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="flex gap-2">
|
||||||
|
<div className="text-center">
|
||||||
|
{isError && <p>Oopsies! Something went wrong.</p>}
|
||||||
|
{currentScores.scores.length === 0 && <p>No scores found. Invalid Page?</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div animate={controls}>
|
||||||
|
<div className="grid min-w-full grid-cols-1 divide-y divide-border">
|
||||||
|
{currentScores.scores.map((playerScore, index) => (
|
||||||
|
<LeaderboardScore key={index} score={playerScore} leaderboard={leaderboard} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
mobilePagination={width < 768}
|
||||||
|
page={currentPage}
|
||||||
|
totalPages={Math.ceil(currentScores.metadata.total / currentScores.metadata.itemsPerPage)}
|
||||||
|
loadingPage={isLoading ? currentPage : undefined}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
21
src/components/player/score/leaderboard-button.tsx
Normal file
21
src/components/player/score/leaderboard-button.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
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">
|
||||||
|
<Button className="p-0" variant="ghost" onClick={() => setIsLeaderboardExpanded(!isLeaderboardExpanded)}>
|
||||||
|
<ArrowDownIcon
|
||||||
|
className={clsx("w-6 h-6 transition-all transform-gpu", isLeaderboardExpanded ? "" : "rotate-180")}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -7,18 +7,32 @@ 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 LeaderboardButton from "./leaderboard-button";
|
||||||
import ScoreButton from "./score-button";
|
import ScoreButton from "./score-button";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
playerScore: ScoreSaberPlayerScore;
|
playerScore: ScoreSaberPlayerScore;
|
||||||
beatSaverMap?: BeatSaverMap;
|
beatSaverMap?: BeatSaverMap;
|
||||||
|
isLeaderboardExpanded: boolean;
|
||||||
|
setIsLeaderboardExpanded: Dispatch<SetStateAction<boolean>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ScoreButtons({ playerScore, beatSaverMap }: Props) {
|
export default function ScoreButtons({
|
||||||
|
playerScore,
|
||||||
|
beatSaverMap,
|
||||||
|
isLeaderboardExpanded,
|
||||||
|
setIsLeaderboardExpanded,
|
||||||
|
}: Props) {
|
||||||
const { leaderboard } = playerScore;
|
const { leaderboard } = playerScore;
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<LeaderboardButton
|
||||||
|
isLeaderboardExpanded={isLeaderboardExpanded}
|
||||||
|
setIsLeaderboardExpanded={setIsLeaderboardExpanded}
|
||||||
|
/>
|
||||||
<div className="flex flex-row justify-center flex-wrap gap-1 lg:justify-end">
|
<div className="flex flex-row justify-center flex-wrap gap-1 lg:justify-end">
|
||||||
{beatSaverMap != undefined && (
|
{beatSaverMap != undefined && (
|
||||||
<>
|
<>
|
||||||
@ -61,5 +75,6 @@ export default function ScoreButtons({ playerScore, beatSaverMap }: Props) {
|
|||||||
<YouTubeLogo />
|
<YouTubeLogo />
|
||||||
</ScoreButton>
|
</ScoreButton>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import ScoreSaberPlayerScore from "@/common/data-fetcher/types/scoresaber/scoresaber-player-score";
|
import ScoreSaberLeaderboard from "@/common/data-fetcher/types/scoresaber/scoresaber-leaderboard";
|
||||||
import BeatSaverMap from "@/common/database/types/beatsaver-map";
|
import BeatSaverMap from "@/common/database/types/beatsaver-map";
|
||||||
import { getDifficultyFromScoreSaberDifficulty } from "@/common/scoresaber-utils";
|
import { getDifficultyFromScoreSaberDifficulty } from "@/common/scoresaber-utils";
|
||||||
import { songDifficultyToColor } from "@/common/song-utils";
|
import { songDifficultyToColor } from "@/common/song-utils";
|
||||||
@ -9,12 +9,11 @@ import clsx from "clsx";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
playerScore: ScoreSaberPlayerScore;
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
beatSaverMap?: BeatSaverMap;
|
beatSaverMap?: BeatSaverMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ScoreSongInfo({ playerScore, beatSaverMap }: Props) {
|
export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
|
||||||
const { leaderboard } = playerScore;
|
|
||||||
const diff = getDifficultyFromScoreSaberDifficulty(leaderboard.difficulty.difficulty);
|
const diff = getDifficultyFromScoreSaberDifficulty(leaderboard.difficulty.difficulty);
|
||||||
const mappersProfile =
|
const mappersProfile =
|
||||||
beatSaverMap != undefined ? `https://beatsaver.com/profile/${beatSaverMap?.fullData.uploader.id}` : undefined;
|
beatSaverMap != undefined ? `https://beatsaver.com/profile/${beatSaverMap?.fullData.uploader.id}` : undefined;
|
||||||
|
@ -1,22 +1,27 @@
|
|||||||
import ScoreSaberPlayerScore from "@/common/data-fetcher/types/scoresaber/scoresaber-player-score";
|
import ScoreSaberScore from "@/common/data-fetcher/types/scoresaber/scoresaber-score";
|
||||||
import { formatNumberWithCommas } from "@/common/number-utils";
|
import { formatNumberWithCommas } from "@/common/number-utils";
|
||||||
import { timeAgo } from "@/common/time-utils";
|
import { timeAgo } from "@/common/time-utils";
|
||||||
import { GlobeAmericasIcon } from "@heroicons/react/24/solid";
|
import { GlobeAmericasIcon } from "@heroicons/react/24/solid";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
playerScore: ScoreSaberPlayerScore;
|
score: ScoreSaberScore;
|
||||||
|
isLeaderboard?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ScoreRankInfo({ playerScore }: Props) {
|
export default function ScoreRankInfo({ score, isLeaderboard = false }: Props) {
|
||||||
const { score } = playerScore;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-row justify-between items-center lg:w-[125px] lg:justify-center lg:flex-col">
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"flex w-full flex-row justify-between lg:w-[125px] lg:flex-col",
|
||||||
|
!isLeaderboard && "lg:justify-center items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex gap-1 items-center">
|
<div className="flex gap-1 items-center">
|
||||||
<GlobeAmericasIcon className="w-5 h-5" />
|
<GlobeAmericasIcon className="w-5 h-5" />
|
||||||
<p className="text-pp">#{formatNumberWithCommas(score.rank)}</p>
|
<p className="text-pp">#{formatNumberWithCommas(score.rank)}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm">{timeAgo(new Date(score.timeSet))}</p>
|
{!isLeaderboard && <p className="text-sm">{timeAgo(new Date(score.timeSet))}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import ScoreSaberPlayerScore from "@/common/data-fetcher/types/scoresaber/scoresaber-player-score";
|
import ScoreSaberLeaderboard from "@/common/data-fetcher/types/scoresaber/scoresaber-leaderboard";
|
||||||
|
import ScoreSaberScore from "@/common/data-fetcher/types/scoresaber/scoresaber-score";
|
||||||
import { formatNumberWithCommas } from "@/common/number-utils";
|
import { formatNumberWithCommas } from "@/common/number-utils";
|
||||||
import StatValue from "@/components/stat-value";
|
import StatValue from "@/components/stat-value";
|
||||||
import { XMarkIcon } from "@heroicons/react/24/solid";
|
import { XMarkIcon } from "@heroicons/react/24/solid";
|
||||||
@ -6,14 +7,13 @@ import clsx from "clsx";
|
|||||||
|
|
||||||
type Badge = {
|
type Badge = {
|
||||||
name: string;
|
name: string;
|
||||||
create: (playerScore: ScoreSaberPlayerScore) => string | React.ReactNode | undefined;
|
create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => string | React.ReactNode | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const badges: Badge[] = [
|
const badges: Badge[] = [
|
||||||
{
|
{
|
||||||
name: "PP",
|
name: "PP",
|
||||||
create: (playerScore: ScoreSaberPlayerScore) => {
|
create: (score: ScoreSaberScore) => {
|
||||||
const { score } = playerScore;
|
|
||||||
const pp = score.pp;
|
const pp = score.pp;
|
||||||
if (pp === 0) {
|
if (pp === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -23,16 +23,14 @@ const badges: Badge[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Accuracy",
|
name: "Accuracy",
|
||||||
create: (playerScore: ScoreSaberPlayerScore) => {
|
create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => {
|
||||||
const { score, leaderboard } = playerScore;
|
|
||||||
const acc = (score.baseScore / leaderboard.maxScore) * 100;
|
const acc = (score.baseScore / leaderboard.maxScore) * 100;
|
||||||
return `${acc.toFixed(2)}%`;
|
return `${acc.toFixed(2)}%`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Score",
|
name: "Score",
|
||||||
create: (playerScore: ScoreSaberPlayerScore) => {
|
create: (score: ScoreSaberScore) => {
|
||||||
const { score } = playerScore;
|
|
||||||
return `${formatNumberWithCommas(score.baseScore)}`;
|
return `${formatNumberWithCommas(score.baseScore)}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -46,8 +44,7 @@ const badges: Badge[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Full Combo",
|
name: "Full Combo",
|
||||||
create: (playerScore: ScoreSaberPlayerScore) => {
|
create: (score: ScoreSaberScore) => {
|
||||||
const { score } = playerScore;
|
|
||||||
const fullCombo = score.missedNotes === 0;
|
const fullCombo = score.missedNotes === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -61,14 +58,15 @@ const badges: Badge[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
playerScore: ScoreSaberPlayerScore;
|
score: ScoreSaberScore;
|
||||||
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ScoreStats({ playerScore }: Props) {
|
export default function ScoreStats({ score, leaderboard }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className={`grid grid-cols-3 grid-rows-2 gap-1 ml-0 lg:ml-2`}>
|
<div className={`grid grid-cols-3 grid-rows-2 gap-1 ml-0 lg:ml-2`}>
|
||||||
{badges.map((badge, index) => {
|
{badges.map((badge, index) => {
|
||||||
const toRender = badge.create(playerScore);
|
const toRender = badge.create(score, leaderboard);
|
||||||
if (toRender === undefined) {
|
if (toRender === undefined) {
|
||||||
return <div key={index} />;
|
return <div key={index} />;
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
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 LeaderboardScores from "@/components/leaderboard/leaderboard-scores";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import ScoreButtons from "./score-buttons";
|
import ScoreButtons from "./score-buttons";
|
||||||
import ScoreSongInfo from "./score-info";
|
import ScoreSongInfo from "./score-info";
|
||||||
@ -17,8 +18,9 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Score({ playerScore }: Props) {
|
export default function Score({ playerScore }: Props) {
|
||||||
const { leaderboard } = playerScore;
|
const { score, leaderboard } = playerScore;
|
||||||
const [beatSaverMap, setBeatSaverMap] = useState<BeatSaverMap | undefined>();
|
const [beatSaverMap, setBeatSaverMap] = useState<BeatSaverMap | undefined>();
|
||||||
|
const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false);
|
||||||
|
|
||||||
const fetchBeatSaverData = useCallback(async () => {
|
const fetchBeatSaverData = useCallback(async () => {
|
||||||
const beatSaverMap = await beatsaverFetcher.lookupMap(leaderboard.songHash);
|
const beatSaverMap = await beatsaverFetcher.lookupMap(leaderboard.songHash);
|
||||||
@ -30,11 +32,19 @@ export default function Score({ playerScore }: Props) {
|
|||||||
}, [fetchBeatSaverData]);
|
}, [fetchBeatSaverData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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="pb-2 pt-2">
|
||||||
<ScoreRankInfo playerScore={playerScore} />
|
<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.85fr_4fr_1fr_300px]">
|
||||||
<ScoreSongInfo playerScore={playerScore} beatSaverMap={beatSaverMap} />
|
<ScoreRankInfo score={score} />
|
||||||
<ScoreButtons playerScore={playerScore} beatSaverMap={beatSaverMap} />
|
<ScoreSongInfo leaderboard={leaderboard} beatSaverMap={beatSaverMap} />
|
||||||
<ScoreStats playerScore={playerScore} />
|
<ScoreButtons
|
||||||
|
playerScore={playerScore}
|
||||||
|
beatSaverMap={beatSaverMap}
|
||||||
|
isLeaderboardExpanded={isLeaderboardExpanded}
|
||||||
|
setIsLeaderboardExpanded={setIsLeaderboardExpanded}
|
||||||
|
/>
|
||||||
|
<ScoreStats score={score} leaderboard={leaderboard} />
|
||||||
|
</div>
|
||||||
|
{isLeaderboardExpanded && <LeaderboardScores leaderboard={leaderboard} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,13 @@
|
|||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "tailwind.config.ts"],
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
"tailwind.config.ts",
|
||||||
|
"src/components/leaderboard/leaderboard-score-statstsx"
|
||||||
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user