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 { Metadata, Viewport } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
|
|
||||||
import "react-toastify/dist/ReactToastify.css";
|
import "react-toastify/dist/ReactToastify.css";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
|
@ -69,7 +69,12 @@ export async function generateMetadata({
|
|||||||
*/
|
*/
|
||||||
async function getData(id: string, page: number, sort: string) {
|
async function getData(id: string, page: number, sort: string) {
|
||||||
const playerData = await ScoreSaberAPI.fetchPlayerData(id);
|
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 {
|
return {
|
||||||
playerData: playerData,
|
playerData: playerData,
|
||||||
playerScores: playerScores,
|
playerScores: playerScores,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
|
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
|
||||||
import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore";
|
import { ScoresaberScoreWithBeatsaverData } from "@/schemas/scoresaber/scoreWithBeatsaverData";
|
||||||
import { useSettingsStore } from "@/store/settingsStore";
|
import { useSettingsStore } from "@/store/settingsStore";
|
||||||
import { SortType, SortTypes } from "@/types/SortTypes";
|
import { SortType, SortTypes } from "@/types/SortTypes";
|
||||||
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
|
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
|
||||||
@ -10,17 +10,17 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import Card from "../Card";
|
import Card from "../Card";
|
||||||
import Error from "../Error";
|
import Error from "../Error";
|
||||||
import Pagination from "../Pagination";
|
import Pagination from "../Pagination";
|
||||||
import Score from "./Score";
|
import Score from "../score/Score";
|
||||||
|
|
||||||
type PageInfo = {
|
type PageInfo = {
|
||||||
page: number;
|
page: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
sortType: SortType;
|
sortType: SortType;
|
||||||
scores: ScoresaberPlayerScore[];
|
scores: Record<string, ScoresaberScoreWithBeatsaverData>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ScoresProps = {
|
type ScoresProps = {
|
||||||
initalScores: ScoresaberPlayerScore[] | undefined;
|
initalScores: Record<string, ScoresaberScoreWithBeatsaverData> | undefined;
|
||||||
initalPage: number;
|
initalPage: number;
|
||||||
initalSortType: SortType;
|
initalSortType: SortType;
|
||||||
initalTotalPages?: number;
|
initalTotalPages?: number;
|
||||||
@ -45,7 +45,7 @@ export default function Scores({
|
|||||||
page: initalPage,
|
page: initalPage,
|
||||||
totalPages: initalTotalPages || 1,
|
totalPages: initalTotalPages || 1,
|
||||||
sortType: initalSortType,
|
sortType: initalSortType,
|
||||||
scores: initalScores ? initalScores : [],
|
scores: initalScores ? initalScores : {},
|
||||||
});
|
});
|
||||||
const [changedPage, setChangedPage] = useState(false);
|
const [changedPage, setChangedPage] = useState(false);
|
||||||
|
|
||||||
@ -61,32 +61,35 @@ export default function Scores({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ScoreSaberAPI.fetchScores(playerId, page, sortType.value, 10).then(
|
ScoreSaberAPI.fetchScoresWithBeatsaverData(
|
||||||
(scoresResponse) => {
|
playerId,
|
||||||
if (!scoresResponse) {
|
page,
|
||||||
setError(true);
|
sortType.value,
|
||||||
setErrorMessage("No Scores");
|
10,
|
||||||
setScores({ ...scores });
|
).then((scoresResponse) => {
|
||||||
return;
|
if (!scoresResponse) {
|
||||||
}
|
setError(true);
|
||||||
setScores({
|
setErrorMessage("No Scores");
|
||||||
...scores,
|
setScores({ ...scores });
|
||||||
scores: scoresResponse.scores,
|
return;
|
||||||
totalPages: scoresResponse.pageInfo.totalPages,
|
}
|
||||||
page: page,
|
setScores({
|
||||||
sortType: sortType,
|
...scores,
|
||||||
});
|
scores: scoresResponse.scores,
|
||||||
settingsStore?.setLastUsedSortType(sortType);
|
totalPages: scoresResponse.pageInfo.totalPages,
|
||||||
window.history.pushState(
|
page: page,
|
||||||
{},
|
sortType: sortType,
|
||||||
"",
|
});
|
||||||
`/player/${playerId}/${sortType.value}/${page}`,
|
settingsStore?.setLastUsedSortType(sortType);
|
||||||
);
|
window.history.pushState(
|
||||||
setChangedPage(true);
|
{},
|
||||||
|
"",
|
||||||
|
`/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,
|
changedPage,
|
||||||
@ -149,8 +152,8 @@ export default function Scores({
|
|||||||
<div className="flex h-full w-full flex-col items-center justify-center">
|
<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">
|
<div className="grid min-w-full grid-cols-1 divide-y divide-border">
|
||||||
{scores.scores.map((scoreData, id) => {
|
{Object.values(scores.scores).map((scoreData, id) => {
|
||||||
const { score, leaderboard } = scoreData;
|
const { score, leaderboard, mapId } = scoreData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Score
|
<Score
|
||||||
@ -158,6 +161,7 @@ export default function Scores({
|
|||||||
player={playerData}
|
player={playerData}
|
||||||
score={score}
|
score={score}
|
||||||
leaderboard={leaderboard}
|
leaderboard={leaderboard}
|
||||||
|
mapId={mapId}
|
||||||
ownProfile={settingsStore?.player}
|
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 clsx from "clsx";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import BeatSaverLogo from "../icons/BeatSaverLogo";
|
||||||
import HeadsetIcon from "../icons/HeadsetIcon";
|
import HeadsetIcon from "../icons/HeadsetIcon";
|
||||||
|
import ScoreStatLabel from "../player/ScoreStatLabel";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/Tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/Tooltip";
|
||||||
import ScoreStatLabel from "./ScoreStatLabel";
|
import { Button } from "../ui/button";
|
||||||
|
import CopyBsrButton from "./CopyBsrButton";
|
||||||
|
|
||||||
type ScoreProps = {
|
type ScoreProps = {
|
||||||
score: ScoresaberScore;
|
score: ScoresaberScore;
|
||||||
player: ScoresaberPlayer;
|
player: ScoresaberPlayer;
|
||||||
leaderboard: ScoresaberLeaderboardInfo;
|
leaderboard: ScoresaberLeaderboardInfo;
|
||||||
ownProfile?: ScoresaberPlayer;
|
ownProfile?: ScoresaberPlayer;
|
||||||
|
mapId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Score({
|
export default function Score({
|
||||||
@ -33,6 +37,7 @@ export default function Score({
|
|||||||
player,
|
player,
|
||||||
leaderboard,
|
leaderboard,
|
||||||
ownProfile,
|
ownProfile,
|
||||||
|
mapId,
|
||||||
}: ScoreProps) {
|
}: ScoreProps) {
|
||||||
const isFullCombo = score.missedNotes + score.badCuts === 0;
|
const isFullCombo = score.missedNotes + score.badCuts === 0;
|
||||||
const diffName = scoresaberDifficultyNumberToName(
|
const diffName = scoresaberDifficultyNumberToName(
|
||||||
@ -44,7 +49,7 @@ export default function Score({
|
|||||||
const weightedPp = formatNumber(getPpGainedFromScore(player.id, score), 2);
|
const weightedPp = formatNumber(getPpGainedFromScore(player.id, score), 2);
|
||||||
|
|
||||||
return (
|
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="flex flex-col items-center justify-center">
|
||||||
<div className="hidden w-fit flex-row items-center justify-center gap-1 md:flex">
|
<div className="hidden w-fit flex-row items-center justify-center gap-1 md:flex">
|
||||||
<GlobeAsiaAustraliaIcon width={20} height={20} />
|
<GlobeAsiaAustraliaIcon width={20} height={20} />
|
||||||
@ -117,6 +122,30 @@ export default function Score({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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 items-center justify-between p-1 md:items-start md:justify-end">
|
||||||
<div className="flex flex-col md:hidden">
|
<div className="flex flex-col md:hidden">
|
||||||
{/* Score rank */}
|
{/* 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 { ScoresaberPlayer } from "@/schemas/scoresaber/player";
|
||||||
import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore";
|
import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore";
|
||||||
import { ScoresaberScore } from "@/schemas/scoresaber/score";
|
import { ScoresaberScore } from "@/schemas/scoresaber/score";
|
||||||
|
import { ScoresaberScoreWithBeatsaverData } from "@/schemas/scoresaber/scoreWithBeatsaverData";
|
||||||
import ssrSettings from "@/ssrSettings.json";
|
import ssrSettings from "@/ssrSettings.json";
|
||||||
import { FetchQueue } from "../fetchWithQueue";
|
import { FetchQueue } from "../fetchWithQueue";
|
||||||
import { formatString } from "../string";
|
import { formatString } from "../string";
|
||||||
|
import { isProduction } from "../utils";
|
||||||
|
|
||||||
// Create a fetch instance with a cache
|
// Create a fetch instance with a cache
|
||||||
export const ScoresaberFetchQueue = new FetchQueue();
|
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
|
* Gets all of the players for the given player id
|
||||||
*
|
*
|
||||||
@ -275,6 +344,7 @@ export const ScoreSaberAPI = {
|
|||||||
searchByName,
|
searchByName,
|
||||||
fetchPlayerData,
|
fetchPlayerData,
|
||||||
fetchScores,
|
fetchScores,
|
||||||
|
fetchScoresWithBeatsaverData,
|
||||||
fetchAllScores,
|
fetchAllScores,
|
||||||
fetchTopPlayers,
|
fetchTopPlayers,
|
||||||
fetchLeaderboardInfo,
|
fetchLeaderboardInfo,
|
||||||
|
Loading…
Reference in New Issue
Block a user