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);
|
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
|
||||||
|
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) {
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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";
|
"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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user