add overlay
Some checks failed
deploy / deploy (push) Has been cancelled

This commit is contained in:
Lee 2023-11-05 20:56:19 +00:00
parent d2df95381c
commit 1792648e8d
31 changed files with 1257 additions and 38 deletions

View File

@ -31,6 +31,12 @@ const nextConfig = {
port: "",
pathname: "/**",
},
{
protocol: "https",
hostname: "eu.cdn.beatsaver.com",
port: "",
pathname: "/**",
},
],
},
};

278
package-lock.json generated
View File

@ -10,10 +10,13 @@
"dependencies": {
"@boiseitguru/cookie-cutter": "^0.2.1",
"@heroicons/react": "^2.0.18",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7",
"@sentry/nextjs": "^7.74.1",
"bluebird": "^3.7.2",
@ -35,6 +38,7 @@
"sharp": "^0.32.6",
"tailwind-merge": "^2.0.0",
"tailwindcss-animate": "^1.0.7",
"websocket": "^1.0.34",
"zustand": "^4.4.3"
},
"devDependencies": {
@ -43,6 +47,7 @@
"@types/node-fetch-cache": "^3.0.3",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/websocket": "^1.0.8",
"autoprefixer": "^10.4.16",
"cross-env": "^7.0.3",
"eslint": "^8",
@ -695,6 +700,29 @@
}
}
},
"node_modules/@radix-ui/react-label": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.0.2.tgz",
"integrity": "sha512-N5ehvlM7qoTLx7nWPodsPYPgMzA5WM8zZChQg8nyFJKnDO5WHdba1vv5/H6IO5LtJMfD2Q3wh1qHFGNtK0w3bQ==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-primitive": "1.0.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.0.7.tgz",
@ -834,6 +862,69 @@
}
}
},
"node_modules/@radix-ui/react-radio-group": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.1.3.tgz",
"integrity": "sha512-x+yELayyefNeKeTx4fjK6j99Fs6c4qKm3aY38G3swQVTN6xMpsrbigC0uHs2L//g8q4qR7qOcww8430jJmi2ag==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.1",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-context": "1.0.1",
"@radix-ui/react-direction": "1.0.1",
"@radix-ui/react-presence": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-roving-focus": "1.0.4",
"@radix-ui/react-use-controllable-state": "1.0.1",
"@radix-ui/react-use-previous": "1.0.1",
"@radix-ui/react-use-size": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz",
"integrity": "sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.1",
"@radix-ui/react-collection": "1.0.3",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-context": "1.0.1",
"@radix-ui/react-direction": "1.0.1",
"@radix-ui/react-id": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-use-callback-ref": "1.0.1",
"@radix-ui/react-use-controllable-state": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.0.3.tgz",
@ -908,6 +999,35 @@
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.0.3.tgz",
"integrity": "sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.1",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-context": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-use-controllable-state": "1.0.1",
"@radix-ui/react-use-previous": "1.0.1",
"@radix-ui/react-use-size": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz",
@ -1483,6 +1603,15 @@
"integrity": "sha512-2L9ifAGl7wmXwP4v3pN4p2FLhD0O1qsJpvKmNin5VA8+UvNVb447UDaAEV6UdrkA+m/Xs58U1RFps44x6TFsVQ==",
"devOptional": true
},
"node_modules/@types/websocket": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/websocket/-/websocket-1.0.8.tgz",
"integrity": "sha512-wvkOpWApbuxVfHhSQ1XrjVN4363vsfLJwEo4AboIZk0g1vJA5nmLp8GXUHuIdf4/Fe7+/V0Efe2HvWiLqHtlqw==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.8.0.tgz",
@ -2076,6 +2205,18 @@
"ieee754": "^1.1.13"
}
},
"node_modules/bufferutil": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz",
"integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==",
"hasInstallScript": true,
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@ -2372,6 +2513,15 @@
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==",
"devOptional": true
},
"node_modules/d": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz",
"integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==",
"dependencies": {
"es5-ext": "^0.10.50",
"type": "^1.0.1"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -2695,6 +2845,39 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es5-ext": {
"version": "0.10.62",
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.62.tgz",
"integrity": "sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==",
"hasInstallScript": true,
"dependencies": {
"es6-iterator": "^2.0.3",
"es6-symbol": "^3.1.3",
"next-tick": "^1.1.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/es6-iterator": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
"integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
"dependencies": {
"d": "1",
"es5-ext": "^0.10.35",
"es6-symbol": "^3.1.1"
}
},
"node_modules/es6-symbol": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz",
"integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==",
"dependencies": {
"d": "^1.0.1",
"ext": "^1.1.2"
}
},
"node_modules/escalade": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@ -3148,6 +3331,19 @@
"node": ">=6"
}
},
"node_modules/ext": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
"integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
"dependencies": {
"type": "^2.7.2"
}
},
"node_modules/ext/node_modules/type": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz",
"integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw=="
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -4072,6 +4268,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-typedarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
"integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="
},
"node_modules/is-weakmap": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz",
@ -4597,6 +4798,11 @@
"react-dom": "*"
}
},
"node_modules/next-tick": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="
},
"node_modules/node-abi": {
"version": "3.51.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.51.0.tgz",
@ -4661,6 +4867,16 @@
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-gyp-build": {
"version": "4.6.1",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.6.1.tgz",
"integrity": "sha512-24vnklJmyRS8ViBNI8KbtK/r/DmXQMRiOMXTNz2nrTnAYUwjmEEbnnpB/+kt+yWRv73bPsSPRFddrcIbAxSiMQ==",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-releases": {
"version": "2.0.13",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz",
@ -6310,6 +6526,11 @@
"node": "*"
}
},
"node_modules/type": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz",
"integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg=="
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -6399,6 +6620,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/typedarray-to-buffer": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
"integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
"dependencies": {
"is-typedarray": "^1.0.0"
}
},
"node_modules/typescript": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
@ -6537,6 +6766,18 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/utf-8-validate": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
"integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==",
"hasInstallScript": true,
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -6594,6 +6835,35 @@
"node": ">=10.13.0"
}
},
"node_modules/websocket": {
"version": "1.0.34",
"resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.34.tgz",
"integrity": "sha512-PRDso2sGwF6kM75QykIesBijKSVceR6jL2G8NGYyq2XrItNC2P5/qL5XeR056GhA+Ly7JMFvJb9I312mJfmqnQ==",
"dependencies": {
"bufferutil": "^4.0.1",
"debug": "^2.2.0",
"es5-ext": "^0.10.50",
"typedarray-to-buffer": "^3.1.5",
"utf-8-validate": "^5.0.2",
"yaeti": "^0.0.6"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/websocket/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/websocket/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -6710,6 +6980,14 @@
}
}
},
"node_modules/yaeti": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz",
"integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==",
"engines": {
"node": ">=0.10.32"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",

View File

@ -11,10 +11,13 @@
"dependencies": {
"@boiseitguru/cookie-cutter": "^0.2.1",
"@heroicons/react": "^2.0.18",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7",
"@sentry/nextjs": "^7.74.1",
"bluebird": "^3.7.2",
@ -36,6 +39,7 @@
"sharp": "^0.32.6",
"tailwind-merge": "^2.0.0",
"tailwindcss-animate": "^1.0.7",
"websocket": "^1.0.34",
"zustand": "^4.4.3"
},
"devDependencies": {
@ -44,6 +48,7 @@
"@types/node-fetch-cache": "^3.0.3",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/websocket": "^1.0.8",
"autoprefixer": "^10.4.16",
"cross-env": "^7.0.3",
"eslint": "^8",

View File

@ -3,7 +3,6 @@ import { ssrSettings } from "@/ssrSettings";
import clsx from "clsx";
import { Metadata } from "next";
import { Inter } from "next/font/google";
import Image from "next/image";
import Script from "next/script";
import "react-toastify/dist/ReactToastify.css";
import "./globals.css";
@ -49,16 +48,7 @@ export default function RootLayout({
src="https://analytics.fascinated.cc/js/script.js"
/>
<body className={clsx(font.className, "bg-black text-primary")}>
<div className="fixed left-0 top-0 z-0 h-full w-full blur-sm">
<Image
className="object-fill object-center"
alt="Background image"
src={"/assets/background.webp"}
fill
/>
</div>
<body className={clsx(font.className, "text-primary")}>
<AppProvider>{children}</AppProvider>
</body>
</html>

View File

@ -0,0 +1,115 @@
"use client";
import Container from "@/components/Container";
import { Input } from "@/components/input/Input";
import { RadioInput } from "@/components/input/RadioInput";
import { SwitchInput } from "@/components/input/SwitchInput";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { useOverlaySettingsStore } from "@/store/overlaySettingsStore";
import useStore from "@/utils/useStore";
import Link from "next/link";
/**
* Opens the overlay with the current settings
*
* @param settings the settings to pass to the overlay
*/
function goToOverlay(settings: any) {
window.open(`/overlay?data=${JSON.stringify(settings)}`, "_blank");
}
export default function Analytics() {
const settingsStore = useStore(useOverlaySettingsStore, (store) => store);
if (!settingsStore) return null;
return (
<main>
<Container>
<Card className="mt-2">
<CardTitle className="p-3">
<h1>Overlay Builder</h1>
</CardTitle>
<CardContent>
<p className="mb-2">
Confused on how to use this? Check out the{" "}
<span className="transform-gpu text-pp-blue transition-all hover:opacity-80">
<Link href={"https://www.youtube.com/watch?v=IjctLf1nX8w"}>
tutorial
</Link>
</span>
.
</p>
<Input
id="ip-address"
label="IP Address"
defaultValue={settingsStore.ipAddress}
onChange={(e) => {
settingsStore.setIpAddress(e);
}}
/>
<Input
id="account-id"
label="Account ID"
defaultValue={settingsStore.accountId}
onChange={(e) => {
settingsStore.setAccountId(e);
}}
/>
<RadioInput
id="platform"
label="Platform"
defaultValue={settingsStore.platform}
items={[
{
id: "scoresaber",
value: "ScoreSaber",
},
// {
// id: "beatleader",
// value: "BeatLeader",
// },
]}
onChange={(value) => {
settingsStore.setPlatform(value);
}}
/>
<div className="mt-2">
<Label>Settings</Label>
<SwitchInput
id="show-player-stats"
label="Show Player Stats"
defaultChecked={settingsStore.settings.showPlayerStats}
onChange={(value) => {
settingsStore.setShowPlayerStats(value);
}}
/>
<SwitchInput
id="show-song-info"
label="Show Song Info"
defaultChecked={settingsStore.settings.showSongInfo}
onChange={(value) => {
settingsStore.setShowSongInfo(value);
}}
/>
</div>
<Button
className="mt-3"
onClick={() => {
goToOverlay(settingsStore);
}}
>
Open Overlay
</Button>
</CardContent>
</Card>
</Container>
</main>
);
}

119
src/app/overlay/page.tsx Normal file
View File

@ -0,0 +1,119 @@
"use client";
import Container from "@/components/Container";
import Spinner from "@/components/Spinner";
import PlayerStats from "@/components/overlay/PlayerStats";
import ScoreStats from "@/components/overlay/ScoreStats";
import SongInfo from "@/components/overlay/SongInfo";
import { Card, CardDescription, CardTitle } from "@/components/ui/card";
import { HttpSiraStatus } from "@/overlay/httpSiraStatus";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import { Component } from "react";
const UPDATE_INTERVAL = 1000 * 60 * 5; // 5 minutes
interface OverlayProps {}
interface OverlayState {
mounted: boolean;
player: ScoresaberPlayer | undefined;
settings: any | undefined;
}
export default class Overlay extends Component<OverlayProps, OverlayState> {
constructor(props: OverlayProps) {
super(props);
this.state = {
mounted: false,
player: undefined,
settings: undefined,
};
}
updatePlayer = async (playerId: string) => {
console.log(`Updating player stats for ${playerId}`);
const player = await ScoreSaberAPI.fetchPlayerData(playerId);
if (!player) {
return;
}
this.setState({ player });
};
componentDidMount() {
if (this.state.mounted) {
return;
}
this.setState({ mounted: true });
if (!this.state.mounted) {
HttpSiraStatus.connectWebSocket();
}
const url = new URL(window.location.href);
const searchParams = url.searchParams;
const data = searchParams.get("data");
if (!data) {
return;
}
const settings = JSON.parse(data);
this.setState({ settings: settings });
this.updatePlayer(settings.accountId);
setInterval(() => {
this.updatePlayer(settings.accountId);
}, UPDATE_INTERVAL);
}
render() {
const { player } = this.state;
if (!this.state.mounted || !player) {
return (
<main className="flex items-center p-3">
<Spinner />
<p className="text-xl">Loading player data</p>
</main>
);
}
if (!this.state.settings) {
return (
<main>
<Container>
<Card className="mt-2 p-3">
<CardTitle>Overlay</CardTitle>
<CardDescription className="mt-2">
<p>
This page is meant to be used as an overlay for streaming.
</p>
<p>
To generate an overlay, go to the builder{" "}
<a
className="transform-gpu text-pp-blue transition-all hover:opacity-80"
href="/overlay/builder"
>
here
</a>
.
</p>
</CardDescription>
</Card>
</Container>
</main>
);
}
return (
<main>
<div>
<PlayerStats player={player} />
<ScoreStats />
</div>
<div className="absolute bottom-0 left-0">
<SongInfo />
</div>
</main>
);
}
}

View File

@ -3,6 +3,7 @@
import { useScoresaberScoresStore } from "@/store/scoresaberScoresStore";
import { useSettingsStore } from "@/store/settingsStore";
import React from "react";
import { ToastContainer } from "react-toastify";
import { TooltipProvider } from "./ui/Tooltip";
import { ThemeProvider } from "./ui/theme-provider";
@ -52,7 +53,15 @@ export default class AppProvider extends React.Component {
return (
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
<TooltipProvider>{props.children}</TooltipProvider>
<TooltipProvider>
<ToastContainer
className="z-50"
position="top-right"
theme="dark"
pauseOnFocusLoss={false}
/>
{props.children}
</TooltipProvider>
</ThemeProvider>
);
}

View File

@ -1,17 +1,19 @@
import { ToastContainer } from "react-toastify";
import Image from "next/image";
import Footer from "./Footer";
import Navbar from "./Navbar";
export default function Container({ children }: { children: React.ReactNode }) {
return (
<>
<ToastContainer
className="z-50"
position="top-right"
theme="dark"
pauseOnFocusLoss={false}
/>
<div className="m-auto flex h-screen min-h-full flex-col items-center opacity-90 md:max-w-[1200px]">
<div className="fixed left-0 top-0 z-0 h-full w-full blur-sm">
<Image
className="object-fill object-center"
alt="Background image"
src={"/assets/background.webp"}
fill
/>
</div>
<div className="z-[9999] m-auto flex h-screen min-h-full flex-col items-center opacity-90 md:max-w-[1200px]">
<Navbar />
<div className="w-full flex-1">{children}</div>

View File

@ -8,7 +8,7 @@ import {
ServerIcon,
UserIcon,
} from "@heroicons/react/20/solid";
import { GlobeAltIcon } from "@heroicons/react/24/outline";
import { GlobeAltIcon, TvIcon } from "@heroicons/react/24/outline";
import Link from "next/link";
import Avatar from "./Avatar";
import { Button } from "./ui/button";
@ -129,6 +129,12 @@ export default function Navbar() {
icon={<GlobeAltIcon height={23} width={23} />}
href="/ranking/global/1"
/>
<NavbarButton
ariaLabel="View the overlay builder"
text="Overlay"
icon={<TvIcon height={23} width={23} />}
href="/overlay/builder"
/>
<NavbarButton
ariaLabel="View analytics for Scoresaber"
text="Analytics"

View File

@ -0,0 +1,24 @@
import { Input as Inputtt } from "../ui/input";
import { Label } from "../ui/label";
type InputProps = {
label: string;
id: string;
defaultValue?: string;
onChange?: (value: string) => void;
};
export function Input({ label, id, defaultValue, onChange }: InputProps) {
return (
<>
<Label htmlFor={id}>{label}</Label>
<Inputtt
id={id}
defaultValue={defaultValue}
onChange={(e) => {
onChange && onChange(e.target.value);
}}
/>
</>
);
}

View File

@ -0,0 +1,45 @@
import { Label } from "../ui/label";
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
type RadioProps = {
id: string;
defaultValue: string;
label?: string;
items: {
id: string;
value: string;
label?: string;
}[];
onChange?: (value: string) => void;
};
export function RadioInput({
id,
defaultValue,
label,
items,
onChange,
}: RadioProps) {
return (
<div className="mt-2">
{id && label && <Label htmlFor={id}>{label}</Label>}
<RadioGroup
id={id}
defaultValue={defaultValue}
className="mt-2"
onValueChange={(value) => onChange && onChange(value)}
>
{items.map((item, index) => {
return (
<div key={index} className="flex items-center space-x-2">
<RadioGroupItem value={item.id} id={item.id}>
{item.value}
</RadioGroupItem>
<Label htmlFor={item.id}>{item.value}</Label>
</div>
);
})}
</RadioGroup>
</div>
);
}

View File

@ -0,0 +1,27 @@
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
type SwitchProps = {
id: string;
label: string;
defaultChecked?: boolean;
onChange?: (value: boolean) => void;
};
export function SwitchInput({
id,
label,
defaultChecked,
onChange,
}: SwitchProps) {
return (
<div className="mt-2 flex items-center space-x-2">
<Switch
id={id}
defaultChecked={defaultChecked}
onCheckedChange={(value) => onChange && onChange(value)}
/>
<Label htmlFor={id}>{label}</Label>
</div>
);
}

View File

@ -0,0 +1,34 @@
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { formatNumber } from "@/utils/numberUtils";
import { GlobeAltIcon } from "@heroicons/react/20/solid";
import Image from "next/image";
import CountyFlag from "../CountryFlag";
type PlayerStatsProps = {
player: ScoresaberPlayer;
};
export default function PlayerStats({ player }: PlayerStatsProps) {
return (
<div className="flex gap-2 p-2">
<Image
alt="Player profile picture"
className="rounded-md"
src={player.profilePicture}
width={180}
height={180}
/>
<div>
<p className="text-3xl font-bold">{formatNumber(player.pp, 2)}pp</p>
<div className="flex items-center gap-2">
<GlobeAltIcon width={25} height={25} />
<p className="text-3xl">#{formatNumber(player.rank)}</p>
</div>
<div className="flex items-center gap-2">
<CountyFlag className="w-[25px]" countryCode={player.country} />
<p className="text-3xl">#{formatNumber(player.countryRank)}</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,20 @@
import { useOverlayDataStore } from "@/store/overlayDataStore";
import { formatNumber } from "@/utils/numberUtils";
import useStore from "@/utils/useStore";
export default function ScoreStats() {
const dataStore = useStore(useOverlayDataStore, (store) => store);
if (!dataStore) return null;
const { scoreStats } = dataStore;
if (!scoreStats) return null;
return (
<div className="flex flex-col p-2">
<p className="text-2xl">{formatNumber(scoreStats.score)}</p>
<p className="text-2xl">Combo: {formatNumber(scoreStats.combo)}</p>
<p className="text-2xl">
{scoreStats.rank} {scoreStats.accuracy.toFixed(2)}%
</p>
</div>
);
}

View File

@ -0,0 +1,46 @@
import { useOverlayDataStore } from "@/store/overlayDataStore";
import { songDifficultyToColor } from "@/utils/songUtils";
import useStore from "@/utils/useStore";
import clsx from "clsx";
import Image from "next/image";
export default function SongInfo() {
const dataStore = useStore(useOverlayDataStore, (store) => store);
if (!dataStore) return null;
const { paused, songInfo } = dataStore;
if (!songInfo) return null;
return (
<div
className={clsx(
"flex transform-gpu gap-2 p-2 transition-all",
paused ? "grayscale" : "grayscale-0",
)}
>
<Image
className="rounded-md"
alt="Song Image"
src={songInfo.art}
width={120}
height={120}
/>
<div className="flex flex-col justify-between pb-2 pt-1">
<div>
<p className="text-xl font-bold">{songInfo.songName}</p>
<p className="text-md">{songInfo.songMapper}</p>
</div>
<div className="mt-1 flex items-center gap-2">
<p
className="text-md rounded-md p-[3px]"
style={{
backgroundColor: songDifficultyToColor(songInfo.difficulty),
}}
>
{songInfo.difficulty}
</p>
<p className="text-md">!bsr {songInfo.bsr}</p>
</div>
</div>
</div>
);
}

View File

@ -1,6 +1,6 @@
import * as React from "react"
import * as React from "react";
import { cn } from "@/utils/utils"
import { cn } from "@/utils/utils";
const Card = React.forwardRef<
HTMLDivElement,
@ -10,12 +10,12 @@ const Card = React.forwardRef<
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
className,
)}
{...props}
/>
))
Card.displayName = "Card"
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
@ -26,8 +26,8 @@ const CardHeader = React.forwardRef<
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
@ -37,12 +37,12 @@ const CardTitle = React.forwardRef<
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
className,
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
@ -53,16 +53,16 @@ const CardDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
@ -73,7 +73,14 @@ const CardFooter = React.forwardRef<
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};

View File

@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/utils/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/utils/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/utils/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, children, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/utils/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@ -0,0 +1,127 @@
import { useOverlayDataStore } from "@/store/overlayDataStore";
import { useOverlaySettingsStore } from "@/store/overlaySettingsStore";
import { BeatsaverAPI } from "@/utils/beatsaver/api";
import { w3cwebsocket as WebSocketClient } from "websocket";
const settingsStore = useOverlaySettingsStore;
const overlayDataStore = useOverlayDataStore;
let isConnected = false;
async function loadIntoSong(data: any) {
const { beatmap, performance } = data;
const hash = beatmap.songHash;
const beatsaverMapData = await BeatsaverAPI.fetchMapByHash(hash);
if (beatsaverMapData == undefined) return;
const coverURL =
beatsaverMapData.versions[beatsaverMapData.versions.length - 1].coverURL;
overlayDataStore.setState({
scoreStats: {
accuracy: performance.relativeScore * 100,
score: performance.rawScore,
combo: performance.combo,
rank: performance.rank,
},
songInfo: {
art: coverURL,
bsr: beatsaverMapData.id,
difficulty: beatmap.difficulty,
songMapper: beatsaverMapData.metadata.levelAuthorName,
songName: beatsaverMapData.metadata.songName,
songSubName: beatsaverMapData.metadata.songSubName,
},
});
}
type Handlers = {
[key: string]: (data: any) => void;
};
const handlers: Handlers = {
hello: async (data: any) => {
if (!data.beatmap || !data.performance) {
return;
}
loadIntoSong(data);
},
songStart: (data: any) => {
loadIntoSong(data);
},
scoreChanged: (data: any) => {
const { performance } = data;
if (performance == undefined) return;
overlayDataStore.setState({
scoreStats: {
accuracy: performance.relativeScore * 100,
score: performance.rawScore,
combo: performance.combo,
rank: performance.rank,
},
});
},
// Left the song
finished: (data: any) => {
overlayDataStore.setState({
scoreStats: undefined,
songInfo: undefined,
});
},
menu: (data: any) => {
overlayDataStore.setState({
scoreStats: undefined,
songInfo: undefined,
});
},
// pause & resume
pause: (data: any) => {
overlayDataStore.setState({
paused: true,
});
},
resume: (data: any) => {
overlayDataStore.setState({
paused: false,
});
},
};
function connectWebSocket() {
if (isConnected) return;
const client = new WebSocketClient(
`ws://${settingsStore.getState().ipAddress}:6557/socket`,
);
isConnected = true;
client.onopen = () => {
console.log("WebSocket Connected to HttpSiraStatus");
};
client.onerror = (error) => {
console.error(error);
};
client.onclose = () => {
isConnected = false;
console.log(
"Lost connection to HttpSiraStatus, reconnecting in 5 seconds...",
);
setTimeout(() => {
connectWebSocket();
}, 5000); // 5 seconds
};
client.onmessage = (message) => {
const data = JSON.parse(message.data.toString());
const handler = handlers[data.event];
//console.log(data.event);
if (handler == undefined) {
console.error(`No handler for ${data.event}`);
return;
}
handler(data.status);
};
}
export const HttpSiraStatus = {
connectWebSocket,
};

View File

@ -0,0 +1,21 @@
import { BeatsaverMapMetadata } from "./BeatsaverMapMetadata";
import { BeatsaverMapStats } from "./BeatsaverMapStats";
import { BeatsaverMapVersion } from "./BeatsaverMapVersion";
import { BeatsaverUploader } from "./BeatsaverUploader";
export type BeatsaverMap = {
id: string;
name: string;
description: string;
uploader: BeatsaverUploader;
metadata: BeatsaverMapMetadata;
stats: BeatsaverMapStats;
uploaded: string;
automapper: boolean;
ranked: boolean;
qualified: boolean;
versions: BeatsaverMapVersion[];
createdAt: string;
updatedAt: string;
lastPublishedAt: string;
};

View File

@ -0,0 +1,22 @@
import { BeatsaverMapSummary } from "./BeatsaverMapSummary";
export type BeatsaverMapDifficulty = {
njs: number;
offset: number;
notes: number;
bombs: number;
obstacles: number;
nps: number;
length: number;
characteristic: string;
difficulty: string;
events: number;
chroma: boolean;
me: boolean;
ne: boolean;
cinema: boolean;
seconds: number;
paritySummary: BeatsaverMapSummary;
maxScore: number;
label: string;
};

View File

@ -0,0 +1,8 @@
export type BeatsaverMapMetadata = {
bpm: number;
duration: number;
songName: string;
songSubName: string;
songAuthorName: string;
levelAuthorName: string;
};

View File

@ -0,0 +1,7 @@
export type BeatsaverMapStats = {
plays: number;
downloads: number;
upvotes: number;
downvotes: number;
score: number;
};

View File

@ -0,0 +1,5 @@
export type BeatsaverMapSummary = {
errors: number;
warns: number;
resets: number;
};

View File

@ -0,0 +1,12 @@
import { BeatsaverMapDifficulty } from "./BeatsaverMapDifficulty";
export type BeatsaverMapVersion = {
hash: string;
state: string;
createdAt: string;
sageScore: number;
diffs: BeatsaverMapDifficulty[];
downloadURL: string;
coverURL: string;
previewURL: string;
};

View File

@ -0,0 +1,10 @@
export type BeatsaverUploader = {
id: string;
name: string;
hash: string;
avatar: string;
type: string;
admin: boolean;
curator: boolean;
playlistUrl: string;
};

View File

@ -0,0 +1,42 @@
"use client";
import { create } from "zustand";
interface OverlayDataStore {
paused: boolean;
scoreStats:
| {
accuracy: number;
score: number;
rank: string;
combo: number;
}
| undefined;
songInfo:
| {
art: string;
songName: string;
songSubName: string;
songMapper: string;
difficulty: string;
bsr: string;
}
| undefined;
}
export const useOverlayDataStore = create<OverlayDataStore>()((set, get) => ({
paused: false,
songInfo: undefined,
scoreStats: undefined,
setScoreStats(scoreStats: OverlayDataStore["scoreStats"]) {
set({
scoreStats,
});
},
setSongInfo(songInfo: OverlayDataStore["songInfo"]) {
set({
songInfo,
});
},
}));

View File

@ -0,0 +1,71 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { IDBStorage } from "./IndexedDBStorage";
interface OverlaySettingsStore {
ipAddress: string;
accountId: string;
platform: string;
settings: {
showPlayerStats: boolean;
showSongInfo: boolean;
};
setIpAddress: (ipAddress: string) => void;
setAccountId: (accountId: string) => void;
setPlatform: (platform: string) => void;
setShowPlayerStats: (showPlayerStats: boolean) => void;
setShowSongInfo: (showSongInfo: boolean) => void;
}
export const useOverlaySettingsStore = create<OverlaySettingsStore>()(
persist(
(set, get) => ({
ipAddress: "localhost",
accountId: "",
platform: "scoresaber",
settings: {
showPlayerStats: true,
showSongInfo: true,
},
setIpAddress(ipAddress: string) {
set({
ipAddress,
});
},
setAccountId(accountId: string) {
set({
accountId,
});
},
setPlatform(platform: string) {
set({
platform,
});
},
setShowPlayerStats(showPlayerStats: boolean) {
set({
settings: {
...get().settings,
showPlayerStats,
},
});
},
setShowSongInfo(showSongInfo: boolean) {
set({
settings: {
...get().settings,
showSongInfo,
},
});
},
}),
{
name: "overlaySettings",
storage: createJSONStorage(() => IDBStorage),
},
),
);

View File

@ -0,0 +1,37 @@
import { BeatsaverMap } from "@/schemas/beatsaver/BeatsaverMap";
import { ssrSettings } from "@/ssrSettings";
import { FetchQueue } from "../fetchWithQueue";
import { formatString } from "../string";
// Create a fetch instance with a cache
export const BeatsaverFetchQueue = new FetchQueue();
// Api endpoints
const BS_API_URL = ssrSettings.proxy + "/https://api.beatsaver.com";
export const BS_GET_MAP_BY_HASH_URL = BS_API_URL + "/maps/hash/{}";
/**
* Returns the map info for the provided hash
*
* @param hash the hash of the map
* @returns the map info
*/
async function fetchMapByHash(
hash: string,
): Promise<BeatsaverMap | undefined | null> {
const response = await BeatsaverFetchQueue.fetch(
formatString(BS_GET_MAP_BY_HASH_URL, true, hash),
);
const json = await response.json();
// Check if there was an error fetching the user data
if (json.error) {
return undefined;
}
return json as BeatsaverMap;
}
export const BeatsaverAPI = {
fetchMapByHash,
};