add leaderboard page
Some checks failed
Deploy / deploy (push) Failing after 2m11s

This commit is contained in:
Lee 2024-10-01 19:14:33 +01:00
parent d4f7aec4a5
commit 3a4bc7a83a
15 changed files with 473 additions and 70 deletions

@ -0,0 +1,108 @@
import { scoresaberService } from "@/common/service/impl/scoresaber";
import { Metadata, Viewport } from "next";
import { redirect } from "next/navigation";
import { Colors } from "@/common/colors";
import { getAverageColor } from "@/common/image-utils";
import { cache } from "react";
import ScoreSaberLeaderboardScoresPageToken from "@/common/model/token/scoresaber/score-saber-leaderboard-scores-page-token";
import { LeaderboardData } from "@/components/leaderboard/leaderboard-data";
const UNKNOWN_LEADERBOARD = {
title: "ScoreSaber Reloaded - Unknown Leaderboard",
description: "The leaderboard you were looking for could not be found.",
};
type Props = {
params: Promise<{
slug: string[];
}>;
searchParams: Promise<{
[key: string]: string | undefined;
}>;
};
/**
* Gets the leaderboard data and scores
*
* @param params the params
* @param fetchScores whether to fetch the scores
* @returns the leaderboard data and scores
*/
const getLeaderboardData = cache(async ({ params }: Props, fetchScores: boolean = true) => {
const { slug } = await params;
const id = slug[0]; // The leaderboard id
const page = parseInt(slug[1]) || 1; // The page number
const leaderboard = await scoresaberService.lookupLeaderboard(id);
let scores: ScoreSaberLeaderboardScoresPageToken | undefined;
if (fetchScores) {
scores = await scoresaberService.lookupLeaderboardScores(id + "", page);
}
return {
page: page,
leaderboard: leaderboard,
scores: scores,
};
});
export async function generateMetadata(props: Props): Promise<Metadata> {
const { leaderboard } = await getLeaderboardData(props, false);
if (leaderboard === undefined) {
return {
title: UNKNOWN_LEADERBOARD.title,
description: UNKNOWN_LEADERBOARD.description,
openGraph: {
title: UNKNOWN_LEADERBOARD.title,
description: UNKNOWN_LEADERBOARD.description,
},
};
}
return {
title: `${leaderboard.songName}`,
openGraph: {
title: `ScoreSaber Reloaded - ${leaderboard.songName}`,
description: `
View the scores on ${leaderboard.songName}!`,
images: [
{
url: leaderboard.coverImage,
},
],
},
twitter: {
card: "summary",
},
};
}
export async function generateViewport(props: Props): Promise<Viewport> {
const { leaderboard } = await getLeaderboardData(props, false);
if (leaderboard === undefined) {
return {
themeColor: Colors.primary,
};
}
const color = await getAverageColor(leaderboard.coverImage);
if (color === undefined) {
return {
themeColor: Colors.primary,
};
}
return {
themeColor: color?.hex,
};
}
export default async function LeaderboardPage(props: Props) {
const { leaderboard, scores, page } = await getLeaderboardData(props);
if (leaderboard == undefined) {
return redirect("/");
}
return <LeaderboardData initialLeaderboard={leaderboard} initialPage={page} initialScores={scores} />;
}

@ -113,7 +113,7 @@ export async function generateViewport(props: Props): Promise<Viewport> {
}; };
} }
export default async function Search(props: Props) { export default async function PlayerPage(props: Props) {
const { player, scores, sort, page, search } = await getPlayerData(props); const { player, scores, sort, page, search } = await getPlayerData(props);
if (player == undefined) { if (player == undefined) {
return redirect("/"); return redirect("/");

@ -5,7 +5,7 @@ export const metadata: Metadata = {
title: "Search", title: "Search",
}; };
export default function Search() { export default function SearchPage() {
return ( return (
<div className="flex flex-col items-center justify-center gap-2"> <div className="flex flex-col items-center justify-center gap-2">
<div className="mb-4 mt-2 flex h-[150px] w-[150px] items-center justify-center rounded-full select-none bg-gray-600"> <div className="mb-4 mt-2 flex h-[150px] w-[150px] items-center justify-center rounded-full select-none bg-gray-600">

@ -77,9 +77,9 @@ export default function RootLayout({
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange> <ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
<QueryProvider> <QueryProvider>
<AnimatePresence> <AnimatePresence>
<main className="flex flex-col min-h-screen gap-2 text-white"> <main className="flex flex-col min-h-screen gap-2 text-white w-full">
<NavBar /> <NavBar />
<div className="z-[1] m-auto flex flex-col flex-grow items-center md:max-w-[1600px]"> <div className="z-[1] m-auto flex flex-col flex-grow items-center w-full md:max-w-[1600px]">
{children} {children}
</div> </div>
<Footer /> <Footer />

@ -24,7 +24,7 @@ export const getAverageColor = cache(async (src: string) => {
const before = performance.now(); const before = performance.now();
console.log(`Getting average color of "${src}"...`); console.log(`Getting average color of "${src}"...`);
try { try {
const response = await ky.get(`https://img.fascinated.cc/upload/w_64,h_64/${src}`); const response = await ky.get(`https://img.fascinated.cc/upload/w_64,h_64,o_jpg/${src}`);
if (response.status !== 200) { if (response.status !== 200) {
return undefined; return undefined;
} }

@ -6,13 +6,23 @@ import { ScoreSaberPlayersPageToken } from "@/common/model/token/scoresaber/scor
import { ScoreSort } from "../../model/score/score-sort"; import { ScoreSort } from "../../model/score/score-sort";
import Service from "../service"; import Service from "../service";
import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@/common/model/player/impl/scoresaber-player"; import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@/common/model/player/impl/scoresaber-player";
import ScoreSaberLeaderboardToken from "@/common/model/token/scoresaber/score-saber-leaderboard-token";
const API_BASE = "https://scoresaber.com/api"; const API_BASE = "https://scoresaber.com/api";
/**
* Player
*/
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_PLAYERS_ENDPOINT = `${API_BASE}/players?page=:page`; const LOOKUP_PLAYERS_ENDPOINT = `${API_BASE}/players?page=:page`;
const LOOKUP_PLAYERS_BY_COUNTRY_ENDPOINT = `${API_BASE}/players?page=:page&countries=:country`; const LOOKUP_PLAYERS_BY_COUNTRY_ENDPOINT = `${API_BASE}/players?page=:page&countries=:country`;
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`;
/**
* Leaderboard
*/
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 LOOKUP_LEADERBOARD_SCORES_ENDPOINT = `${API_BASE}/leaderboard/by-id/:id/scores?page=:page`;
class ScoreSaberService extends Service { class ScoreSaberService extends Service {
@ -165,11 +175,30 @@ class ScoreSaberService extends Service {
return response; return response;
} }
/**
* Looks up a leaderboard
*
* @param leaderboardId the ID of the leaderboard to look up
* @param useProxy whether to use the proxy or not
*/
async lookupLeaderboard(leaderboardId: string, useProxy = true): Promise<ScoreSaberLeaderboardToken | undefined> {
const before = performance.now();
this.log(`Looking up leaderboard "${leaderboardId}"...`);
const response = await this.fetch<ScoreSaberLeaderboardToken>(
useProxy,
LOOKUP_LEADERBOARD_ENDPOINT.replace(":id", leaderboardId)
);
if (response === undefined) {
return undefined;
}
this.log(`Found leaderboard "${leaderboardId}" in ${(performance.now() - before).toFixed(0)}ms`);
return response;
}
/** /**
* Looks up a page of scores for a leaderboard * Looks up a page of scores for a leaderboard
* *
* @param leaderboardId the ID of the leaderboard to look up * @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 page the page to get scores for
* @param useProxy whether to use the proxy or not * @param useProxy whether to use the proxy or not
* @returns the scores of the leaderboard, or undefined * @returns the scores of the leaderboard, or undefined

@ -0,0 +1,6 @@
"use client";
import { createContext } from "react";
import ScoreSaberLeaderboardToken from "@/common/model/token/scoresaber/score-saber-leaderboard-token";
export const LeaderboardContext = createContext<ScoreSaberLeaderboardToken | undefined>(undefined);

@ -0,0 +1,80 @@
"use client";
import ScoreSaberLeaderboardScoresPageToken from "@/common/model/token/scoresaber/score-saber-leaderboard-scores-page-token";
import ScoreSaberLeaderboardToken from "@/common/model/token/scoresaber/score-saber-leaderboard-token";
import { LeaderboardContext } from "@/components/context/leaderboard-context";
import LeaderboardScores from "@/components/leaderboard/leaderboard-scores";
import { LeaderboardInfo } from "@/components/leaderboard/leaderboard-info";
import { useQuery } from "@tanstack/react-query";
import { scoresaberService } from "@/common/service/impl/scoresaber";
import { useCallback, useEffect, useState } from "react";
import BeatSaverMap from "@/common/database/types/beatsaver-map";
import { beatsaverService } from "@/common/service/impl/beatsaver";
type LeaderboardDataProps = {
/**
* The page to show when opening the leaderboard.
*/
initialPage?: number;
/**
* The initial scores to show.
*/
initialScores?: ScoreSaberLeaderboardScoresPageToken;
/**
* The leaderboard to display.
*/
initialLeaderboard: ScoreSaberLeaderboardToken;
};
export function LeaderboardData({ initialPage, initialScores, initialLeaderboard }: LeaderboardDataProps) {
const [beatSaverMap, setBeatSaverMap] = useState<BeatSaverMap | undefined>();
const [selectedLeaderboardId, setSelectedLeaderboardId] = useState(initialLeaderboard.id);
const [currentLeaderboard, setCurrentLeaderboard] = useState(initialLeaderboard);
const { data: leaderboard } = useQuery({
queryKey: ["leaderboard-" + initialLeaderboard.id, selectedLeaderboardId],
queryFn: () => scoresaberService.lookupLeaderboard(selectedLeaderboardId + ""),
staleTime: 30 * 1000, // Cache data for 30 seconds
});
const fetchBeatSaverData = useCallback(async () => {
const beatSaverMap = await beatsaverService.lookupMap(initialLeaderboard.songHash);
setBeatSaverMap(beatSaverMap);
}, [initialLeaderboard.songHash]);
useEffect(() => {
fetchBeatSaverData();
}, [fetchBeatSaverData]);
/**
* When the leaderboard changes, update the previous and current leaderboards.
* This is to prevent flickering between leaderboards.
*/
useEffect(() => {
if (leaderboard) {
setCurrentLeaderboard(leaderboard);
}
}, [leaderboard]);
if (!currentLeaderboard) {
return null;
}
return (
<main className="flex flex-col-reverse xl:flex-row w-full gap-2">
<LeaderboardContext.Provider value={currentLeaderboard}>
<LeaderboardScores
leaderboard={currentLeaderboard}
initialScores={initialScores}
initialPage={initialPage}
showDifficulties
isLeaderboardPage
leaderboardChanged={id => setSelectedLeaderboardId(id)}
/>
<LeaderboardInfo leaderboard={currentLeaderboard} beatSaverMap={beatSaverMap} />
</LeaderboardContext.Provider>
</main>
);
}

@ -0,0 +1,65 @@
import Card from "@/components/card";
import ScoreSaberLeaderboardToken from "@/common/model/token/scoresaber/score-saber-leaderboard-token";
import Image from "next/image";
import { LeaderboardSongStarCount } from "@/components/leaderboard/leaderboard-song-star-count";
import ScoreButtons from "@/components/score/score-buttons";
import BeatSaverMap from "@/common/database/types/beatsaver-map";
type LeaderboardInfoProps = {
/**
* The leaderboard to display.
*/
leaderboard: ScoreSaberLeaderboardToken;
/**
* The beat saver map associated with the leaderboard.
*/
beatSaverMap?: BeatSaverMap;
};
export function LeaderboardInfo({ leaderboard, beatSaverMap }: LeaderboardInfoProps) {
return (
<Card className="xl:w-[500px] h-fit w-full">
<div className="flex flex-row justify-between w-full">
<div className="flex flex-col justify-between w-full min-h-[160px]">
{/* Song Info */}
<div className="flex flex-col gap-1">
<p className="font-semibold">
{leaderboard.songName} {leaderboard.songSubName}
</p>
<p className="text-sm text-gray-400">
By <span className="text-pp">{leaderboard.songAuthorName}</span>
</p>
</div>
{/* Song Stats */}
<div className="text-sm">
<p>
Mapper: <span className="text-pp font-semibold">{leaderboard.levelAuthorName}</span>
</p>
<p>
Plays: <span className="font-semibold">{leaderboard.plays}</span> ({leaderboard.dailyPlays} today)
</p>
<p>
Status: <span className="font-semibold">{leaderboard.stars > 0 ? "Ranked" : "Unranked"}</span>
</p>
</div>
</div>
<Image
src={leaderboard.coverImage}
alt={`${leaderboard.songName} Cover Image`}
className="rounded-md w-fit h-fit"
width={96}
height={96}
/>
<div className="relative">
<div className="absolute bottom-0 right-0 w-fit h-fit flex flex-col gap-2 items-end">
<LeaderboardSongStarCount leaderboard={leaderboard} />
<ScoreButtons leaderboard={leaderboard} beatSaverMap={beatSaverMap} alwaysSingleLine />
</div>
</div>
</div>
</Card>
);
}

@ -24,7 +24,7 @@ type Props = {
export default function LeaderboardScore({ player, score, leaderboard }: Props) { export default function LeaderboardScore({ player, score, leaderboard }: Props) {
return ( return (
<div className="pb-1.5 pt-1.5"> <div className="py-1.5">
<div className="grid items-center w-full gap-2 grid-cols-[20px 1fr_1fr] lg:grid-cols-[130px_4fr_300px]"> <div className="grid items-center w-full gap-2 grid-cols-[20px 1fr_1fr] lg:grid-cols-[130px_4fr_300px]">
<ScoreRankInfo score={score} /> <ScoreRankInfo score={score} />
<LeaderboardPlayer player={player} score={score} /> <LeaderboardPlayer player={player} score={score} />

@ -12,6 +12,9 @@ import Pagination from "../input/pagination";
import LeaderboardScore from "./leaderboard-score"; import LeaderboardScore from "./leaderboard-score";
import { scoreAnimation } from "@/components/score/score-animation"; import { scoreAnimation } from "@/components/score/score-animation";
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player"; import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
import { Button } from "@/components/ui/button";
import { getDifficultyFromScoreSaberDifficulty } from "@/common/scoresaber-utils";
import { clsx } from "clsx";
type LeaderboardScoresProps = { type LeaderboardScoresProps = {
/** /**
@ -19,6 +22,11 @@ type LeaderboardScoresProps = {
*/ */
initialPage?: number; initialPage?: number;
/**
* The initial scores to show.
*/
initialScores?: ScoreSaberLeaderboardScoresPageToken;
/** /**
* The player who set the score. * The player who set the score.
*/ */
@ -28,29 +36,56 @@ type LeaderboardScoresProps = {
* The leaderboard to display. * The leaderboard to display.
*/ */
leaderboard: ScoreSaberLeaderboardToken; leaderboard: ScoreSaberLeaderboardToken;
/**
* Whether to show the difficulties.
*/
showDifficulties?: boolean;
/**
* Whether this is the full leaderboard page.
*/
isLeaderboardPage?: boolean;
/**
* Called when the leaderboard changes.
*
* @param id the new leaderboard id
*/
leaderboardChanged?: (id: number) => void;
}; };
export default function LeaderboardScores({ initialPage, player, leaderboard }: LeaderboardScoresProps) { export default function LeaderboardScores({
initialPage,
initialScores,
player,
leaderboard,
showDifficulties,
isLeaderboardPage,
leaderboardChanged,
}: LeaderboardScoresProps) {
if (!initialPage) { if (!initialPage) {
initialPage = 1; initialPage = 1;
} }
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const controls = useAnimation(); const controls = useAnimation();
const [selectedLeaderboardId, setSelectedLeaderboardId] = useState(leaderboard.id);
const [previousPage, setPreviousPage] = useState(initialPage); const [previousPage, setPreviousPage] = useState(initialPage);
const [currentPage, setCurrentPage] = useState(initialPage); const [currentPage, setCurrentPage] = useState(initialPage);
const [currentScores, setCurrentScores] = useState<ScoreSaberLeaderboardScoresPageToken | undefined>(); const [currentScores, setCurrentScores] = useState<ScoreSaberLeaderboardScoresPageToken | undefined>(initialScores);
const topOfScoresRef = useRef<HTMLDivElement>(null); const topOfScoresRef = useRef<HTMLDivElement>(null);
const [shouldFetch, setShouldFetch] = useState(false); // New state to control fetching
const { const {
data: scores, data: scores,
isError, isError,
isLoading, isLoading,
refetch,
} = useQuery({ } = useQuery({
queryKey: ["playerScores", leaderboard.id, currentPage], queryKey: ["leaderboardScores-" + leaderboard.id, selectedLeaderboardId, currentPage],
queryFn: () => scoresaberService.lookupLeaderboardScores(leaderboard.id + "", currentPage), queryFn: () => scoresaberService.lookupLeaderboardScores(selectedLeaderboardId + "", currentPage),
staleTime: 30 * 1000, // Cache data for 30 seconds staleTime: 30 * 1000, // Cache data for 30 seconds
enabled: shouldFetch || isLeaderboardPage,
}); });
/** /**
@ -62,6 +97,25 @@ export default function LeaderboardScores({ initialPage, player, leaderboard }:
await controls.start("visible"); await controls.start("visible");
}, [controls, currentPage, previousPage, scores]); }, [controls, currentPage, previousPage, scores]);
/**
* Set the selected leaderboard.
*/
const handleLeaderboardChange = useCallback(
(id: number) => {
setSelectedLeaderboardId(id);
setCurrentPage(1);
setShouldFetch(true);
if (leaderboardChanged) {
leaderboardChanged(id);
}
// Update the URL
window.history.replaceState(null, "", `/leaderboard/${id}`);
},
[leaderboardChanged]
);
/** /**
* Set the current scores. * Set the current scores.
*/ */
@ -71,13 +125,6 @@ export default function LeaderboardScores({ initialPage, player, leaderboard }:
} }
}, [scores, handleScoreAnimation]); }, [scores, handleScoreAnimation]);
/**
* Handle page change.
*/
useEffect(() => {
refetch();
}, [leaderboard, currentPage, refetch]);
/** /**
* Handle scrolling to the top of the * Handle scrolling to the top of the
* scores when new scores are loaded. * scores when new scores are loaded.
@ -97,40 +144,55 @@ export default function LeaderboardScores({ initialPage, player, leaderboard }:
} }
return ( return (
<motion.div initial={{ opacity: 0, y: -50 }} exit={{ opacity: 0, y: -50 }} animate={{ opacity: 1, y: 0 }}> <Card className={clsx("flex gap-2 w-full relative", !isLeaderboardPage && "border border-input")}>
<Card className="flex gap-2 border border-input mt-2"> {/* Where to scroll to when new scores are loaded */}
{/* Where to scroll to when new scores are loaded */} <div ref={topOfScoresRef} className="absolute" />
<div ref={topOfScoresRef} className="absolute" />
<div className="text-center"> <div className="text-center">
{isError && <p>Oopsies! Something went wrong.</p>} {isError && <p>Oopsies! Something went wrong.</p>}
{currentScores.scores.length === 0 && <p>No scores found. Invalid Page?</p>} {currentScores.scores.length === 0 && <p>No scores found. Invalid Page?</p>}
</div> </div>
<motion.div <div className="flex gap-2 justify-center items-center">
initial="hidden" {showDifficulties &&
animate={controls} leaderboard.difficulties.map(({ difficulty, leaderboardId }) => {
variants={scoreAnimation} return (
className="grid min-w-full grid-cols-1 divide-y divide-border" <Button
> variant={leaderboardId === selectedLeaderboardId ? "default" : "outline"}
{currentScores.scores.map((playerScore, index) => ( onClick={() => {
<motion.div key={index} variants={scoreAnimation}> handleLeaderboardChange(leaderboardId);
<LeaderboardScore key={index} player={player} score={playerScore} leaderboard={leaderboard} /> }}
</motion.div> >
))} {getDifficultyFromScoreSaberDifficulty(difficulty)}
</motion.div> </Button>
);
})}
</div>
<Pagination <motion.div
mobilePagination={width < 768} initial="hidden"
page={currentPage} animate={controls}
totalPages={Math.ceil(currentScores.metadata.total / currentScores.metadata.itemsPerPage)} variants={scoreAnimation}
loadingPage={isLoading ? currentPage : undefined} className="grid min-w-full grid-cols-1 divide-y divide-border"
onPageChange={newPage => { >
setCurrentPage(newPage); {currentScores.scores.map((playerScore, index) => (
setPreviousPage(currentPage); <motion.div key={index} variants={scoreAnimation}>
}} <LeaderboardScore key={index} player={player} score={playerScore} leaderboard={leaderboard} />
/> </motion.div>
</Card> ))}
</motion.div> </motion.div>
<Pagination
mobilePagination={width < 768}
page={currentPage}
totalPages={Math.ceil(currentScores.metadata.total / currentScores.metadata.itemsPerPage)}
loadingPage={isLoading ? currentPage : undefined}
onPageChange={newPage => {
setCurrentPage(newPage);
setPreviousPage(currentPage);
setShouldFetch(true);
}}
/>
</Card>
); );
} }

@ -0,0 +1,32 @@
import { songDifficultyToColor } from "@/common/song-utils";
import { StarIcon } from "@heroicons/react/24/solid";
import ScoreSaberLeaderboardToken from "@/common/model/token/scoresaber/score-saber-leaderboard-token";
import { getDifficultyFromScoreSaberDifficulty } from "@/common/scoresaber-utils";
type LeaderboardSongStarCountProps = {
/**
* The leaderboard for the song
*/
leaderboard: ScoreSaberLeaderboardToken;
};
export function LeaderboardSongStarCount({ leaderboard }: LeaderboardSongStarCountProps) {
if (leaderboard.stars <= 0) {
return null;
}
const diff = getDifficultyFromScoreSaberDifficulty(leaderboard.difficulty.difficulty);
return (
<div
className="w-fit h-[20px] rounded-sm flex justify-center items-center text-xs cursor-default"
style={{
backgroundColor: songDifficultyToColor(diff) + "f0", // Transparency value (in hex 0-255)
}}
>
<div className="flex gap-1 items-center justify-center p-1">
<p>{leaderboard.stars}</p>
<StarIcon className="w-4 h-4" />
</div>
</div>
);
}

@ -1,7 +1,6 @@
"use client"; "use client";
import { copyToClipboard } from "@/common/browser-utils"; import { copyToClipboard } from "@/common/browser-utils";
import ScoreSaberPlayerScoreToken from "@/common/model/token/scoresaber/score-saber-player-score-token";
import BeatSaverMap from "@/common/database/types/beatsaver-map"; import BeatSaverMap from "@/common/database/types/beatsaver-map";
import { songNameToYouTubeLink } from "@/common/youtube-utils"; import { songNameToYouTubeLink } from "@/common/youtube-utils";
import BeatSaverLogo from "@/components/logos/beatsaver-logo"; import BeatSaverLogo from "@/components/logos/beatsaver-logo";
@ -10,26 +9,30 @@ import { useToast } from "@/hooks/use-toast";
import { Dispatch, SetStateAction } from "react"; import { Dispatch, SetStateAction } from "react";
import LeaderboardButton from "./leaderboard-button"; import LeaderboardButton from "./leaderboard-button";
import ScoreButton from "./score-button"; import ScoreButton from "./score-button";
import ScoreSaberLeaderboardToken from "@/common/model/token/scoresaber/score-saber-leaderboard-token";
type Props = { type Props = {
playerScore: ScoreSaberPlayerScoreToken; leaderboard: ScoreSaberLeaderboardToken;
beatSaverMap?: BeatSaverMap; beatSaverMap?: BeatSaverMap;
isLeaderboardExpanded: boolean; alwaysSingleLine?: boolean;
setIsLeaderboardExpanded: Dispatch<SetStateAction<boolean>>; isLeaderboardExpanded?: boolean;
setIsLeaderboardExpanded?: Dispatch<SetStateAction<boolean>>;
}; };
export default function ScoreButtons({ export default function ScoreButtons({
playerScore, leaderboard,
beatSaverMap, beatSaverMap,
alwaysSingleLine,
isLeaderboardExpanded, isLeaderboardExpanded,
setIsLeaderboardExpanded, setIsLeaderboardExpanded,
}: Props) { }: Props) {
const { leaderboard } = playerScore;
const { toast } = useToast(); const { toast } = useToast();
return ( return (
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<div className="flex flex-row items-center lg:items-start justify-center flex-wrap gap-1 lg:justify-end"> <div
className={`flex ${alwaysSingleLine ? "flex-nowrap" : "flex-wrap"} items-center lg:items-start justify-center lg:justify-end gap-1`}
>
{beatSaverMap != undefined && ( {beatSaverMap != undefined && (
<> <>
{/* Copy BSR */} {/* Copy BSR */}
@ -71,10 +74,12 @@ export default function ScoreButtons({
<YouTubeLogo /> <YouTubeLogo />
</ScoreButton> </ScoreButton>
</div> </div>
<LeaderboardButton {isLeaderboardExpanded && setIsLeaderboardExpanded && (
isLeaderboardExpanded={isLeaderboardExpanded} <LeaderboardButton
setIsLeaderboardExpanded={setIsLeaderboardExpanded} isLeaderboardExpanded={isLeaderboardExpanded}
/> setIsLeaderboardExpanded={setIsLeaderboardExpanded}
/>
)}
</div> </div>
); );
} }

@ -7,6 +7,7 @@ import { StarIcon } from "@heroicons/react/24/solid";
import clsx from "clsx"; import clsx from "clsx";
import Image from "next/image"; import Image from "next/image";
import { songDifficultyToColor } from "@/common/song-utils"; import { songDifficultyToColor } from "@/common/song-utils";
import Link from "next/link";
type Props = { type Props = {
leaderboard: ScoreSaberLeaderboardToken; leaderboard: ScoreSaberLeaderboardToken;
@ -63,10 +64,15 @@ export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
</div> </div>
<div className="flex"> <div className="flex">
<div className="overflow-y-clip"> <div className="overflow-y-clip">
<p className="text-pp"> <Link
{leaderboard.songName} {leaderboard.songSubName} href={`/leaderboard/${leaderboard.id}`}
</p> className="cursor-pointer select-none hover:brightness-75 transform-gpu transition-all"
<p className="text-sm text-gray-400">{leaderboard.songAuthorName}</p> >
<p className="text-pp">
{leaderboard.songName} {leaderboard.songSubName}
</p>
<p className="text-sm text-gray-400">{leaderboard.songAuthorName}</p>
</Link>
<FallbackLink href={mappersProfile}> <FallbackLink href={mappersProfile}>
<p className={clsx("text-sm", mappersProfile && "hover:brightness-75 transform-gpu transition-all w-fit")}> <p className={clsx("text-sm", mappersProfile && "hover:brightness-75 transform-gpu transition-all w-fit")}>
{leaderboard.levelAuthorName} {leaderboard.levelAuthorName}

@ -4,12 +4,13 @@ import BeatSaverMap from "@/common/database/types/beatsaver-map";
import ScoreSaberPlayerScoreToken from "@/common/model/token/scoresaber/score-saber-player-score-token"; import ScoreSaberPlayerScoreToken from "@/common/model/token/scoresaber/score-saber-player-score-token";
import { beatsaverService } from "@/common/service/impl/beatsaver"; import { beatsaverService } from "@/common/service/impl/beatsaver";
import LeaderboardScores from "@/components/leaderboard/leaderboard-scores"; import LeaderboardScores from "@/components/leaderboard/leaderboard-scores";
import { cache, 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";
import ScoreRankInfo from "./score-rank-info"; import ScoreRankInfo from "./score-rank-info";
import ScoreStats from "./score-stats"; import ScoreStats from "./score-stats";
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player"; import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
import { motion } from "framer-motion";
type Props = { type Props = {
/** /**
@ -29,7 +30,7 @@ export default function Score({ player, playerScore }: Props) {
const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false); const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false);
const fetchBeatSaverData = useCallback(async () => { const fetchBeatSaverData = useCallback(async () => {
const beatSaverMap = await cache(async () => await beatsaverService.lookupMap(leaderboard.songHash))(); const beatSaverMap = await beatsaverService.lookupMap(leaderboard.songHash);
setBeatSaverMap(beatSaverMap); setBeatSaverMap(beatSaverMap);
}, [leaderboard.songHash]); }, [leaderboard.songHash]);
@ -46,14 +47,23 @@ export default function Score({ player, playerScore }: Props) {
<ScoreRankInfo score={score} /> <ScoreRankInfo score={score} />
<ScoreSongInfo leaderboard={leaderboard} beatSaverMap={beatSaverMap} /> <ScoreSongInfo leaderboard={leaderboard} beatSaverMap={beatSaverMap} />
<ScoreButtons <ScoreButtons
playerScore={playerScore} leaderboard={leaderboard}
beatSaverMap={beatSaverMap} beatSaverMap={beatSaverMap}
isLeaderboardExpanded={isLeaderboardExpanded} isLeaderboardExpanded={isLeaderboardExpanded}
setIsLeaderboardExpanded={setIsLeaderboardExpanded} setIsLeaderboardExpanded={setIsLeaderboardExpanded}
/> />
<ScoreStats score={score} leaderboard={leaderboard} /> <ScoreStats score={score} leaderboard={leaderboard} />
</div> </div>
{isLeaderboardExpanded && <LeaderboardScores initialPage={page} player={player} leaderboard={leaderboard} />} {isLeaderboardExpanded && (
<motion.div
initial={{ opacity: 0, y: -50 }}
exit={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0 }}
className="w-full mt-2"
>
<LeaderboardScores initialPage={page} player={player} leaderboard={leaderboard} />
</motion.div>
)}
</div> </div>
); );
} }