feat(ssr): add map and bsr button
All checks were successful
deploy / deploy (push) Successful in 1m3s
All checks were successful
deploy / deploy (push) Successful in 1m3s
This commit is contained in:
parent
c3889a4d5a
commit
b9472ce982
32
src/app/api/beatsaver/mapdata/route.ts
Normal file
32
src/app/api/beatsaver/mapdata/route.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { BeatsaverMap } from "@/schemas/beatsaver/BeatsaverMap";
|
||||
import { BeatsaverAPI } from "@/utils/beatsaver/api";
|
||||
|
||||
const mapCache = new Map<string, BeatsaverMap>();
|
||||
|
||||
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<string, BeatsaverMap | { id: string }> = {};
|
||||
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" },
|
||||
});
|
||||
}
|
||||
}
|
@ -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";
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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<string, ScoresaberScoreWithBeatsaverData>;
|
||||
};
|
||||
|
||||
type ScoresProps = {
|
||||
initalScores: ScoresaberPlayerScore[] | undefined;
|
||||
initalScores: Record<string, ScoresaberScoreWithBeatsaverData> | 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({
|
||||
<div className="flex h-full w-full flex-col items-center justify-center">
|
||||
<>
|
||||
<div className="grid min-w-full grid-cols-1 divide-y divide-border">
|
||||
{scores.scores.map((scoreData, id) => {
|
||||
const { score, leaderboard } = scoreData;
|
||||
{Object.values(scores.scores).map((scoreData, id) => {
|
||||
const { score, leaderboard, mapId } = scoreData;
|
||||
|
||||
return (
|
||||
<Score
|
||||
@ -158,6 +161,7 @@ export default function Scores({
|
||||
player={playerData}
|
||||
score={score}
|
||||
leaderboard={leaderboard}
|
||||
mapId={mapId}
|
||||
ownProfile={settingsStore?.player}
|
||||
/>
|
||||
);
|
||||
|
34
src/components/score/CopyBsrButton.tsx
Normal file
34
src/components/score/CopyBsrButton.tsx
Normal file
@ -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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
className="h-[30px] w-[30px] p-1"
|
||||
variant={"secondary"}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`!bsr ${mapId}`);
|
||||
toast.success("Copied BSR code to clipboard");
|
||||
}}
|
||||
>
|
||||
<p>!</p>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div>
|
||||
<p>Click to copy the BSR code</p>
|
||||
<p>!bsr {mapId}</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
@ -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 (
|
||||
<div className="grid grid-cols-1 pb-2 pt-2 first:pt-0 last:pb-0 md:grid-cols-[0.85fr_6fr_1.3fr]">
|
||||
<div className="grid grid-cols-1 pb-2 pt-2 first:pt-0 last:pb-0 md:grid-cols-[0.85fr_6fr_0.5fr_1.3fr]">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="hidden w-fit flex-row items-center justify-center gap-1 md:flex">
|
||||
<GlobeAsiaAustraliaIcon width={20} height={20} />
|
||||
@ -117,6 +122,30 @@ export default function Score({
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center justify-between gap-1 p-1 md:flex md:items-start md:justify-end">
|
||||
{mapId && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Link href={`https://beatsaver.com/maps/${mapId}`}>
|
||||
<Button
|
||||
className="h-[30px] w-[30px] p-1"
|
||||
variant={"secondary"}
|
||||
>
|
||||
<BeatSaverLogo size={20} />
|
||||
</Button>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Click to open the map page</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<CopyBsrButton mapId={mapId} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-1 md:items-start md:justify-end">
|
||||
<div className="flex flex-col md:hidden">
|
||||
{/* Score rank */}
|
10
src/schemas/scoresaber/scoreWithBeatsaverData.ts
Normal file
10
src/schemas/scoresaber/scoreWithBeatsaverData.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { ScoresaberLeaderboardInfo } from "./leaderboard";
|
||||
import { ScoresaberScore } from "./score";
|
||||
|
||||
export type ScoresaberScoreWithBeatsaverData = {
|
||||
score: ScoresaberScore;
|
||||
leaderboard: ScoresaberLeaderboardInfo;
|
||||
|
||||
// Beatsaver data
|
||||
mapId: string;
|
||||
};
|
@ -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<string, ScoresaberScoreWithBeatsaverData>;
|
||||
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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user