feat(ssr): add map and bsr button
All checks were successful
deploy / deploy (push) Successful in 1m3s

This commit is contained in:
Lee 2023-11-08 22:13:20 +00:00
parent c3889a4d5a
commit b9472ce982
8 changed files with 220 additions and 35 deletions

@ -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}
/> />
); );

@ -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 */}

@ -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,