This commit is contained in:
parent
d4f7aec4a5
commit
3a4bc7a83a
108
src/app/(pages)/leaderboard/[...slug]/page.tsx
Normal file
108
src/app/(pages)/leaderboard/[...slug]/page.tsx
Normal file
@ -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);
|
||||
if (player == undefined) {
|
||||
return redirect("/");
|
||||
|
@ -5,7 +5,7 @@ export const metadata: Metadata = {
|
||||
title: "Search",
|
||||
};
|
||||
|
||||
export default function Search() {
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<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">
|
||||
|
@ -77,9 +77,9 @@ export default function RootLayout({
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
|
||||
<QueryProvider>
|
||||
<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 />
|
||||
<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}
|
||||
</div>
|
||||
<Footer />
|
||||
|
@ -24,7 +24,7 @@ export const getAverageColor = cache(async (src: string) => {
|
||||
const before = performance.now();
|
||||
console.log(`Getting average color of "${src}"...`);
|
||||
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) {
|
||||
return undefined;
|
||||
}
|
||||
|
@ -6,13 +6,23 @@ import { ScoreSaberPlayersPageToken } from "@/common/model/token/scoresaber/scor
|
||||
import { ScoreSort } from "../../model/score/score-sort";
|
||||
import Service from "../service";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Player
|
||||
*/
|
||||
const SEARCH_PLAYERS_ENDPOINT = `${API_BASE}/players?search=:query`;
|
||||
const LOOKUP_PLAYER_ENDPOINT = `${API_BASE}/player/:id/full`;
|
||||
const LOOKUP_PLAYERS_ENDPOINT = `${API_BASE}/players?page=:page`;
|
||||
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`;
|
||||
|
||||
/**
|
||||
* 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`;
|
||||
|
||||
class ScoreSaberService extends Service {
|
||||
@ -165,11 +175,30 @@ class ScoreSaberService extends Service {
|
||||
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
|
||||
*
|
||||
* @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
|
||||
|
6
src/components/context/leaderboard-context.tsx
Normal file
6
src/components/context/leaderboard-context.tsx
Normal file
@ -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);
|
80
src/components/leaderboard/leaderboard-data.tsx
Normal file
80
src/components/leaderboard/leaderboard-data.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
65
src/components/leaderboard/leaderboard-info.tsx
Normal file
65
src/components/leaderboard/leaderboard-info.tsx
Normal file
@ -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) {
|
||||
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]">
|
||||
<ScoreRankInfo score={score} />
|
||||
<LeaderboardPlayer player={player} score={score} />
|
||||
|
@ -12,6 +12,9 @@ import Pagination from "../input/pagination";
|
||||
import LeaderboardScore from "./leaderboard-score";
|
||||
import { scoreAnimation } from "@/components/score/score-animation";
|
||||
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 = {
|
||||
/**
|
||||
@ -19,6 +22,11 @@ type LeaderboardScoresProps = {
|
||||
*/
|
||||
initialPage?: number;
|
||||
|
||||
/**
|
||||
* The initial scores to show.
|
||||
*/
|
||||
initialScores?: ScoreSaberLeaderboardScoresPageToken;
|
||||
|
||||
/**
|
||||
* The player who set the score.
|
||||
*/
|
||||
@ -28,29 +36,56 @@ type LeaderboardScoresProps = {
|
||||
* The leaderboard to display.
|
||||
*/
|
||||
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) {
|
||||
initialPage = 1;
|
||||
}
|
||||
const { width } = useWindowDimensions();
|
||||
const controls = useAnimation();
|
||||
|
||||
const [selectedLeaderboardId, setSelectedLeaderboardId] = useState(leaderboard.id);
|
||||
const [previousPage, setPreviousPage] = 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 [shouldFetch, setShouldFetch] = useState(false); // New state to control fetching
|
||||
|
||||
const {
|
||||
data: scores,
|
||||
isError,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["playerScores", leaderboard.id, currentPage],
|
||||
queryFn: () => scoresaberService.lookupLeaderboardScores(leaderboard.id + "", currentPage),
|
||||
queryKey: ["leaderboardScores-" + leaderboard.id, selectedLeaderboardId, currentPage],
|
||||
queryFn: () => scoresaberService.lookupLeaderboardScores(selectedLeaderboardId + "", currentPage),
|
||||
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");
|
||||
}, [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.
|
||||
*/
|
||||
@ -71,13 +125,6 @@ export default function LeaderboardScores({ initialPage, player, leaderboard }:
|
||||
}
|
||||
}, [scores, handleScoreAnimation]);
|
||||
|
||||
/**
|
||||
* Handle page change.
|
||||
*/
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [leaderboard, currentPage, refetch]);
|
||||
|
||||
/**
|
||||
* Handle scrolling to the top of the
|
||||
* scores when new scores are loaded.
|
||||
@ -97,40 +144,55 @@ export default function LeaderboardScores({ initialPage, player, leaderboard }:
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: -50 }} exit={{ opacity: 0, y: -50 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<Card className="flex gap-2 border border-input mt-2">
|
||||
{/* Where to scroll to when new scores are loaded */}
|
||||
<div ref={topOfScoresRef} className="absolute" />
|
||||
<Card className={clsx("flex gap-2 w-full relative", !isLeaderboardPage && "border border-input")}>
|
||||
{/* Where to scroll to when new scores are loaded */}
|
||||
<div ref={topOfScoresRef} className="absolute" />
|
||||
|
||||
<div className="text-center">
|
||||
{isError && <p>Oopsies! Something went wrong.</p>}
|
||||
{currentScores.scores.length === 0 && <p>No scores found. Invalid Page?</p>}
|
||||
</div>
|
||||
<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
|
||||
initial="hidden"
|
||||
animate={controls}
|
||||
variants={scoreAnimation}
|
||||
className="grid min-w-full grid-cols-1 divide-y divide-border"
|
||||
>
|
||||
{currentScores.scores.map((playerScore, index) => (
|
||||
<motion.div key={index} variants={scoreAnimation}>
|
||||
<LeaderboardScore key={index} player={player} score={playerScore} leaderboard={leaderboard} />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
<div className="flex gap-2 justify-center items-center">
|
||||
{showDifficulties &&
|
||||
leaderboard.difficulties.map(({ difficulty, leaderboardId }) => {
|
||||
return (
|
||||
<Button
|
||||
variant={leaderboardId === selectedLeaderboardId ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
handleLeaderboardChange(leaderboardId);
|
||||
}}
|
||||
>
|
||||
{getDifficultyFromScoreSaberDifficulty(difficulty)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</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);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate={controls}
|
||||
variants={scoreAnimation}
|
||||
className="grid min-w-full grid-cols-1 divide-y divide-border"
|
||||
>
|
||||
{currentScores.scores.map((playerScore, index) => (
|
||||
<motion.div key={index} variants={scoreAnimation}>
|
||||
<LeaderboardScore key={index} player={player} score={playerScore} leaderboard={leaderboard} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
32
src/components/leaderboard/leaderboard-song-star-count.tsx
Normal file
32
src/components/leaderboard/leaderboard-song-star-count.tsx
Normal file
@ -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";
|
||||
|
||||
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 { songNameToYouTubeLink } from "@/common/youtube-utils";
|
||||
import BeatSaverLogo from "@/components/logos/beatsaver-logo";
|
||||
@ -10,26 +9,30 @@ import { useToast } from "@/hooks/use-toast";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import LeaderboardButton from "./leaderboard-button";
|
||||
import ScoreButton from "./score-button";
|
||||
import ScoreSaberLeaderboardToken from "@/common/model/token/scoresaber/score-saber-leaderboard-token";
|
||||
|
||||
type Props = {
|
||||
playerScore: ScoreSaberPlayerScoreToken;
|
||||
leaderboard: ScoreSaberLeaderboardToken;
|
||||
beatSaverMap?: BeatSaverMap;
|
||||
isLeaderboardExpanded: boolean;
|
||||
setIsLeaderboardExpanded: Dispatch<SetStateAction<boolean>>;
|
||||
alwaysSingleLine?: boolean;
|
||||
isLeaderboardExpanded?: boolean;
|
||||
setIsLeaderboardExpanded?: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export default function ScoreButtons({
|
||||
playerScore,
|
||||
leaderboard,
|
||||
beatSaverMap,
|
||||
alwaysSingleLine,
|
||||
isLeaderboardExpanded,
|
||||
setIsLeaderboardExpanded,
|
||||
}: Props) {
|
||||
const { leaderboard } = playerScore;
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<>
|
||||
{/* Copy BSR */}
|
||||
@ -71,10 +74,12 @@ export default function ScoreButtons({
|
||||
<YouTubeLogo />
|
||||
</ScoreButton>
|
||||
</div>
|
||||
<LeaderboardButton
|
||||
isLeaderboardExpanded={isLeaderboardExpanded}
|
||||
setIsLeaderboardExpanded={setIsLeaderboardExpanded}
|
||||
/>
|
||||
{isLeaderboardExpanded && setIsLeaderboardExpanded && (
|
||||
<LeaderboardButton
|
||||
isLeaderboardExpanded={isLeaderboardExpanded}
|
||||
setIsLeaderboardExpanded={setIsLeaderboardExpanded}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import { StarIcon } from "@heroicons/react/24/solid";
|
||||
import clsx from "clsx";
|
||||
import Image from "next/image";
|
||||
import { songDifficultyToColor } from "@/common/song-utils";
|
||||
import Link from "next/link";
|
||||
|
||||
type Props = {
|
||||
leaderboard: ScoreSaberLeaderboardToken;
|
||||
@ -63,10 +64,15 @@ export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
|
||||
</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>
|
||||
<Link
|
||||
href={`/leaderboard/${leaderboard.id}`}
|
||||
className="cursor-pointer select-none hover:brightness-75 transform-gpu transition-all"
|
||||
>
|
||||
<p className="text-pp">
|
||||
{leaderboard.songName} {leaderboard.songSubName}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">{leaderboard.songAuthorName}</p>
|
||||
</Link>
|
||||
<FallbackLink href={mappersProfile}>
|
||||
<p className={clsx("text-sm", mappersProfile && "hover:brightness-75 transform-gpu transition-all w-fit")}>
|
||||
{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 { beatsaverService } from "@/common/service/impl/beatsaver";
|
||||
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 ScoreSongInfo from "./score-info";
|
||||
import ScoreRankInfo from "./score-rank-info";
|
||||
import ScoreStats from "./score-stats";
|
||||
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
@ -29,7 +30,7 @@ export default function Score({ player, playerScore }: Props) {
|
||||
const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false);
|
||||
|
||||
const fetchBeatSaverData = useCallback(async () => {
|
||||
const beatSaverMap = await cache(async () => await beatsaverService.lookupMap(leaderboard.songHash))();
|
||||
const beatSaverMap = await beatsaverService.lookupMap(leaderboard.songHash);
|
||||
setBeatSaverMap(beatSaverMap);
|
||||
}, [leaderboard.songHash]);
|
||||
|
||||
@ -46,14 +47,23 @@ export default function Score({ player, playerScore }: Props) {
|
||||
<ScoreRankInfo score={score} />
|
||||
<ScoreSongInfo leaderboard={leaderboard} beatSaverMap={beatSaverMap} />
|
||||
<ScoreButtons
|
||||
playerScore={playerScore}
|
||||
leaderboard={leaderboard}
|
||||
beatSaverMap={beatSaverMap}
|
||||
isLeaderboardExpanded={isLeaderboardExpanded}
|
||||
setIsLeaderboardExpanded={setIsLeaderboardExpanded}
|
||||
/>
|
||||
<ScoreStats score={score} leaderboard={leaderboard} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
Reference in New Issue
Block a user