diff --git a/src/app/api/beatsaver/mapdata/route.ts b/src/app/api/beatsaver/mapdata/route.ts new file mode 100644 index 0000000..2c9eb82 --- /dev/null +++ b/src/app/api/beatsaver/mapdata/route.ts @@ -0,0 +1,32 @@ +import { BeatsaverMap } from "@/schemas/beatsaver/BeatsaverMap"; +import { BeatsaverAPI } from "@/utils/beatsaver/api"; + +const mapCache = new Map(); + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const mapHashes = searchParams.get("hashes")?.split(",") ?? undefined; + if (!mapHashes) { + return new Response("mapHashes parameter is required", { status: 400 }); + } + const idOnly = searchParams.get("idonly") === "true"; + + const maps: Record = {}; + for (const mapHash of mapHashes) { + if (mapCache.has(mapHash)) { + maps[mapHash] = mapCache.get(mapHash)!; + } else { + const map = await BeatsaverAPI.fetchMapByHash(mapHash); + if (map) { + maps[mapHash] = map; + } + if (map && idOnly) { + maps[mapHash] = { id: map.id }; + } + } + + return new Response(JSON.stringify(maps), { + headers: { "content-type": "application/json;charset=UTF-8" }, + }); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 028306d..145a9c2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,7 @@ import clsx from "clsx"; import { Metadata, Viewport } from "next"; import { Inter } from "next/font/google"; import Script from "next/script"; + import "react-toastify/dist/ReactToastify.css"; import "./globals.css"; diff --git a/src/app/player/[id]/[sort]/[page]/page.tsx b/src/app/player/[id]/[sort]/[page]/page.tsx index 3ee6b44..65b9471 100644 --- a/src/app/player/[id]/[sort]/[page]/page.tsx +++ b/src/app/player/[id]/[sort]/[page]/page.tsx @@ -69,7 +69,12 @@ export async function generateMetadata({ */ async function getData(id: string, page: number, sort: string) { const playerData = await ScoreSaberAPI.fetchPlayerData(id); - const playerScores = await ScoreSaberAPI.fetchScores(id, page, sort, 10); + const playerScores = await ScoreSaberAPI.fetchScoresWithBeatsaverData( + id, + page, + sort, + 10, + ); return { playerData: playerData, playerScores: playerScores, diff --git a/src/components/player/Scores.tsx b/src/components/player/Scores.tsx index f2c939d..772e37a 100644 --- a/src/components/player/Scores.tsx +++ b/src/components/player/Scores.tsx @@ -1,7 +1,7 @@ "use client"; import { ScoresaberPlayer } from "@/schemas/scoresaber/player"; -import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore"; +import { ScoresaberScoreWithBeatsaverData } from "@/schemas/scoresaber/scoreWithBeatsaverData"; import { useSettingsStore } from "@/store/settingsStore"; import { SortType, SortTypes } from "@/types/SortTypes"; import { ScoreSaberAPI } from "@/utils/scoresaber/api"; @@ -10,17 +10,17 @@ import { useCallback, useEffect, useState } from "react"; import Card from "../Card"; import Error from "../Error"; import Pagination from "../Pagination"; -import Score from "./Score"; +import Score from "../score/Score"; type PageInfo = { page: number; totalPages: number; sortType: SortType; - scores: ScoresaberPlayerScore[]; + scores: Record; }; type ScoresProps = { - initalScores: ScoresaberPlayerScore[] | undefined; + initalScores: Record | undefined; initalPage: number; initalSortType: SortType; initalTotalPages?: number; @@ -45,7 +45,7 @@ export default function Scores({ page: initalPage, totalPages: initalTotalPages || 1, sortType: initalSortType, - scores: initalScores ? initalScores : [], + scores: initalScores ? initalScores : {}, }); const [changedPage, setChangedPage] = useState(false); @@ -61,32 +61,35 @@ export default function Scores({ return; } - ScoreSaberAPI.fetchScores(playerId, page, sortType.value, 10).then( - (scoresResponse) => { - if (!scoresResponse) { - setError(true); - setErrorMessage("No Scores"); - setScores({ ...scores }); - return; - } - setScores({ - ...scores, - scores: scoresResponse.scores, - totalPages: scoresResponse.pageInfo.totalPages, - page: page, - sortType: sortType, - }); - settingsStore?.setLastUsedSortType(sortType); - window.history.pushState( - {}, - "", - `/player/${playerId}/${sortType.value}/${page}`, - ); - setChangedPage(true); + ScoreSaberAPI.fetchScoresWithBeatsaverData( + playerId, + page, + sortType.value, + 10, + ).then((scoresResponse) => { + if (!scoresResponse) { + setError(true); + setErrorMessage("No Scores"); + setScores({ ...scores }); + return; + } + setScores({ + ...scores, + scores: scoresResponse.scores, + totalPages: scoresResponse.pageInfo.totalPages, + page: page, + sortType: sortType, + }); + settingsStore?.setLastUsedSortType(sortType); + window.history.pushState( + {}, + "", + `/player/${playerId}/${sortType.value}/${page}`, + ); + setChangedPage(true); - console.log(`Switched page to ${page} with sort ${sortType.value}`); - }, - ); + console.log(`Switched page to ${page} with sort ${sortType.value}`); + }); }, [ changedPage, @@ -149,8 +152,8 @@ export default function Scores({
<>
- {scores.scores.map((scoreData, id) => { - const { score, leaderboard } = scoreData; + {Object.values(scores.scores).map((scoreData, id) => { + const { score, leaderboard, mapId } = scoreData; return ( ); diff --git a/src/components/score/CopyBsrButton.tsx b/src/components/score/CopyBsrButton.tsx new file mode 100644 index 0000000..87c9310 --- /dev/null +++ b/src/components/score/CopyBsrButton.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { toast } from "react-toastify"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/Tooltip"; +import { Button } from "../ui/button"; + +type CopyBsrButtonProps = { + mapId: string; +}; + +export default function CopyBsrButton({ mapId }: CopyBsrButtonProps) { + return ( + + + + + +
+

Click to copy the BSR code

+

!bsr {mapId}

+
+
+
+ ); +} diff --git a/src/components/player/Score.tsx b/src/components/score/Score.tsx similarity index 87% rename from src/components/player/Score.tsx rename to src/components/score/Score.tsx index 6a25f82..94129fb 100644 --- a/src/components/player/Score.tsx +++ b/src/components/score/Score.tsx @@ -17,15 +17,19 @@ import { import clsx from "clsx"; import Image from "next/image"; import Link from "next/link"; +import BeatSaverLogo from "../icons/BeatSaverLogo"; import HeadsetIcon from "../icons/HeadsetIcon"; +import ScoreStatLabel from "../player/ScoreStatLabel"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/Tooltip"; -import ScoreStatLabel from "./ScoreStatLabel"; +import { Button } from "../ui/button"; +import CopyBsrButton from "./CopyBsrButton"; type ScoreProps = { score: ScoresaberScore; player: ScoresaberPlayer; leaderboard: ScoresaberLeaderboardInfo; ownProfile?: ScoresaberPlayer; + mapId?: string; }; export default function Score({ @@ -33,6 +37,7 @@ export default function Score({ player, leaderboard, ownProfile, + mapId, }: ScoreProps) { const isFullCombo = score.missedNotes + score.badCuts === 0; const diffName = scoresaberDifficultyNumberToName( @@ -44,7 +49,7 @@ export default function Score({ const weightedPp = formatNumber(getPpGainedFromScore(player.id, score), 2); return ( -
+
@@ -117,6 +122,30 @@ export default function Score({
+
+ {mapId && ( + <> + + + + + + + +

Click to open the map page

+
+
+ + + + )} +
+
{/* Score rank */} diff --git a/src/schemas/scoresaber/scoreWithBeatsaverData.ts b/src/schemas/scoresaber/scoreWithBeatsaverData.ts new file mode 100644 index 0000000..1621881 --- /dev/null +++ b/src/schemas/scoresaber/scoreWithBeatsaverData.ts @@ -0,0 +1,10 @@ +import { ScoresaberLeaderboardInfo } from "./leaderboard"; +import { ScoresaberScore } from "./score"; + +export type ScoresaberScoreWithBeatsaverData = { + score: ScoresaberScore; + leaderboard: ScoresaberLeaderboardInfo; + + // Beatsaver data + mapId: string; +}; diff --git a/src/utils/scoresaber/api.ts b/src/utils/scoresaber/api.ts index 3daba81..a6a2465 100644 --- a/src/utils/scoresaber/api.ts +++ b/src/utils/scoresaber/api.ts @@ -2,9 +2,11 @@ import { ScoresaberLeaderboardInfo } from "@/schemas/scoresaber/leaderboard"; import { ScoresaberPlayer } from "@/schemas/scoresaber/player"; import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore"; import { ScoresaberScore } from "@/schemas/scoresaber/score"; +import { ScoresaberScoreWithBeatsaverData } from "@/schemas/scoresaber/scoreWithBeatsaverData"; import ssrSettings from "@/ssrSettings.json"; import { FetchQueue } from "../fetchWithQueue"; import { formatString } from "../string"; +import { isProduction } from "../utils"; // Create a fetch instance with a cache export const ScoresaberFetchQueue = new FetchQueue(); @@ -123,6 +125,73 @@ async function fetchScores( }; } +async function fetchScoresWithBeatsaverData( + playerId: string, + page: number = 1, + searchType: string = SearchType.RECENT, + limit: number = 100, +): Promise< + | { + scores: Record; + pageInfo: { + totalScores: number; + page: number; + totalPages: number; + }; + } + | undefined +> { + if (limit > 100) { + throw new Error("Limit cannot be greater than 100"); + } + const response = await ScoresaberFetchQueue.fetch( + formatString(SS_PLAYER_SCORES, true, playerId, limit, searchType, page), + ); + const json = await response.json(); + + // Check if there was an error fetching the user data + if (json.errorMessage) { + return undefined; + } + + const scores = json.playerScores as ScoresaberPlayerScore[]; + const metadata = json.metadata; + + // Fetch the beatsaver data for each score + const scoresWithBeatsaverData: Record< + string, + ScoresaberScoreWithBeatsaverData + > = {}; + for (const score of scores) { + const mapResponse = await fetch( + `${ + isProduction() ? ssrSettings.siteUrl : "http://localhost:3000" + }/api/beatsaver/mapdata?hashes=${score.leaderboard.songHash}&idonly=true`, + { + next: { + revalidate: 60 * 60 * 24 * 7, // 1 week + }, + }, + ); + const mapData = await mapResponse.json(); + const mapId = mapData[score.leaderboard.songHash].id; + scoresWithBeatsaverData[score.score.id] = { + score: score.score, + leaderboard: score.leaderboard, + mapId: mapId, + }; + } + + return { + scores: scoresWithBeatsaverData, + pageInfo: { + totalScores: metadata.total, + page: metadata.page, + totalPages: Math.ceil(metadata.total / metadata.itemsPerPage), + }, + }; +} + /** * Gets all of the players for the given player id * @@ -275,6 +344,7 @@ export const ScoreSaberAPI = { searchByName, fetchPlayerData, fetchScores, + fetchScoresWithBeatsaverData, fetchAllScores, fetchTopPlayers, fetchLeaderboardInfo,