From 14845c0377552851ec688c79d2f145a49fdad0ab Mon Sep 17 00:00:00 2001 From: Liam Date: Thu, 12 Sep 2024 22:30:55 +0100 Subject: [PATCH] add basic leaderboard dropdown on scores --- src/common/data-fetcher/impl/scoresaber.ts | 29 ++++++ .../scoresaber-leaderboard-scores-page.ts | 14 +++ .../leaderboard/leaderboard-player.tsx | 27 ++++++ .../leaderboard/leaderboard-score-stats.tsx | 64 +++++++++++++ .../leaderboard/leaderboard-score.tsx | 31 ++++++ .../leaderboard/leaderboard-scores.tsx | 85 +++++++++++++++++ .../player/score/leaderboard-button.tsx | 21 ++++ src/components/player/score/score-buttons.tsx | 95 +++++++++++-------- src/components/player/score/score-info.tsx | 7 +- .../player/score/score-rank-info.tsx | 19 ++-- src/components/player/score/score-stats.tsx | 24 +++-- src/components/player/score/score.tsx | 22 +++-- tsconfig.json | 9 +- 13 files changed, 376 insertions(+), 71 deletions(-) create mode 100644 src/common/data-fetcher/types/scoresaber/scoresaber-leaderboard-scores-page.ts create mode 100644 src/components/leaderboard/leaderboard-player.tsx create mode 100644 src/components/leaderboard/leaderboard-score-stats.tsx create mode 100644 src/components/leaderboard/leaderboard-score.tsx create mode 100644 src/components/leaderboard/leaderboard-scores.tsx create mode 100644 src/components/player/score/leaderboard-button.tsx diff --git a/src/common/data-fetcher/impl/scoresaber.ts b/src/common/data-fetcher/impl/scoresaber.ts index 6426da7..9f01f2a 100644 --- a/src/common/data-fetcher/impl/scoresaber.ts +++ b/src/common/data-fetcher/impl/scoresaber.ts @@ -1,5 +1,6 @@ import DataFetcher from "../data-fetcher"; import { ScoreSort } from "../sort"; +import ScoreSaberLeaderboardScoresPage from "../types/scoresaber/scoresaber-leaderboard-scores-page"; import ScoreSaberPlayer from "../types/scoresaber/scoresaber-player"; import ScoreSaberPlayerScoresPage from "../types/scoresaber/scoresaber-player-scores-page"; 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 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_LEADERBOARD_SCORES_ENDPOINT = `${API_BASE}/leaderboard/by-id/:id/scores?page=:page`; class ScoreSaberFetcher extends DataFetcher { constructor() { @@ -87,6 +89,33 @@ class ScoreSaberFetcher extends DataFetcher { this.log(`Found scores for player "${playerId}" in ${(performance.now() - before).toFixed(0)}ms`); 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 { + const before = performance.now(); + this.log(`Looking up scores for leaderboard "${leaderboardId}", page "${page}"...`); + const response = await this.fetch( + 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(); diff --git a/src/common/data-fetcher/types/scoresaber/scoresaber-leaderboard-scores-page.ts b/src/common/data-fetcher/types/scoresaber/scoresaber-leaderboard-scores-page.ts new file mode 100644 index 0000000..7070853 --- /dev/null +++ b/src/common/data-fetcher/types/scoresaber/scoresaber-leaderboard-scores-page.ts @@ -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; +} diff --git a/src/components/leaderboard/leaderboard-player.tsx b/src/components/leaderboard/leaderboard-player.tsx new file mode 100644 index 0000000..031ec91 --- /dev/null +++ b/src/components/leaderboard/leaderboard-player.tsx @@ -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 ( +
+ Song Artwork +
+

{player.name}

+
+
+ ); +} diff --git a/src/components/leaderboard/leaderboard-score-stats.tsx b/src/components/leaderboard/leaderboard-score-stats.tsx new file mode 100644 index 0000000..dc77685 --- /dev/null +++ b/src/components/leaderboard/leaderboard-score-stats.tsx @@ -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 ( + <> +

{fullCombo ? FC : formatNumberWithCommas(score.missedNotes)}

+ + + ); + }, + }, +]; + +type Props = { + score: ScoreSaberScore; + leaderboard: ScoreSaberLeaderboard; +}; + +export default function LeaderboardScoreStats({ score, leaderboard }: Props) { + return ( +
+ {badges.map((badge, index) => { + const toRender = badge.create(score, leaderboard); + if (toRender === undefined) { + return
; + } + + return ; + })} +
+ ); +} diff --git a/src/components/leaderboard/leaderboard-score.tsx b/src/components/leaderboard/leaderboard-score.tsx new file mode 100644 index 0000000..d617386 --- /dev/null +++ b/src/components/leaderboard/leaderboard-score.tsx @@ -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 ( +
+ + +

{timeAgo(new Date(score.timeSet))}

+ +
+ ); +} diff --git a/src/components/leaderboard/leaderboard-scores.tsx b/src/components/leaderboard/leaderboard-scores.tsx new file mode 100644 index 0000000..1f71c4e --- /dev/null +++ b/src/components/leaderboard/leaderboard-scores.tsx @@ -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(); + + 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 ( + +
+ {isError &&

Oopsies! Something went wrong.

} + {currentScores.scores.length === 0 &&

No scores found. Invalid Page?

} +
+ + +
+ {currentScores.scores.map((playerScore, index) => ( + + ))} +
+
+ + +
+ ); +} diff --git a/src/components/player/score/leaderboard-button.tsx b/src/components/player/score/leaderboard-button.tsx new file mode 100644 index 0000000..e5b0ad3 --- /dev/null +++ b/src/components/player/score/leaderboard-button.tsx @@ -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>; +}; + +export default function LeaderboardButton({ isLeaderboardExpanded, setIsLeaderboardExpanded }: Props) { + return ( +
+ +
+ ); +} diff --git a/src/components/player/score/score-buttons.tsx b/src/components/player/score/score-buttons.tsx index 8be984f..8cc9d94 100644 --- a/src/components/player/score/score-buttons.tsx +++ b/src/components/player/score/score-buttons.tsx @@ -7,59 +7,74 @@ 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 ScoreButton from "./score-button"; type Props = { playerScore: ScoreSaberPlayerScore; beatSaverMap?: BeatSaverMap; + isLeaderboardExpanded: boolean; + setIsLeaderboardExpanded: Dispatch>; }; -export default function ScoreButtons({ playerScore, beatSaverMap }: Props) { +export default function ScoreButtons({ + playerScore, + beatSaverMap, + isLeaderboardExpanded, + setIsLeaderboardExpanded, +}: Props) { const { leaderboard } = playerScore; const { toast } = useToast(); return ( -
- {beatSaverMap != undefined && ( - <> - {/* Copy BSR */} - { - toast({ - title: "Copied!", - description: `Copied "!bsr ${beatSaverMap}" to your clipboard!`, - }); - copyToClipboard(`!bsr ${beatSaverMap.bsr}`); - }} - tooltip={

Click to copy the bsr code

} - > -

!

-
+
+ +
+ {beatSaverMap != undefined && ( + <> + {/* Copy BSR */} + { + toast({ + title: "Copied!", + description: `Copied "!bsr ${beatSaverMap}" to your clipboard!`, + }); + copyToClipboard(`!bsr ${beatSaverMap.bsr}`); + }} + tooltip={

Click to copy the bsr code

} + > +

!

+
- {/* Open map in BeatSaver */} - { - window.open(`https://beatsaver.com/maps/${beatSaverMap.bsr}`, "_blank"); - }} - tooltip={

Click to open the map

} - > - -
- - )} + {/* Open map in BeatSaver */} + { + window.open(`https://beatsaver.com/maps/${beatSaverMap.bsr}`, "_blank"); + }} + tooltip={

Click to open the map

} + > + +
+ + )} - {/* Open song in YouTube */} - { - window.open( - songNameToYouTubeLink(leaderboard.songName, leaderboard.songSubName, leaderboard.songAuthorName), - "_blank" - ); - }} - tooltip={

Click to open the song in YouTube

} - > - -
+ {/* Open song in YouTube */} + { + window.open( + songNameToYouTubeLink(leaderboard.songName, leaderboard.songSubName, leaderboard.songAuthorName), + "_blank" + ); + }} + tooltip={

Click to open the song in YouTube

} + > + +
+
); } diff --git a/src/components/player/score/score-info.tsx b/src/components/player/score/score-info.tsx index 9881781..9ee1dc1 100644 --- a/src/components/player/score/score-info.tsx +++ b/src/components/player/score/score-info.tsx @@ -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 { getDifficultyFromScoreSaberDifficulty } from "@/common/scoresaber-utils"; import { songDifficultyToColor } from "@/common/song-utils"; @@ -9,12 +9,11 @@ import clsx from "clsx"; import Image from "next/image"; type Props = { - playerScore: ScoreSaberPlayerScore; + leaderboard: ScoreSaberLeaderboard; beatSaverMap?: BeatSaverMap; }; -export default function ScoreSongInfo({ playerScore, beatSaverMap }: Props) { - const { leaderboard } = playerScore; +export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) { const diff = getDifficultyFromScoreSaberDifficulty(leaderboard.difficulty.difficulty); const mappersProfile = beatSaverMap != undefined ? `https://beatsaver.com/profile/${beatSaverMap?.fullData.uploader.id}` : undefined; diff --git a/src/components/player/score/score-rank-info.tsx b/src/components/player/score/score-rank-info.tsx index bd780af..5dac96e 100644 --- a/src/components/player/score/score-rank-info.tsx +++ b/src/components/player/score/score-rank-info.tsx @@ -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 { timeAgo } from "@/common/time-utils"; import { GlobeAmericasIcon } from "@heroicons/react/24/solid"; +import clsx from "clsx"; type Props = { - playerScore: ScoreSaberPlayerScore; + score: ScoreSaberScore; + isLeaderboard?: boolean; }; -export default function ScoreRankInfo({ playerScore }: Props) { - const { score } = playerScore; - +export default function ScoreRankInfo({ score, isLeaderboard = false }: Props) { return ( -
+

#{formatNumberWithCommas(score.rank)}

-

{timeAgo(new Date(score.timeSet))}

+ {!isLeaderboard &&

{timeAgo(new Date(score.timeSet))}

}
); } diff --git a/src/components/player/score/score-stats.tsx b/src/components/player/score/score-stats.tsx index 9cdb9f7..abdef81 100644 --- a/src/components/player/score/score-stats.tsx +++ b/src/components/player/score/score-stats.tsx @@ -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 StatValue from "@/components/stat-value"; import { XMarkIcon } from "@heroicons/react/24/solid"; @@ -6,14 +7,13 @@ import clsx from "clsx"; type Badge = { name: string; - create: (playerScore: ScoreSaberPlayerScore) => string | React.ReactNode | undefined; + create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => string | React.ReactNode | undefined; }; const badges: Badge[] = [ { name: "PP", - create: (playerScore: ScoreSaberPlayerScore) => { - const { score } = playerScore; + create: (score: ScoreSaberScore) => { const pp = score.pp; if (pp === 0) { return undefined; @@ -23,16 +23,14 @@ const badges: Badge[] = [ }, { name: "Accuracy", - create: (playerScore: ScoreSaberPlayerScore) => { - const { score, leaderboard } = playerScore; + create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => { const acc = (score.baseScore / leaderboard.maxScore) * 100; return `${acc.toFixed(2)}%`; }, }, { name: "Score", - create: (playerScore: ScoreSaberPlayerScore) => { - const { score } = playerScore; + create: (score: ScoreSaberScore) => { return `${formatNumberWithCommas(score.baseScore)}`; }, }, @@ -46,8 +44,7 @@ const badges: Badge[] = [ }, { name: "Full Combo", - create: (playerScore: ScoreSaberPlayerScore) => { - const { score } = playerScore; + create: (score: ScoreSaberScore) => { const fullCombo = score.missedNotes === 0; return ( @@ -61,14 +58,15 @@ const badges: Badge[] = [ ]; type Props = { - playerScore: ScoreSaberPlayerScore; + score: ScoreSaberScore; + leaderboard: ScoreSaberLeaderboard; }; -export default function ScoreStats({ playerScore }: Props) { +export default function ScoreStats({ score, leaderboard }: Props) { return (
{badges.map((badge, index) => { - const toRender = badge.create(playerScore); + const toRender = badge.create(score, leaderboard); if (toRender === undefined) { return
; } diff --git a/src/components/player/score/score.tsx b/src/components/player/score/score.tsx index 3c2ce28..4f7ae89 100644 --- a/src/components/player/score/score.tsx +++ b/src/components/player/score/score.tsx @@ -3,6 +3,7 @@ import { beatsaverFetcher } from "@/common/data-fetcher/impl/beatsaver"; import ScoreSaberPlayerScore from "@/common/data-fetcher/types/scoresaber/scoresaber-player-score"; import BeatSaverMap from "@/common/database/types/beatsaver-map"; +import LeaderboardScores from "@/components/leaderboard/leaderboard-scores"; import { useCallback, useEffect, useState } from "react"; import ScoreButtons from "./score-buttons"; import ScoreSongInfo from "./score-info"; @@ -17,8 +18,9 @@ type Props = { }; export default function Score({ playerScore }: Props) { - const { leaderboard } = playerScore; + const { score, leaderboard } = playerScore; const [beatSaverMap, setBeatSaverMap] = useState(); + const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false); const fetchBeatSaverData = useCallback(async () => { const beatSaverMap = await beatsaverFetcher.lookupMap(leaderboard.songHash); @@ -30,11 +32,19 @@ export default function Score({ playerScore }: Props) { }, [fetchBeatSaverData]); return ( -
- - - - +
+
+ + + + +
+ {isLeaderboardExpanded && }
); } diff --git a/tsconfig.json b/tsconfig.json index 0effe2e..16bc772 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,13 @@ "@/*": ["./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"] }