Compare commits
194 Commits
ab03478073
...
renovate/l
Author | SHA1 | Date | |
---|---|---|---|
622896a5a4 | |||
2fedf0e6ff | |||
a14ff2f343 | |||
be9328bab5 | |||
73046d46d4 | |||
6fcb561085 | |||
fac75f1e6a | |||
9b5d38896d | |||
1b42f9f6d9 | |||
136fc06469 | |||
e6f2e6a03d | |||
c0c8b625be | |||
ae482a48c0 | |||
75bf876fb6 | |||
2d9de44fa9 | |||
8c9a5de93b | |||
56f7255918 | |||
f58ea539f9 | |||
0f70a50bd4 | |||
738a95180f | |||
4e3ba2d0c7 | |||
9e73ff3937 | |||
3b7f458d5c | |||
d769b0d15e | |||
cf75dfb06d | |||
41090360e1 | |||
b5629c0169 | |||
4c0775b000 | |||
407bcc866b | |||
b74b22c0b6 | |||
0f7b28ca02 | |||
d5afdbde69 | |||
31d37feb8e | |||
b3c3be4b7c | |||
14f67af125 | |||
bb45a4bd6b | |||
65e8df76a2 | |||
925e423955 | |||
ef7c6f4878 | |||
fd7cbf73a7 | |||
a793847b91 | |||
8c50b484cf | |||
3c5b5be02f | |||
da701cf046 | |||
443b4ce2f7 | |||
cbcaaa2d8c | |||
4a03b0c97f | |||
dbfc6a93d0 | |||
992b97a35d | |||
d1b0d85ecf | |||
7e730ed0c9 | |||
bdb2ffc7ba | |||
95a9e103eb | |||
5e817186de | |||
75afdfed7d | |||
9c2bf54426 | |||
0c9fc07e83 | |||
24c787969c | |||
000812b2e5 | |||
c22dabeb9b | |||
7a31e158ea | |||
b42ba1afdd | |||
426a2b5a2f | |||
350fe875fe | |||
13b814700f | |||
96496edfdf | |||
0797bf8523 | |||
5e4dcf1b37 | |||
0ffb3e341d | |||
b60d713d3c | |||
1a5493be69 | |||
5822aa70bf | |||
016b958546 | |||
e6b169f3fc | |||
af707a8c79 | |||
3dd2fc48ad | |||
b9472ce982 | |||
c3889a4d5a | |||
736f286d66 | |||
3341f07136 | |||
662999f65b | |||
3b2e726fa1 | |||
a5abcb1e23 | |||
fb0e8a7ea1 | |||
26490bbad4 | |||
ddf0b61282 | |||
f2801f2ae7 | |||
9637993471 | |||
6ec834ddc5 | |||
e40c78e9b9 | |||
fb38ac69df | |||
0a8878cdac | |||
8e5aeadce0 | |||
c251239e45 | |||
3a6312510a | |||
c84f5e1dd4 | |||
928e1df444 | |||
5f6c0bbe22 | |||
fb0629a58f | |||
7259ffb3de | |||
5de33019d8 | |||
bfe884a9e6 | |||
2904e4c0c9 | |||
72cf612b76 | |||
4bf874453e | |||
15aeaa69d5 | |||
b24f1db3c1 | |||
0529cf3492 | |||
b37d7cd621 | |||
41d1d5f1d7 | |||
ea89e8cb85 | |||
f56ba5269a | |||
43b762f756 | |||
4baa1ca956 | |||
4084891c73 | |||
4864b8bd5b | |||
2187d37e3e | |||
6bd409fbda | |||
4b966aaaae | |||
5e8737d7fa | |||
cefaa734fd | |||
226716b555 | |||
d7c6b24fac | |||
36c723e846 | |||
cfb7d1f15d | |||
cfaca9fb40 | |||
e16727713c | |||
638dc528ad | |||
cff615424d | |||
1a7c7751b6 | |||
be998f068c | |||
8e0a6a736e | |||
95965aa391 | |||
2e4842efd4 | |||
3e5f141938 | |||
fb2b72875f | |||
fadfdee316 | |||
1485083d60 | |||
2fbc463264 | |||
e88bfaab89 | |||
1b6715318a | |||
6b0f1bf5ce | |||
22dfb56167 | |||
e9de4fa258 | |||
c9a6703d94 | |||
a49fa9dbb6 | |||
11826faa9a | |||
d574d701f6 | |||
666584e022 | |||
1792648e8d | |||
d2df95381c | |||
44fb21ee82 | |||
383923a768 | |||
1aa8630db0 | |||
0123541095 | |||
4981ce5d37 | |||
ce4ec291c6 | |||
57c2fe1301 | |||
1ab07709ac | |||
40d5bcf86d | |||
ad162cbe3f | |||
832f4eebe5 | |||
3967ea76e0 | |||
59c3b7a421 | |||
f124fb1a57 | |||
e7ed757194 | |||
db837d6d90 | |||
afe17cd982 | |||
ec2afd8811 | |||
65cfe58557 | |||
092d5a5d24 | |||
e5913392b5 | |||
606e129429 | |||
87edf0e3e3 | |||
27b4eb38ea | |||
ffd821e6d2 | |||
5148a76cf2 | |||
f37afc849c | |||
f2ea9ae1ca | |||
69d3e34556 | |||
d80659e884 | |||
25307a72ef | |||
3e6d04a13b | |||
24cf9bfb14 | |||
06a908b79b | |||
f8eb17b06c | |||
7aeeea79ba | |||
c3835d963d | |||
ab6ba10cb6 | |||
aa2ae06a3e | |||
00864515a6 | |||
61a0252c9f | |||
12873429b8 | |||
fe7d31a6a0 |
@ -1 +1,4 @@
|
|||||||
SENTRY_AUTH_TOKEN=hi
|
INFISICAL_TOKEN=hi
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
5
.eslintrc.js
Normal file
5
.eslintrc.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
extends: ["next/core-web-vitals"],
|
||||||
|
};
|
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "next/core-web-vitals"
|
|
||||||
}
|
|
8
.gitignore
vendored
8
.gitignore
vendored
@ -5,8 +5,9 @@
|
|||||||
/.pnp
|
/.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
|
|
||||||
# yarn
|
# lock files
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
@ -42,3 +43,8 @@ next-env.d.ts
|
|||||||
|
|
||||||
# Webpack bundle analyzer
|
# Webpack bundle analyzer
|
||||||
analyze
|
analyze
|
||||||
|
|
||||||
|
# Sitemap
|
||||||
|
public/sitemap*
|
||||||
|
|
||||||
|
.env
|
5
.infisical.json
Normal file
5
.infisical.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"workspaceId": "6551ad1ded9edd83540488e0",
|
||||||
|
"defaultEnvironment": "",
|
||||||
|
"gitBranchToEnvironmentMapping": null
|
||||||
|
}
|
17
Dockerfile
17
Dockerfile
@ -1,11 +1,11 @@
|
|||||||
FROM fascinated/docker-images:node-latest AS base
|
FROM fascinated/docker-images:node-pnpm-latest AS base
|
||||||
|
|
||||||
# Install depends
|
# Install depends
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json* package-lock.yaml* ./
|
COPY package.json* pnpm-lock.yaml* ./
|
||||||
RUN npm install --frozen-lockfile --quiet
|
RUN pnpm install --frozen-lockfile --quiet
|
||||||
|
|
||||||
# Build from source
|
# Build from source
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
@ -17,7 +17,14 @@ ENV NEXT_TELEMETRY_DISABLED 1
|
|||||||
ARG GIT_REV
|
ARG GIT_REV
|
||||||
ENV GIT_REV ${GIT_REV}
|
ENV GIT_REV ${GIT_REV}
|
||||||
|
|
||||||
RUN npm run build
|
# ARG REDIS_URL
|
||||||
|
ENV REDIS_URL redis://:bigtitsyes7@10.0.0.203:30004
|
||||||
|
|
||||||
|
# Build the app
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
# Generate sitemap
|
||||||
|
RUN pnpm run generate-sitemap
|
||||||
|
|
||||||
# Run the app
|
# Run the app
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
@ -42,4 +49,4 @@ USER nextjs
|
|||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
ENV HOSTNAME "0.0.0.0"
|
ENV HOSTNAME "0.0.0.0"
|
||||||
ENV PORT 80
|
ENV PORT 80
|
||||||
CMD ["npm", "run", "start"]
|
CMD ["pnpm", "start"]
|
74
next-sitemap.config.js
Normal file
74
next-sitemap.config.js
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
const ssrSettings = require("./src/ssrSettings");
|
||||||
|
const { getCodeList } = require("country-list");
|
||||||
|
|
||||||
|
const SS_API_URL = ssrSettings.proxy + "/https://scoresaber.com/api";
|
||||||
|
const SS_GET_PLAYERS_URL = SS_API_URL + "/players?page={}";
|
||||||
|
|
||||||
|
// todo: cache this on a file somehow?
|
||||||
|
async function getTopPlayers() {
|
||||||
|
console.log("Fetching top players...");
|
||||||
|
const players = [];
|
||||||
|
const pagesToFetch = 10;
|
||||||
|
for (let i = 0; i < pagesToFetch; i++) {
|
||||||
|
console.log(`Fetching page ${i + 1} of ${pagesToFetch}...`);
|
||||||
|
try {
|
||||||
|
const response = await fetch(SS_GET_PLAYERS_URL.replace("{}", i));
|
||||||
|
const data = await response.json();
|
||||||
|
players.push(...data.players);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`Error fetching page ${i + 1} of ${pagesToFetch}: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("Done fetching top players.");
|
||||||
|
return players;
|
||||||
|
}
|
||||||
|
|
||||||
|
const additionalData = {
|
||||||
|
priority: 0.5,
|
||||||
|
changefreq: "monthly",
|
||||||
|
lastmod: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @type {import('next-sitemap').IConfig} */
|
||||||
|
module.exports = {
|
||||||
|
siteUrl: ssrSettings.siteUrl,
|
||||||
|
generateRobotsTxt: true,
|
||||||
|
additionalPaths: async (config) => {
|
||||||
|
const paths = [];
|
||||||
|
// Add the top 50 global ranking pages
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
paths.push({
|
||||||
|
loc: `/ranking/global/${i + 1}`,
|
||||||
|
...additionalData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the top 50 pages for all countries
|
||||||
|
const countries = Object.keys(getCodeList());
|
||||||
|
for (const country of countries) {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
paths.push({
|
||||||
|
loc: `/ranking/country/${country}/${i + 1}`,
|
||||||
|
...additionalData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortTypes = ["top", "recent"];
|
||||||
|
|
||||||
|
// Add top players
|
||||||
|
const players = await getTopPlayers();
|
||||||
|
for (const sortType of sortTypes) {
|
||||||
|
for (const player of players) {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
paths.push({
|
||||||
|
loc: `/player/${player.id}/${sortType}/${i + 1}`,
|
||||||
|
...additionalData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths;
|
||||||
|
},
|
||||||
|
};
|
106
next.config.js
106
next.config.js
@ -1,75 +1,57 @@
|
|||||||
const nextBuildId = require("next-build-id");
|
const nextBuildId = require("next-build-id");
|
||||||
const withBundleAnalyzer = require("@next/bundle-analyzer")({
|
const withBundleAnalyzer = require("@next/bundle-analyzer")({ enabled: false });
|
||||||
enabled: false,
|
|
||||||
});
|
// Define remote patterns for images
|
||||||
|
const remotePatterns = [
|
||||||
|
{ protocol: "https", hostname: "cdn.fascinated.cc", pathname: "/**" },
|
||||||
|
{ protocol: "https", hostname: "cdn.scoresaber.com", pathname: "/**" },
|
||||||
|
{ protocol: "https", hostname: "cdn.jsdelivr.net", pathname: "/**" },
|
||||||
|
{ protocol: "https", hostname: "eu.cdn.beatsaver.com", pathname: "/**" },
|
||||||
|
{ protocol: "https", hostname: "na.cdn.beatsaver.com", pathname: "/**" },
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "avatars.akamai.steamstatic.com",
|
||||||
|
pathname: "/**",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Define optimized package imports
|
||||||
|
const optimizePackageImports = [
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"next-themes",
|
||||||
|
"react-tostify",
|
||||||
|
"websocket",
|
||||||
|
"cslx",
|
||||||
|
"chart.js",
|
||||||
|
"react-chartjs-2",
|
||||||
|
"country-list",
|
||||||
|
"@sentry/nextjs",
|
||||||
|
];
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
generateEtags: true,
|
generateEtags: true,
|
||||||
|
reactStrictMode: true,
|
||||||
|
swcMinify: true,
|
||||||
compress: true,
|
compress: true,
|
||||||
|
poweredByHeader: false,
|
||||||
|
experimental: {
|
||||||
|
webpackBuildWorker: true,
|
||||||
|
optimizePackageImports,
|
||||||
|
},
|
||||||
env: {
|
env: {
|
||||||
NEXT_PUBLIC_BUILD_ID:
|
NEXT_PUBLIC_BUILD_ID:
|
||||||
process.env.GIT_REV || nextBuildId.sync({ dir: __dirname }),
|
process.env.GIT_REV || nextBuildId.sync({ dir: __dirname }),
|
||||||
|
NEXT_PUBLIC_BUILD_TIME: new Date().toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
images: {
|
images: { remotePatterns },
|
||||||
remotePatterns: [
|
|
||||||
{
|
|
||||||
protocol: "https",
|
|
||||||
hostname: "cdn.fascinated.cc",
|
|
||||||
port: "",
|
|
||||||
pathname: "/**",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
protocol: "https",
|
|
||||||
hostname: "cdn.scoresaber.com",
|
|
||||||
port: "",
|
|
||||||
pathname: "/**",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
protocol: "https",
|
|
||||||
hostname: "cdn.jsdelivr.net",
|
|
||||||
port: "",
|
|
||||||
pathname: "/**",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = withBundleAnalyzer(nextConfig);
|
module.exports = withBundleAnalyzer(nextConfig);
|
||||||
|
|
||||||
// // Injected content via Sentry wizard below
|
|
||||||
|
|
||||||
const { withSentryConfig } = require("@sentry/nextjs");
|
|
||||||
|
|
||||||
module.exports = withSentryConfig(
|
|
||||||
module.exports,
|
|
||||||
{
|
|
||||||
// For all available options, see:
|
|
||||||
// https://github.com/getsentry/sentry-webpack-plugin#options
|
|
||||||
|
|
||||||
// Suppresses source map uploading logs during build
|
|
||||||
silent: true,
|
|
||||||
org: "sentry",
|
|
||||||
project: "scoresaber-reloaded",
|
|
||||||
url: "https://sentry.fascinated.cc/",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// For all available options, see:
|
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
|
||||||
|
|
||||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
|
||||||
widenClientFileUpload: false,
|
|
||||||
|
|
||||||
// Transpiles SDK to be compatible with IE11 (increases bundle size)
|
|
||||||
transpileClientSDK: false,
|
|
||||||
|
|
||||||
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
|
|
||||||
tunnelRoute: "/monitoring",
|
|
||||||
|
|
||||||
// Hides source maps from generated client bundles
|
|
||||||
hideSourceMaps: true,
|
|
||||||
|
|
||||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
|
||||||
disableLogger: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
6765
package-lock.json
generated
6765
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
67
package.json
67
package.json
@ -2,55 +2,64 @@
|
|||||||
"name": "scoresaber-reloadedv2",
|
"name": "scoresaber-reloadedv2",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"sideEffects": false,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
"generate-sitemap": "next-sitemap",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@boiseitguru/cookie-cutter": "^0.2.1",
|
"@boiseitguru/cookie-cutter": "^0.2.3",
|
||||||
"@heroicons/react": "^2.0.18",
|
"@heroicons/react": "^2.1.1",
|
||||||
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@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-separator": "^1.0.3",
|
||||||
"@radix-ui/react-slider": "^1.1.2",
|
"@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",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"@sentry/nextjs": "^7.74.1",
|
"chart.js": "^4.4.1",
|
||||||
"bluebird": "^3.7.2",
|
|
||||||
"chart.js": "^4.4.0",
|
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.1.0",
|
||||||
"date-fns": "^2.30.0",
|
"country-list": "^2.3.0",
|
||||||
|
"critters": "^0.0.20",
|
||||||
|
"date-fns": "^3.0.0",
|
||||||
"encoding": "^0.1.13",
|
"encoding": "^0.1.13",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"lucide-react": "^0.290.0",
|
"infisical-node": "^1.5.1",
|
||||||
"next": "14.0.0",
|
"lucide-react": "^0.370.0",
|
||||||
|
"next": "^14.1.0",
|
||||||
"next-build-id": "^3.0.0",
|
"next-build-id": "^3.0.0",
|
||||||
|
"next-sitemap": "^4.2.3",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"node-fetch-cache": "^3.1.3",
|
"react": "^18.2.0",
|
||||||
"react": "^18",
|
|
||||||
"react-chartjs-2": "^5.2.0",
|
"react-chartjs-2": "^5.2.0",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18.2.0",
|
||||||
"react-toastify": "^9.1.3",
|
"react-toastify": "^10.0.4",
|
||||||
"sharp": "^0.32.6",
|
"redis": "^4.6.12",
|
||||||
"tailwind-merge": "^2.0.0",
|
"sharp": "^0.33.0",
|
||||||
|
"tailwind-merge": "^2.2.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zustand": "^4.4.3"
|
"websocket": "^1.0.34",
|
||||||
|
"zustand": "^4.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@next/bundle-analyzer": "^14.0.0",
|
"@next/bundle-analyzer": "^14.1.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20.11.14",
|
||||||
"@types/node-fetch-cache": "^3.0.3",
|
"@types/react": "^18.2.48",
|
||||||
"@types/react": "^18",
|
"@types/react-dom": "^18.2.18",
|
||||||
"@types/react-dom": "^18",
|
"@types/websocket": "^1.0.10",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.17",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^8",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-next": "14.0.0",
|
"eslint-config-next": "14.1.0",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.33",
|
||||||
"prettier": "^3.0.3",
|
"prettier": "^3.2.4",
|
||||||
"prettier-plugin-tailwindcss": "^0.5.6",
|
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
5154
pnpm-lock.yaml
generated
Normal file
5154
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
public/BingSiteAuth.xml
Normal file
4
public/BingSiteAuth.xml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<users>
|
||||||
|
<user>8100264FFBFADD8CD0134169A492D34B</user>
|
||||||
|
</users>
|
BIN
public/assets/logos/beatleader.png
Normal file
BIN
public/assets/logos/beatleader.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
BIN
public/assets/logos/scoresaber.png
Normal file
BIN
public/assets/logos/scoresaber.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
@ -1,2 +1,9 @@
|
|||||||
|
# *
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
|
|
||||||
|
# Host
|
||||||
|
Host: https://ssr.fascinated.cc
|
||||||
|
|
||||||
|
# Sitemaps
|
||||||
|
Sitemap: https://ssr.fascinated.cc/sitemap.xml
|
||||||
|
@ -1,15 +0,0 @@
|
|||||||
// This file configures the initialization of Sentry on the client.
|
|
||||||
// The config you add here will be used whenever a users loads a page in their browser.
|
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
|
|
||||||
Sentry.init({
|
|
||||||
dsn: "https://1922e9d5b729f1568ad1e9537a118056@sentry.fascinated.cc/2",
|
|
||||||
|
|
||||||
// Adjust this value in production, or use tracesSampler for greater control
|
|
||||||
tracesSampleRate: 1,
|
|
||||||
|
|
||||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
|
||||||
debug: false,
|
|
||||||
});
|
|
@ -1,16 +0,0 @@
|
|||||||
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
|
|
||||||
// The config you add here will be used whenever one of the edge features is loaded.
|
|
||||||
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
|
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
|
|
||||||
Sentry.init({
|
|
||||||
dsn: "https://1922e9d5b729f1568ad1e9537a118056@sentry.fascinated.cc/2",
|
|
||||||
|
|
||||||
// Adjust this value in production, or use tracesSampler for greater control
|
|
||||||
tracesSampleRate: 1,
|
|
||||||
|
|
||||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
|
||||||
debug: false,
|
|
||||||
});
|
|
@ -1,15 +0,0 @@
|
|||||||
// This file configures the initialization of Sentry on the server.
|
|
||||||
// The config you add here will be used whenever the server handles a request.
|
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
|
|
||||||
Sentry.init({
|
|
||||||
dsn: "https://1922e9d5b729f1568ad1e9537a118056@sentry.fascinated.cc/2",
|
|
||||||
|
|
||||||
// Adjust this value in production, or use tracesSampler for greater control
|
|
||||||
tracesSampleRate: 1,
|
|
||||||
|
|
||||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
|
||||||
debug: false,
|
|
||||||
});
|
|
@ -1,84 +0,0 @@
|
|||||||
import AnalyticsChart from "@/components/AnalyticsChart";
|
|
||||||
import Card from "@/components/Card";
|
|
||||||
import Container from "@/components/Container";
|
|
||||||
import { ScoresaberMetricsHistory } from "@/schemas/fascinated/scoresaberMetricsHistory";
|
|
||||||
import { ssrSettings } from "@/ssrSettings";
|
|
||||||
import { formatNumber } from "@/utils/numberUtils";
|
|
||||||
import { isProduction } from "@/utils/utils";
|
|
||||||
import { Metadata } from "next";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
async function getData() {
|
|
||||||
const response = await fetch(
|
|
||||||
"https://bs-tracker.fascinated.cc/analytics?time=30d",
|
|
||||||
{
|
|
||||||
next: {
|
|
||||||
revalidate: isProduction() ? 600 : 0, // 10 minutes (0 seconds in dev)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const json = await response.json();
|
|
||||||
return {
|
|
||||||
data: json as ScoresaberMetricsHistory,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
|
||||||
const { data } = await getData();
|
|
||||||
|
|
||||||
const description =
|
|
||||||
"View Scoresaber metrics and statistics over the last 30 days.";
|
|
||||||
|
|
||||||
const lastActivePlayers =
|
|
||||||
data.activePlayersHistory[data.activePlayersHistory.length - 1].value;
|
|
||||||
const lastScoreCount =
|
|
||||||
data.scoreCountHistory[data.scoreCountHistory.length - 1].value;
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: `Analytics`,
|
|
||||||
description: description,
|
|
||||||
openGraph: {
|
|
||||||
siteName: ssrSettings.siteName,
|
|
||||||
title: `Analytics`,
|
|
||||||
description:
|
|
||||||
description +
|
|
||||||
`
|
|
||||||
|
|
||||||
Players currently online: ${formatNumber(lastActivePlayers)}
|
|
||||||
Scores set Today: ${formatNumber(lastScoreCount)}`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function Analytics() {
|
|
||||||
const { data } = await getData();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<Container>
|
|
||||||
<Card className="flex flex-col items-center justify-center">
|
|
||||||
<h1 className="text-center text-3xl font-bold">Analytics</h1>
|
|
||||||
<p className="text-center">
|
|
||||||
Scoresaber metrics and statistics over the last 30 days.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-300">
|
|
||||||
Want more in-depth data? Click{" "}
|
|
||||||
<span className="text-pp-blue">
|
|
||||||
<Link
|
|
||||||
href="https://grafana.fascinated.cc/d/b3c6c28d-39e9-4fa9-8e2b-b0ddb10f875e/beatsaber-metrics"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
here
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 h-[400px] w-full">
|
|
||||||
<AnalyticsChart historyData={data} />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Container>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
100
src/app/api/beatsaver/mapdata/route.ts
Normal file
100
src/app/api/beatsaver/mapdata/route.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { Redis } from "@/lib/db/redis";
|
||||||
|
import { BeatsaverMap } from "@/schemas/beatsaver/BeatsaverMap";
|
||||||
|
import { BeatsaverAPI } from "@/utils/beatsaver/api";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const mapHashes = searchParams.get("hashes");
|
||||||
|
|
||||||
|
if (!mapHashes) {
|
||||||
|
return new Response("mapHashes parameter is required", { status: 400 });
|
||||||
|
}
|
||||||
|
let toFetch: any[] = [];
|
||||||
|
if (mapHashes.includes(",")) {
|
||||||
|
const parts = mapHashes.substring(0, mapHashes.length - 1).split(",");
|
||||||
|
toFetch.push(...parts);
|
||||||
|
} else {
|
||||||
|
toFetch.push(mapHashes);
|
||||||
|
}
|
||||||
|
// Convert all hashes to uppercase
|
||||||
|
for (const hash of toFetch) {
|
||||||
|
toFetch[toFetch.indexOf(hash)] = hash.toUpperCase();
|
||||||
|
}
|
||||||
|
// Remove duplicates
|
||||||
|
toFetch = toFetch.filter((hash, index) => toFetch.indexOf(hash) === index);
|
||||||
|
|
||||||
|
const idOnly = searchParams.get("idonly") === "true";
|
||||||
|
let totalInCache = 0;
|
||||||
|
|
||||||
|
const maps: Record<string, BeatsaverMap | { id: string }> = {};
|
||||||
|
|
||||||
|
const fetchMapFromCache = async (mapHash: string) => {
|
||||||
|
const cachedMap = await (
|
||||||
|
await Redis.client
|
||||||
|
).get(`beatsaver:map:${mapHash}`);
|
||||||
|
return cachedMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAndCacheMap = async (mapHash: string) => {
|
||||||
|
const beatSaverMap = await BeatsaverAPI.fetchMapByHash(mapHash);
|
||||||
|
|
||||||
|
if (beatSaverMap) {
|
||||||
|
addMap(mapHash, beatSaverMap);
|
||||||
|
await cacheMap(mapHash, beatSaverMap);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cacheMap = async (mapHash: string, map: BeatsaverMap) => {
|
||||||
|
await (
|
||||||
|
await Redis.client
|
||||||
|
).set(
|
||||||
|
`beatsaver:map:${mapHash}`,
|
||||||
|
JSON.stringify(idOnly ? { id: map.id } : map),
|
||||||
|
"EX",
|
||||||
|
60 * 60 * 24 * 7,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMap = (mapHash: string, map: any) => {
|
||||||
|
maps[mapHash] = idOnly ? { id: map.id } : map;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const mapHash of toFetch) {
|
||||||
|
const map = await fetchMapFromCache(mapHash);
|
||||||
|
if (map !== null) {
|
||||||
|
const json = JSON.parse(map);
|
||||||
|
addMap(mapHash, json);
|
||||||
|
totalInCache++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalInCache === 0 && toFetch.length > 1) {
|
||||||
|
const beatSaverMaps = await BeatsaverAPI.fetchMapsByHash(...toFetch);
|
||||||
|
if (beatSaverMaps) {
|
||||||
|
for (const mapHash of toFetch) {
|
||||||
|
const beatSaverMap = beatSaverMaps[mapHash.toLowerCase()];
|
||||||
|
|
||||||
|
if (beatSaverMap) {
|
||||||
|
await cacheMap(mapHash, beatSaverMap);
|
||||||
|
addMap(mapHash, beatSaverMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const mapHash of toFetch) {
|
||||||
|
if (!maps[mapHash]) {
|
||||||
|
await fetchAndCacheMap(mapHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
maps,
|
||||||
|
totalInCache,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: { "content-type": "application/json;charset=UTF-8" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
@ -21,7 +21,7 @@ export default async function Analytics() {
|
|||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<Container>
|
<Container>
|
||||||
<Card>
|
<Card outerClassName="mt-2">
|
||||||
<h1 className="mb-1 text-3xl font-bold">Credits</h1>
|
<h1 className="mb-1 text-3xl font-bold">Credits</h1>
|
||||||
<p className="mb-8 text-gray-300">
|
<p className="mb-8 text-gray-300">
|
||||||
This website is made possible because of the following:
|
This website is made possible because of the following:
|
||||||
|
@ -5,64 +5,47 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 224 71.4% 4.1%;
|
--foreground: 240 10% 3.9%;
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 224 71.4% 4.1%;
|
--card-foreground: 240 10% 3.9%;
|
||||||
|
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 224 71.4% 4.1%;
|
--popover-foreground: 240 10% 3.9%;
|
||||||
|
--primary: 262.1 83.3% 57.8%;
|
||||||
--primary: 220.9 39.3% 11%;
|
--primary-foreground: 355.7 100% 97.3%;
|
||||||
--primary-foreground: 210 20% 98%;
|
--secondary: 240 4.8% 95.9%;
|
||||||
|
--secondary-foreground: 240 5.9% 10%;
|
||||||
--secondary: 220 14.3% 95.9%;
|
--muted: 240 4.8% 95.9%;
|
||||||
--secondary-foreground: 220.9 39.3% 11%;
|
--muted-foreground: 240 3.8% 46.1%;
|
||||||
|
--accent: 240 4.8% 95.9%;
|
||||||
--muted: 220 14.3% 95.9%;
|
--accent-foreground: 240 5.9% 10%;
|
||||||
--muted-foreground: 220 8.9% 46.1%;
|
|
||||||
|
|
||||||
--accent: 220 14.3% 95.9%;
|
|
||||||
--accent-foreground: 220.9 39.3% 11%;
|
|
||||||
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 210 20% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 240 5.9% 90%;
|
||||||
--border: 220 13% 91%;
|
--input: 240 5.9% 90%;
|
||||||
--input: 220 13% 91%;
|
--ring: 262.1 83.3% 57.8%;
|
||||||
--ring: 224 71.4% 4.1%;
|
|
||||||
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 224 71.4% 4.1%;
|
--background: 20 14.3% 4.1%;
|
||||||
--foreground: 210 20% 98%;
|
--foreground: 0 0% 95%;
|
||||||
|
--card: 24 9.8% 10%;
|
||||||
--card: 224 71.4% 4.1%;
|
--card-foreground: 0 0% 95%;
|
||||||
--card-foreground: 210 20% 98%;
|
--popover: 0 0% 9%;
|
||||||
|
--popover-foreground: 0 0% 95%;
|
||||||
--popover: 224 71.4% 4.1%;
|
--primary: 262.1 83.3% 57.8%;
|
||||||
--popover-foreground: 210 20% 98%;
|
--primary-foreground: 144.9 80.4% 10%;
|
||||||
|
--secondary: 240 3.7% 15.9%;
|
||||||
--primary: 210 20% 98%;
|
--secondary-foreground: 0 0% 98%;
|
||||||
--primary-foreground: 220.9 39.3% 11%;
|
--muted: 0 0% 15%;
|
||||||
|
--muted-foreground: 240 5% 64.9%;
|
||||||
--secondary: 215 27.9% 16.9%;
|
--accent: 12 6.5% 15.1%;
|
||||||
--secondary-foreground: 210 20% 98%;
|
--accent-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--muted: 215 27.9% 16.9%;
|
|
||||||
--muted-foreground: 217.9 10.6% 64.9%;
|
|
||||||
|
|
||||||
--accent: 215 27.9% 16.9%;
|
|
||||||
--accent-foreground: 210 20% 98%;
|
|
||||||
|
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 210 20% 98%;
|
--destructive-foreground: 0 85.7% 97.3%;
|
||||||
|
--border: 240 3.7% 15.9%;
|
||||||
--border: 215 27.9% 16.9%;
|
--input: 240 3.7% 15.9%;
|
||||||
--input: 215 27.9% 16.9%;
|
--ring: 262.1 83.3% 57.8%;
|
||||||
--ring: 216 12.2% 83.9%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,31 +1,33 @@
|
|||||||
import AppProvider from "@/components/AppProvider";
|
import AppProvider from "@/components/AppProvider";
|
||||||
import { ssrSettings } from "@/ssrSettings";
|
import { ThemeProvider } from "@/components/ui/theme-provider";
|
||||||
|
import ssrSettings from "@/ssrSettings.json";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Metadata } from "next";
|
import { Metadata, Viewport } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import Image from "next/image";
|
|
||||||
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";
|
||||||
|
|
||||||
const font = Inter({ subsets: ["latin-ext"], weight: "500" });
|
const font = Inter({ subsets: ["latin-ext"], weight: "500" });
|
||||||
|
|
||||||
// TODO: use type when NextJS fixes the type:
|
export const viewport: Viewport = {
|
||||||
// export const viewport: Viewport = {
|
|
||||||
export const viewport: any = {
|
|
||||||
themeColor: "#3B82F6",
|
themeColor: "#3B82F6",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL(ssrSettings.siteUrl),
|
metadataBase: new URL(ssrSettings.siteUrl),
|
||||||
title: {
|
title: {
|
||||||
template: ssrSettings.siteName + " - %s",
|
template: ssrSettings.siteNameShort + " - %s",
|
||||||
default: ssrSettings.siteName,
|
default: ssrSettings.siteName,
|
||||||
},
|
},
|
||||||
description: ssrSettings.description,
|
description: ssrSettings.description,
|
||||||
keywords:
|
keywords:
|
||||||
"scoresaber, score saber, scoresaber stats, score saber stats, beatleader, beat leader, " +
|
"scoresaber, score saber, scoresaber stats, score saber stats, beatleader, beat leader," +
|
||||||
"scoresaber reloaded, ssr, github, score aggregation, scoresaber api, score saber api, scoresaber api",
|
"scoresaber reloaded, ssr, github, score aggregation, scoresaber api, score saber api, scoresaber api," +
|
||||||
|
"BeatSaber, Overlay, OBS, Twitch, YouTube, BeatSaber Overlay, Github, Beat Saber overlay, ScoreSaber, BeatLeader," +
|
||||||
|
"VR gaming, Twitch stream enhancement, Customizable overlay, Real-time scores, Rankings, Leaderboard information," +
|
||||||
|
"Stream enhancement, Professional overlay, Easy to use overlay builder.",
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: ssrSettings.siteName,
|
title: ssrSettings.siteName,
|
||||||
description: ssrSettings.description,
|
description: ssrSettings.description,
|
||||||
@ -44,22 +46,20 @@ export default function RootLayout({
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<Script
|
<Script
|
||||||
id="plausible"
|
id="plausible"
|
||||||
defer
|
|
||||||
data-domain="ssr.fascinated.cc"
|
data-domain="ssr.fascinated.cc"
|
||||||
src="https://analytics.fascinated.cc/js/script.js"
|
src="https://analytics.fascinated.cc/js/script.js"
|
||||||
|
defer
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<body className={clsx(font.className, "bg-black text-white")}>
|
<body className={clsx(font.className, "text-primary")}>
|
||||||
<div className="fixed left-0 top-0 z-0 h-full w-full blur-sm">
|
<ThemeProvider
|
||||||
<Image
|
storageKey="ssr-theme"
|
||||||
className="object-fill object-center"
|
attribute="class"
|
||||||
alt="Background image"
|
defaultTheme="dark"
|
||||||
src={"/assets/background.webp"}
|
enableSystem
|
||||||
fill
|
>
|
||||||
/>
|
<AppProvider>{children}</AppProvider>
|
||||||
</div>
|
</ThemeProvider>
|
||||||
|
|
||||||
<AppProvider>{children}</AppProvider>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
@ -4,7 +4,10 @@ import Container from "@/components/Container";
|
|||||||
export default async function NotFound() {
|
export default async function NotFound() {
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Card className="flex h-full items-center justify-center">
|
<Card
|
||||||
|
outerClassName="mt-2"
|
||||||
|
className="flex h-full flex-col items-center justify-center"
|
||||||
|
>
|
||||||
<p className="text-xl font-bold text-red-500">404 Not Found</p>
|
<p className="text-xl font-bold text-red-500">404 Not Found</p>
|
||||||
<p className="text-lg text-gray-300">
|
<p className="text-lg text-gray-300">
|
||||||
The page you requested does not exist.
|
The page you requested does not exist.
|
||||||
|
142
src/app/overlay/builder/page.tsx
Normal file
142
src/app/overlay/builder/page.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
"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";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the overlay with the current settings
|
||||||
|
*
|
||||||
|
* @param settings the settings to pass to the overlay
|
||||||
|
*/
|
||||||
|
function openOverlay(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>
|
||||||
|
<div className="mb-2">
|
||||||
|
<p>
|
||||||
|
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>
|
||||||
|
<p>
|
||||||
|
The overlay requires{" "}
|
||||||
|
<span className="transform-gpu text-pp-blue transition-all hover:opacity-80">
|
||||||
|
<Link href={"https://github.com/denpadokei/HttpSiraStatus"}>
|
||||||
|
HttpSiraStatus
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SwitchInput
|
||||||
|
id="show-score-stats"
|
||||||
|
label="Show Song Stats"
|
||||||
|
defaultChecked={settingsStore.settings.showScoreStats}
|
||||||
|
onChange={(value) => {
|
||||||
|
settingsStore.setShowScoreStats(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="mt-3"
|
||||||
|
onClick={() => {
|
||||||
|
if (!settingsStore.ipAddress) {
|
||||||
|
toast.error("No IP Address provided");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!settingsStore.accountId) {
|
||||||
|
toast.error("No Account ID provided");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openOverlay(settingsStore);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Open Overlay
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Container>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
138
src/app/overlay/page.tsx
Normal file
138
src/app/overlay/page.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Spinner from "@/components/Spinner";
|
||||||
|
import PlayerStats from "@/components/overlay/PlayerStats";
|
||||||
|
import ScoreStats from "@/components/overlay/ScoreStats";
|
||||||
|
import SongInfo from "@/components/overlay/SongInfo";
|
||||||
|
import { HttpSiraStatus } from "@/lib/overlay/httpSiraStatus";
|
||||||
|
import { OverlayPlayer } from "@/lib/overlay/type/overlayPlayer";
|
||||||
|
import { BeatLeaderAPI } from "@/utils/beatleader/api";
|
||||||
|
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: OverlayPlayer | undefined;
|
||||||
|
config: any | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Overlay extends Component<OverlayProps, OverlayState> {
|
||||||
|
constructor(props: OverlayProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
mounted: false,
|
||||||
|
player: undefined,
|
||||||
|
config: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlayer = async (
|
||||||
|
playerId: string,
|
||||||
|
leaderboard: "scoresaber" | "beatleader" = "scoresaber",
|
||||||
|
) => {
|
||||||
|
console.log(`Updating player stats for ${playerId}`);
|
||||||
|
if (leaderboard == "scoresaber") {
|
||||||
|
const player = await ScoreSaberAPI.fetchPlayerData(playerId);
|
||||||
|
if (!player) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setState({
|
||||||
|
player: {
|
||||||
|
id: player.id,
|
||||||
|
profilePicture: player.profilePicture,
|
||||||
|
country: player.country,
|
||||||
|
pp: player.pp,
|
||||||
|
rank: player.rank,
|
||||||
|
countryRank: player.countryRank,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leaderboard == "beatleader") {
|
||||||
|
const player = await BeatLeaderAPI.fetchPlayerData(playerId);
|
||||||
|
if (!player) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setState({
|
||||||
|
player: {
|
||||||
|
id: player.id,
|
||||||
|
profilePicture: player.avatar,
|
||||||
|
country: player.country,
|
||||||
|
pp: player.pp,
|
||||||
|
rank: player.rank,
|
||||||
|
countryRank: player.countryRank,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
if (this.state.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setState({ mounted: true });
|
||||||
|
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const searchParams = url.searchParams;
|
||||||
|
const data = searchParams.get("data");
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const config = JSON.parse(data);
|
||||||
|
this.setState({ config: config });
|
||||||
|
const settings = config.settings;
|
||||||
|
|
||||||
|
if (settings.showPlayerStats) {
|
||||||
|
this.updatePlayer(config.accountId, config.platform);
|
||||||
|
setInterval(() => {
|
||||||
|
this.updatePlayer(config.accountId, config.platform);
|
||||||
|
}, UPDATE_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.showScoreStats || settings.showSongInfo) {
|
||||||
|
HttpSiraStatus.connectWebSocket();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { player, config, mounted } = this.state;
|
||||||
|
|
||||||
|
// Redirect to builder if no config is set
|
||||||
|
if (mounted && !config) {
|
||||||
|
window.location.href = "/overlay/builder";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading screen if player stats are enabled and not loaded yet
|
||||||
|
if (!mounted || (!player && config.settings.showPlayerStats)) {
|
||||||
|
return (
|
||||||
|
<main className="flex items-center !bg-transparent p-3">
|
||||||
|
<Spinner />
|
||||||
|
<p className="text-xl">Loading player data</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The overlay
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<div>
|
||||||
|
{config.settings.showPlayerStats && player && (
|
||||||
|
<PlayerStats player={player} config={config} />
|
||||||
|
)}
|
||||||
|
{config.settings.showScoreStats && <ScoreStats />}
|
||||||
|
</div>
|
||||||
|
{config.settings.showSongInfo && (
|
||||||
|
<div className="absolute bottom-0 left-0">
|
||||||
|
<SongInfo />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,23 @@
|
|||||||
import PlayerPage from "@/components/player/PlayerPage";
|
import Card from "@/components/Card";
|
||||||
import { ssrSettings } from "@/ssrSettings";
|
import Container from "@/components/Container";
|
||||||
|
import PlayerChart from "@/components/player/PlayerChart";
|
||||||
|
import PlayerInfo from "@/components/player/PlayerInfo";
|
||||||
|
import Scores from "@/components/player/Scores";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/Tooltip";
|
||||||
|
import ssrSettings from "@/ssrSettings.json";
|
||||||
|
import { SortTypes } from "@/types/SortTypes";
|
||||||
import { formatNumber } from "@/utils/numberUtils";
|
import { formatNumber } from "@/utils/numberUtils";
|
||||||
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
|
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
|
||||||
import { normalizedRegionName } from "@/utils/utils";
|
import { normalizedRegionName } from "@/utils/utils";
|
||||||
|
import clsx from "clsx";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
const DEFAULT_SORT_TYPE = SortTypes.top;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
params: { id: string; sort: string; page: string };
|
params: { id: string; sort: string; page: string };
|
||||||
@ -47,6 +61,85 @@ export async function generateMetadata({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Player({ params: { id, sort, page } }: Props) {
|
/**
|
||||||
return <PlayerPage id={id} sort={sort} page={page} />;
|
* Gets the player's data on the server side.
|
||||||
|
*
|
||||||
|
* @param id the player's id
|
||||||
|
* @returns the player's data
|
||||||
|
*/
|
||||||
|
async function getData(id: string, page: number, sort: string) {
|
||||||
|
const playerData = await ScoreSaberAPI.fetchPlayerData(id);
|
||||||
|
const playerScores = await ScoreSaberAPI.fetchScores(id, page, sort, 10);
|
||||||
|
return {
|
||||||
|
playerData: playerData,
|
||||||
|
playerScores: playerScores,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Player({ params: { id, sort, page } }: Props) {
|
||||||
|
const { playerData: player, playerScores } = await getData(
|
||||||
|
id,
|
||||||
|
Number(page),
|
||||||
|
sort,
|
||||||
|
);
|
||||||
|
if (!player) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<Container>
|
||||||
|
<Card outerClassName="mt-2">
|
||||||
|
<h1 className="text-2xl font-bold">Player not found</h1>
|
||||||
|
</Card>
|
||||||
|
</Container>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortType = SortTypes[sort] || DEFAULT_SORT_TYPE;
|
||||||
|
const badges = player.badges;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<Container>
|
||||||
|
<PlayerInfo playerData={player} />
|
||||||
|
{/* Chart */}
|
||||||
|
<Card outerClassName="mt-2 min-h-[320px]">
|
||||||
|
{/* Badges */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"mb-2 mt-2 flex flex-wrap items-center justify-center gap-2",
|
||||||
|
badges.length > 0 ? "block" : "hidden",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{badges.map((badge) => {
|
||||||
|
return (
|
||||||
|
<Tooltip key={badge.image}>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Image
|
||||||
|
src={badge.image}
|
||||||
|
alt={badge.description}
|
||||||
|
width={80}
|
||||||
|
height={30}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{badge.description}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="h-[320px] w-full">
|
||||||
|
<PlayerChart scoresaber={player} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Scores
|
||||||
|
initalScores={playerScores?.scores}
|
||||||
|
initalPage={Number(page)}
|
||||||
|
initalSortType={sortType}
|
||||||
|
initalTotalPages={playerScores?.pageInfo.totalPages}
|
||||||
|
playerData={player}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,7 @@ export default async function Analytics() {
|
|||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<Container>
|
<Container>
|
||||||
<Card>
|
<Card outerClassName="mt-2">
|
||||||
<h1 className="mb-1 text-3xl font-bold">Privacy</h1>
|
<h1 className="mb-1 text-3xl font-bold">Privacy</h1>
|
||||||
<p className="mb-8 text-gray-300">
|
<p className="mb-8 text-gray-300">
|
||||||
This site does not collect personal data. All of the data stored is
|
This site does not collect personal data. All of the data stored is
|
||||||
|
@ -1,4 +1,8 @@
|
|||||||
|
import Card from "@/components/Card";
|
||||||
|
import Container from "@/components/Container";
|
||||||
|
import Error from "@/components/Error";
|
||||||
import GlobalRanking from "@/components/GlobalRanking";
|
import GlobalRanking from "@/components/GlobalRanking";
|
||||||
|
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@ -9,6 +13,43 @@ type Props = {
|
|||||||
params: { page: string; country: string };
|
params: { page: string; country: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RankingGlobal({ params: { page, country } }: Props) {
|
async function getData(page: number, country: string) {
|
||||||
return <GlobalRanking page={Number(page)} country={country} />;
|
const response = await ScoreSaberAPI.fetchTopPlayers(page, country);
|
||||||
|
return {
|
||||||
|
data: response,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RankingGlobal({
|
||||||
|
params: { page, country },
|
||||||
|
}: Props) {
|
||||||
|
const { data } = await getData(Number(page), country);
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<Container>
|
||||||
|
<Card outerClassName="mt-2" className="mt-2">
|
||||||
|
<div className="p-3 text-center">
|
||||||
|
<div role="status">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2">
|
||||||
|
<Error errorMessage="Unable to find this page or country" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Container>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlobalRanking
|
||||||
|
pageInfo={{
|
||||||
|
page: Number(page),
|
||||||
|
totalPages: data.pageInfo.totalPages,
|
||||||
|
}}
|
||||||
|
players={data.players}
|
||||||
|
country={country}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,8 @@
|
|||||||
|
import Card from "@/components/Card";
|
||||||
|
import Container from "@/components/Container";
|
||||||
|
import Error from "@/components/Error";
|
||||||
import GlobalRanking from "@/components/GlobalRanking";
|
import GlobalRanking from "@/components/GlobalRanking";
|
||||||
|
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@ -9,6 +13,40 @@ type Props = {
|
|||||||
params: { page: string };
|
params: { page: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RankingGlobal({ params: { page } }: Props) {
|
async function getData(page: number) {
|
||||||
return <GlobalRanking page={Number(page)} />;
|
const response = await ScoreSaberAPI.fetchTopPlayers(page);
|
||||||
|
return {
|
||||||
|
data: response,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RankingGlobal({ params: { page } }: Props) {
|
||||||
|
const { data } = await getData(Number(page));
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<Container>
|
||||||
|
<Card outerClassName="mt-2" className="mt-2">
|
||||||
|
<div className="p-3 text-center">
|
||||||
|
<div role="status">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2">
|
||||||
|
<Error errorMessage="Unable to find this page" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Container>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlobalRanking
|
||||||
|
pageInfo={{
|
||||||
|
page: Number(page),
|
||||||
|
totalPages: data.pageInfo.totalPages,
|
||||||
|
}}
|
||||||
|
players={data.players}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -13,11 +13,14 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<Container>
|
<Container>
|
||||||
<Card className="flex flex-col items-center justify-center">
|
<Card
|
||||||
|
outerClassName="mt-2"
|
||||||
|
className="flex flex-col items-center justify-center"
|
||||||
|
>
|
||||||
<UnknownAvatar />
|
<UnknownAvatar />
|
||||||
|
|
||||||
<p className="text-xl">Stranger</p>
|
<h1 className="text-xl">Search Player</h1>
|
||||||
<p className="text mt-2">Find a player profile</p>
|
<p className="text mt-2 text-gray-300">Find yourself or a friend</p>
|
||||||
|
|
||||||
<SearchPlayer />
|
<SearchPlayer />
|
||||||
</Card>
|
</Card>
|
||||||
|
@ -43,11 +43,19 @@ export const options: any = {
|
|||||||
maxTicksLimit: 8,
|
maxTicksLimit: 8,
|
||||||
stepSize: 1,
|
stepSize: 1,
|
||||||
},
|
},
|
||||||
|
grid: {
|
||||||
|
// gray grid lines
|
||||||
|
color: "#252525",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
ticks: {
|
ticks: {
|
||||||
autoSkip: true,
|
autoSkip: true,
|
||||||
},
|
},
|
||||||
|
grid: {
|
||||||
|
// gray grid lines
|
||||||
|
color: "#252525",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
elements: {
|
elements: {
|
||||||
|
@ -3,8 +3,9 @@
|
|||||||
import { useScoresaberScoresStore } from "@/store/scoresaberScoresStore";
|
import { useScoresaberScoresStore } from "@/store/scoresaberScoresStore";
|
||||||
import { useSettingsStore } from "@/store/settingsStore";
|
import { useSettingsStore } from "@/store/settingsStore";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { ToastContainer } from "react-toastify";
|
||||||
import { TooltipProvider } from "./ui/Tooltip";
|
import { TooltipProvider } from "./ui/Tooltip";
|
||||||
import { ThemeProvider } from "./ui/theme-provider";
|
|
||||||
const UPDATE_INTERVAL = 1000 * 60 * 5; // 5 minutes
|
const UPDATE_INTERVAL = 1000 * 60 * 5; // 5 minutes
|
||||||
|
|
||||||
export default class AppProvider extends React.Component {
|
export default class AppProvider extends React.Component {
|
||||||
@ -50,9 +51,15 @@ export default class AppProvider extends React.Component {
|
|||||||
const props: any = this.props;
|
const props: any = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
|
<TooltipProvider>
|
||||||
<TooltipProvider>{props.children}</TooltipProvider>
|
<ToastContainer
|
||||||
</ThemeProvider>
|
className="z-50"
|
||||||
|
position="top-right"
|
||||||
|
theme="dark"
|
||||||
|
pauseOnFocusLoss={false}
|
||||||
|
/>
|
||||||
|
{props.children}
|
||||||
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,38 +3,38 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/Tooltip";
|
|||||||
|
|
||||||
interface ButtonProps {
|
interface ButtonProps {
|
||||||
text?: JSX.Element | string;
|
text?: JSX.Element | string;
|
||||||
url?: string;
|
|
||||||
icon?: JSX.Element;
|
icon?: JSX.Element;
|
||||||
color?: string;
|
color?: string;
|
||||||
tooltip?: React.ReactNode;
|
tooltip?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Button({
|
export default function Button({
|
||||||
text,
|
text,
|
||||||
url,
|
|
||||||
icon,
|
icon,
|
||||||
color,
|
color,
|
||||||
tooltip,
|
tooltip,
|
||||||
className,
|
className,
|
||||||
|
ariaLabel = "Default button label",
|
||||||
onClick,
|
onClick,
|
||||||
}: ButtonProps) {
|
}: ButtonProps) {
|
||||||
if (!color) color = "bg-blue-500";
|
if (!color) color = "bg-blue-500";
|
||||||
|
|
||||||
const base = (
|
const base = (
|
||||||
<a href={url} onClick={onClick}>
|
<button
|
||||||
<p
|
className={clsx(
|
||||||
className={clsx(
|
"flex items-center justify-center gap-2 rounded-md p-1",
|
||||||
"font-md flex w-fit transform-gpu flex-row items-center gap-1 rounded-md p-1 transition-all hover:opacity-80",
|
color,
|
||||||
className,
|
className,
|
||||||
color,
|
)}
|
||||||
)}
|
onClick={onClick}
|
||||||
>
|
aria-label={ariaLabel}
|
||||||
{icon}
|
>
|
||||||
{text}
|
{icon}
|
||||||
</p>
|
{text}
|
||||||
</a>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (tooltip) {
|
if (tooltip) {
|
||||||
|
@ -3,13 +3,20 @@ import clsx from "clsx";
|
|||||||
|
|
||||||
type CardProps = {
|
type CardProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
outerClassName?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Card({ className, children }: CardProps) {
|
export default function Card({
|
||||||
|
className,
|
||||||
|
outerClassName,
|
||||||
|
children,
|
||||||
|
}: CardProps) {
|
||||||
return (
|
return (
|
||||||
<CardBase className="mt-2">
|
<CardBase className={outerClassName}>
|
||||||
<CardContent className={clsx(className, "mt-2")}>{children}</CardContent>
|
<CardContent className={clsx(className, "p-3 pb-4 pt-2")}>
|
||||||
|
{children}
|
||||||
|
</CardContent>
|
||||||
</CardBase>
|
</CardBase>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,17 +1,19 @@
|
|||||||
import { ToastContainer } from "react-toastify";
|
import Image from "next/image";
|
||||||
import Footer from "./Footer";
|
import Footer from "./Footer";
|
||||||
import Navbar from "./Navbar";
|
import Navbar from "./navbar/Navbar";
|
||||||
|
|
||||||
export default function Container({ children }: { children: React.ReactNode }) {
|
export default function Container({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ToastContainer
|
<div className="fixed left-0 top-0 z-0 h-full w-full blur-sm">
|
||||||
className="z-50"
|
<Image
|
||||||
position="top-right"
|
className="object-fill object-center"
|
||||||
theme="dark"
|
alt="Background image"
|
||||||
pauseOnFocusLoss={false}
|
src={"/assets/background.webp"}
|
||||||
/>
|
fill
|
||||||
<div className="m-auto flex h-screen min-h-full flex-col items-center opacity-90 md:max-w-[1200px]">
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="z-[9999] m-auto flex h-screen min-h-full flex-col items-center opacity-90 md:max-w-[1200px]">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div className="w-full flex-1">{children}</div>
|
<div className="w-full flex-1">{children}</div>
|
||||||
|
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { ssrSettings } from "@/ssrSettings";
|
import ssrSettings from "@/ssrSettings.json";
|
||||||
import { isProduction } from "@/utils/utils";
|
import { isProduction } from "@/utils/utils";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Card from "./Card";
|
||||||
|
|
||||||
const buttons = [
|
const buttons = [
|
||||||
{
|
{
|
||||||
@ -13,42 +15,54 @@ const buttons = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const buildId = process.env.NEXT_PUBLIC_BUILD_ID
|
const buildId = process.env.NEXT_PUBLIC_BUILD_ID
|
||||||
? process.env.NEXT_PUBLIC_BUILD_ID.slice(0, 7) +
|
? isProduction()
|
||||||
(isProduction() ? "" : "-dev")
|
? process.env.NEXT_PUBLIC_BUILD_ID.slice(0, 7)
|
||||||
|
: "dev"
|
||||||
: "";
|
: "";
|
||||||
|
const buildTime = process.env.NEXT_PUBLIC_BUILD_TIME;
|
||||||
|
const gitUrl = isProduction()
|
||||||
|
? `https://git.fascinated.cc/Fascinated/scoresaber-reloaded-v2/commit/${buildId}`
|
||||||
|
: "https://s.fascinated.cc/s/ssr-gitea";
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer>
|
<footer className="p-3">
|
||||||
<div className="bg-background m-3 flex flex-col items-center justify-center gap-1 rounded-md p-3">
|
<Card className="mb-2 mt-2 flex flex-col items-center justify-center gap-1 !pb-1 !pt-0">
|
||||||
<div className="flex flex-row gap-3">
|
<div className="flex flex-col items-center gap-1 md:flex-row md:items-start md:gap-3">
|
||||||
<a
|
<a
|
||||||
className="transform-gpu transition-all hover:text-blue-500"
|
className="transform-gpu transition-all hover:text-blue-500"
|
||||||
href="https://git.fascinated.cc/Fascinated/scoresaber-reloaded-v2"
|
href="https://s.fascinated.cc/s/ssr-gitea"
|
||||||
>
|
>
|
||||||
{ssrSettings.siteName}
|
{ssrSettings.siteName}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{buttons.map((button, index) => {
|
<div className="flex divide-x divide-solid divide-neutral-500">
|
||||||
return (
|
{buttons.map((button, index) => {
|
||||||
<div
|
return (
|
||||||
key={index}
|
<div
|
||||||
className="flex flex-row items-center justify-center gap-3"
|
key={index}
|
||||||
>
|
className="flex flex-row items-center justify-center gap-3 pl-2 pr-2"
|
||||||
<div className="h-4 w-[1px] bg-neutral-100"></div>
|
|
||||||
<a
|
|
||||||
href={button.url}
|
|
||||||
className="transform-gpu transition-all hover:text-blue-500"
|
|
||||||
>
|
>
|
||||||
{button.name}
|
<a
|
||||||
</a>
|
href={button.url}
|
||||||
</div>
|
className="transform-gpu transition-all hover:text-blue-500"
|
||||||
);
|
>
|
||||||
})}
|
{button.name}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-gray-400">Build ID: {buildId}</div>
|
<Link
|
||||||
</div>
|
className="transform-gpu text-sm text-gray-400 transition-all hover:opacity-80"
|
||||||
|
href={gitUrl}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Build ID: {buildId} ({buildTime})
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,194 +1,81 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
|
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
|
||||||
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
|
|
||||||
import { normalizedRegionName } from "@/utils/utils";
|
import { normalizedRegionName } from "@/utils/utils";
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import Card from "./Card";
|
import Card from "./Card";
|
||||||
import Container from "./Container";
|
import Container from "./Container";
|
||||||
import CountyFlag from "./CountryFlag";
|
import CountyFlag from "./CountryFlag";
|
||||||
import Pagination from "./Pagination";
|
import Pagination from "./Pagination";
|
||||||
import Spinner from "./Spinner";
|
|
||||||
import PlayerRanking from "./player/PlayerRanking";
|
import PlayerRanking from "./player/PlayerRanking";
|
||||||
import PlayerRankingMobile from "./player/PlayerRankingMobile";
|
|
||||||
import { Separator } from "./ui/separator";
|
import { Separator } from "./ui/separator";
|
||||||
|
|
||||||
const Error = dynamic(() => import("@/components/Error"));
|
|
||||||
|
|
||||||
type PageInfo = {
|
|
||||||
loading: boolean;
|
|
||||||
page: number;
|
|
||||||
totalPages: number;
|
|
||||||
players: ScoresaberPlayer[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type GlobalRankingProps = {
|
type GlobalRankingProps = {
|
||||||
page: number;
|
players: ScoresaberPlayer[];
|
||||||
country?: string;
|
country?: string;
|
||||||
|
pageInfo: {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function GlobalRanking({ page, country }: GlobalRankingProps) {
|
export default function GlobalRanking({
|
||||||
const router = useRouter();
|
players,
|
||||||
const searchQuery = useSearchParams();
|
country,
|
||||||
const isMobile = searchQuery.get("mobile") == "true";
|
pageInfo,
|
||||||
|
}: GlobalRankingProps) {
|
||||||
const [error, setError] = useState(false);
|
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
|
||||||
|
|
||||||
const [pageInfo, setPageInfo] = useState<PageInfo>({
|
|
||||||
loading: true,
|
|
||||||
page: page,
|
|
||||||
totalPages: 1,
|
|
||||||
players: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatePage = useCallback(
|
|
||||||
(page: any) => {
|
|
||||||
const windowSize = document.documentElement.clientWidth;
|
|
||||||
if (windowSize < 768 && !isMobile) {
|
|
||||||
router.push(`/ranking/global/${page}?mobile=true`);
|
|
||||||
router.refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Switching page to", page);
|
|
||||||
ScoreSaberAPI.fetchTopPlayers(page, country).then((response) => {
|
|
||||||
if (!response) {
|
|
||||||
setError(true);
|
|
||||||
setErrorMessage("No players found");
|
|
||||||
setPageInfo({ ...pageInfo, loading: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPageInfo({
|
|
||||||
...pageInfo,
|
|
||||||
players: response.players,
|
|
||||||
totalPages: response.pageInfo.totalPages,
|
|
||||||
loading: false,
|
|
||||||
page: page,
|
|
||||||
});
|
|
||||||
window.history.pushState(
|
|
||||||
{},
|
|
||||||
"",
|
|
||||||
country
|
|
||||||
? `/ranking/country/${country}/${page}`
|
|
||||||
: `/ranking/global/${page}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[country, isMobile, pageInfo, router],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pageInfo.loading || error) return;
|
|
||||||
|
|
||||||
updatePage(pageInfo.page);
|
|
||||||
}, [error, country, updatePage, pageInfo.page, pageInfo.loading]);
|
|
||||||
|
|
||||||
if (pageInfo.loading || error) {
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<Container>
|
|
||||||
<Card className="mt-2">
|
|
||||||
<div className="p-3 text-center">
|
|
||||||
<div role="status">
|
|
||||||
<div className="flex flex-col items-center justify-center gap-2">
|
|
||||||
{error && <Error errorMessage={errorMessage} />}
|
|
||||||
{!error && <Spinner />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Container>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const players = pageInfo.players;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<Container>
|
<Container>
|
||||||
<Card className="mt-2">
|
<Card outerClassName="mt-2" className="mt-2">
|
||||||
{pageInfo.loading ? (
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex justify-center">
|
<div className="flex items-center gap-2 p-2">
|
||||||
<Spinner />
|
{country && (
|
||||||
|
<CountyFlag countryCode={country} className="!h-8 !w-8" />
|
||||||
|
)}
|
||||||
|
<p>
|
||||||
|
You are viewing{" "}
|
||||||
|
{country
|
||||||
|
? "scores from " + normalizedRegionName(country.toUpperCase())
|
||||||
|
: "Global scores"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center gap-2 p-2">
|
|
||||||
{country && (
|
|
||||||
<CountyFlag countryCode={country} className="!h-8 !w-8" />
|
|
||||||
)}
|
|
||||||
<p>
|
|
||||||
You are viewing{" "}
|
|
||||||
{country
|
|
||||||
? "scores from " + normalizedRegionName(country)
|
|
||||||
: "Global scores"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{!isMobile && (
|
<table className="table w-full table-auto border-spacing-2 border-none text-left">
|
||||||
<table className="table w-full table-auto border-spacing-2 border-none text-left">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th className="px-4 py-2">Rank</th>
|
||||||
<th className="px-4 py-2">Rank</th>
|
<th className="px-4 py-2">Profile</th>
|
||||||
<th className="px-4 py-2">Profile</th>
|
<th className="px-4 py-2">Performance Points</th>
|
||||||
<th className="px-4 py-2">Performance Points</th>
|
<th className="px-4 py-2">Total Plays</th>
|
||||||
<th className="px-4 py-2">Total Plays</th>
|
<th className="px-4 py-2">Total Ranked Plays</th>
|
||||||
<th className="px-4 py-2">Total Ranked Plays</th>
|
<th className="px-4 py-2">Avg Ranked Accuracy</th>
|
||||||
<th className="px-4 py-2">Avg Ranked Accuracy</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody className="border-none">
|
||||||
<tbody className="border-none">
|
{players.map((player) => (
|
||||||
{players.map((player) => (
|
<tr key={player.rank} className="border-b border-border">
|
||||||
<tr
|
<PlayerRanking
|
||||||
key={player.rank}
|
isCountry={country == undefined ? false : true}
|
||||||
className="border-b border-gray-800"
|
player={player}
|
||||||
>
|
/>
|
||||||
<PlayerRanking
|
</tr>
|
||||||
showCountryFlag={country ? false : true}
|
))}
|
||||||
player={player}
|
</tbody>
|
||||||
/>
|
</table>
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isMobile && (
|
{/* Pagination */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex w-full flex-row justify-center">
|
||||||
{players.map((player) => (
|
<div className="pt-3">
|
||||||
<div
|
<Pagination
|
||||||
key={player.rank}
|
currentPage={pageInfo.page}
|
||||||
className="flex flex-col gap-2 rounded-md bg-gray-700 hover:bg-gray-600"
|
totalPages={pageInfo.totalPages}
|
||||||
>
|
useHref
|
||||||
<Link href={`/player/${player.id}/top/1`}>
|
/>
|
||||||
<PlayerRankingMobile player={player} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
<div className="flex w-full flex-row justify-center">
|
|
||||||
<div className="pt-3">
|
|
||||||
<Pagination
|
|
||||||
currentPage={pageInfo.page}
|
|
||||||
totalPages={pageInfo.totalPages}
|
|
||||||
onPageChange={(page) => {
|
|
||||||
updatePage(page);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Container>
|
</Container>
|
||||||
</main>
|
</main>
|
||||||
|
@ -1,133 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useSettingsStore } from "@/store/settingsStore";
|
|
||||||
import useStore from "@/utils/useStore";
|
|
||||||
import {
|
|
||||||
CogIcon,
|
|
||||||
MagnifyingGlassIcon,
|
|
||||||
ServerIcon,
|
|
||||||
UserIcon,
|
|
||||||
} from "@heroicons/react/20/solid";
|
|
||||||
import { GlobeAltIcon } from "@heroicons/react/24/outline";
|
|
||||||
import Avatar from "./Avatar";
|
|
||||||
import Button from "./Button";
|
|
||||||
|
|
||||||
interface ButtonProps {
|
|
||||||
text: string;
|
|
||||||
icon?: JSX.Element;
|
|
||||||
href?: string;
|
|
||||||
ariaLabel: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function NavbarButton({ text, icon, href, ariaLabel, children }: ButtonProps) {
|
|
||||||
return (
|
|
||||||
<div className="group">
|
|
||||||
<a
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
className="flex h-full w-fit transform-gpu items-center justify-center gap-1 rounded-md p-3 transition-all hover:cursor-pointer hover:bg-blue-500"
|
|
||||||
href={href}
|
|
||||||
>
|
|
||||||
<>
|
|
||||||
{icon}
|
|
||||||
<p className="hidden md:block">{text}</p>
|
|
||||||
</>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{children && (
|
|
||||||
<div className="absolute z-20 hidden divide-y rounded-md bg-gray-600 opacity-[0.98] shadow-sm group-hover:flex">
|
|
||||||
<div className="p-2">{children}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Navbar() {
|
|
||||||
const settingsStore = useStore(useSettingsStore, (state) => state);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="bg-background flex h-fit w-full rounded-md">
|
|
||||||
{settingsStore !== undefined && settingsStore.player && (
|
|
||||||
<NavbarButton
|
|
||||||
ariaLabel="Your profile"
|
|
||||||
text="You"
|
|
||||||
icon={
|
|
||||||
<Avatar
|
|
||||||
url={settingsStore.player.profilePicture}
|
|
||||||
label="Your avatar"
|
|
||||||
size={32}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
href={`/player/${settingsStore.player.id}/top/1`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<NavbarButton
|
|
||||||
ariaLabel="View your friends"
|
|
||||||
text="Friends"
|
|
||||||
icon={<UserIcon height={20} width={20} />}
|
|
||||||
href="/search"
|
|
||||||
>
|
|
||||||
{settingsStore?.friends.length == 0 ? (
|
|
||||||
<p className="text-sm font-bold">No friends, add someone!</p>
|
|
||||||
) : (
|
|
||||||
settingsStore?.friends.map((friend) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={friend.id}
|
|
||||||
className="mt-2"
|
|
||||||
color="bg-gray-500"
|
|
||||||
text={friend.name}
|
|
||||||
url={`/player/${friend.id}/top/1`}
|
|
||||||
icon={
|
|
||||||
<Avatar
|
|
||||||
url={friend.profilePicture}
|
|
||||||
label={`${friend.name}'s avatar`}
|
|
||||||
size={20}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
className="mt-2"
|
|
||||||
text="Search"
|
|
||||||
url="/search"
|
|
||||||
icon={<MagnifyingGlassIcon height={20} width={20} />}
|
|
||||||
/>
|
|
||||||
</NavbarButton>
|
|
||||||
<NavbarButton
|
|
||||||
ariaLabel="View the global ranking"
|
|
||||||
text="Ranking"
|
|
||||||
icon={<GlobeAltIcon height={20} width={20} />}
|
|
||||||
href="/ranking/global/1"
|
|
||||||
/>
|
|
||||||
<NavbarButton
|
|
||||||
ariaLabel="View analytics for Scoresaber"
|
|
||||||
text="Analytics"
|
|
||||||
icon={<ServerIcon height={20} width={20} />}
|
|
||||||
href="/analytics"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="m-auto" />
|
|
||||||
|
|
||||||
<NavbarButton
|
|
||||||
ariaLabel="Search for a player"
|
|
||||||
text="Search"
|
|
||||||
icon={<MagnifyingGlassIcon height={20} width={20} />}
|
|
||||||
href="/search"
|
|
||||||
/>
|
|
||||||
<NavbarButton
|
|
||||||
ariaLabel="View your settings"
|
|
||||||
text="Settings"
|
|
||||||
icon={<CogIcon height={20} width={20} />}
|
|
||||||
href="/settings"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -2,15 +2,17 @@ import {
|
|||||||
ArrowUturnLeftIcon,
|
ArrowUturnLeftIcon,
|
||||||
ArrowUturnRightIcon,
|
ArrowUturnRightIcon,
|
||||||
} from "@heroicons/react/20/solid";
|
} from "@heroicons/react/20/solid";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
type PaginationProps = {
|
type PaginationProps = {
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
onPageChange: (pageNumber: number) => void;
|
useHref?: boolean;
|
||||||
|
onPageChange?: (pageNumber: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Pagination(props: PaginationProps) {
|
export default function Pagination(props: PaginationProps) {
|
||||||
const { currentPage, totalPages, onPageChange } = props;
|
const { currentPage, totalPages, useHref, onPageChange } = props;
|
||||||
|
|
||||||
// Calculate the range of page numbers to display
|
// Calculate the range of page numbers to display
|
||||||
const rangeStart = Math.max(1, currentPage - 2);
|
const rangeStart = Math.max(1, currentPage - 2);
|
||||||
@ -23,31 +25,53 @@ export default function Pagination(props: PaginationProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center text-white">
|
<div className="flex justify-center text-primary">
|
||||||
<nav>
|
<nav>
|
||||||
<ul className="flex items-center gap-2">
|
<ul className="flex items-center gap-2">
|
||||||
{currentPage > 1 && (
|
{currentPage > 1 && (
|
||||||
<li className="rounded-md bg-neutral-700 hover:opacity-80">
|
<li className="rounded-md bg-neutral-700 hover:opacity-80">
|
||||||
<button
|
{useHref ? (
|
||||||
className="px-3 py-1"
|
<Link href={`${currentPage - 1}`}>
|
||||||
onClick={() => onPageChange(currentPage - 1)}
|
<p
|
||||||
aria-label={`Page ${currentPage - 1} (previous page)`}
|
className="px-3 py-1"
|
||||||
>
|
aria-label={`Page ${currentPage - 1} (previous page)`}
|
||||||
<ArrowUturnLeftIcon width={20} height={20} />
|
>
|
||||||
</button>
|
<ArrowUturnLeftIcon width={20} height={20} />
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="px-3 py-1"
|
||||||
|
onClick={() => onPageChange && onPageChange(currentPage - 1)}
|
||||||
|
aria-label={`Page ${currentPage - 1} (previous page)`}
|
||||||
|
>
|
||||||
|
<ArrowUturnLeftIcon width={20} height={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentPage !== 1 && currentPage - 2 > 1 && (
|
{currentPage !== 1 && currentPage - 2 > 1 && (
|
||||||
<>
|
<>
|
||||||
<li>
|
<li>
|
||||||
<button
|
{useHref ? (
|
||||||
className="rounded-md bg-neutral-700 px-3 py-1 hover:opacity-80"
|
<Link href={`${1}`}>
|
||||||
onClick={() => onPageChange(1)}
|
<p
|
||||||
aria-label="Page 1 (first page)"
|
className="rounded-md bg-neutral-700 px-3 py-1 hover:opacity-80"
|
||||||
>
|
aria-label="Page 1 (first page)"
|
||||||
1
|
>
|
||||||
</button>
|
1
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-neutral-700 px-3 py-1 hover:opacity-80"
|
||||||
|
onClick={() => onPageChange && onPageChange(1)}
|
||||||
|
aria-label="Page 1 (first page)"
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p>...</p>
|
<p>...</p>
|
||||||
@ -57,17 +81,32 @@ export default function Pagination(props: PaginationProps) {
|
|||||||
|
|
||||||
{pageNumbers.map((pageNumber) => (
|
{pageNumbers.map((pageNumber) => (
|
||||||
<li key={pageNumber}>
|
<li key={pageNumber}>
|
||||||
<button
|
{useHref ? (
|
||||||
className={`rounded-md px-3 py-1 ${
|
<Link href={`${pageNumber}`}>
|
||||||
pageNumber === currentPage
|
<p
|
||||||
? "bg-blue-500 text-white"
|
className={`rounded-md px-3 py-1 ${
|
||||||
: "bg-neutral-700 hover:opacity-80"
|
pageNumber === currentPage
|
||||||
}`}
|
? "bg-blue-500 text-primary"
|
||||||
onClick={() => onPageChange(pageNumber)}
|
: "bg-neutral-700 hover:opacity-80"
|
||||||
aria-label={`Page ${pageNumber}`}
|
}`}
|
||||||
>
|
aria-label={`Page ${pageNumber}`}
|
||||||
{pageNumber}
|
>
|
||||||
</button>
|
{pageNumber}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={`rounded-md px-3 py-1 ${
|
||||||
|
pageNumber === currentPage
|
||||||
|
? "bg-blue-500 text-primary"
|
||||||
|
: "bg-neutral-700 hover:opacity-80"
|
||||||
|
}`}
|
||||||
|
onClick={() => onPageChange && onPageChange(pageNumber)}
|
||||||
|
aria-label={`Page ${pageNumber}`}
|
||||||
|
>
|
||||||
|
{pageNumber}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@ -78,26 +117,48 @@ export default function Pagination(props: PaginationProps) {
|
|||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<button
|
{useHref ? (
|
||||||
className="rounded-md bg-neutral-700 px-3 py-1 hover:opacity-80"
|
<Link href={`${totalPages}`}>
|
||||||
onClick={() => onPageChange(totalPages)}
|
<p
|
||||||
aria-label={`Page ${totalPages} (last page)`}
|
className="rounded-md bg-neutral-700 px-3 py-1 hover:opacity-80"
|
||||||
>
|
aria-label={`Page ${totalPages} (last page)`}
|
||||||
{totalPages}
|
>
|
||||||
</button>
|
{totalPages}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-neutral-700 px-3 py-1 hover:opacity-80"
|
||||||
|
onClick={() => onPageChange && onPageChange(totalPages)}
|
||||||
|
aria-label={`Page ${totalPages} (last page)`}
|
||||||
|
>
|
||||||
|
{totalPages}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentPage < totalPages && (
|
{currentPage < totalPages && (
|
||||||
<li className="rounded-md bg-neutral-700 hover:opacity-80">
|
<li className="rounded-md bg-neutral-700 hover:opacity-80">
|
||||||
<button
|
{useHref ? (
|
||||||
className="px-3 py-1"
|
<Link href={`${currentPage + 1}`}>
|
||||||
onClick={() => onPageChange(currentPage + 1)}
|
<p
|
||||||
aria-label={`Page ${currentPage + 1} (next page)`}
|
className="px-3 py-1"
|
||||||
>
|
aria-label={`Page ${currentPage + 1} (next page)`}
|
||||||
<ArrowUturnRightIcon width={20} height={20} />
|
>
|
||||||
</button>
|
<ArrowUturnRightIcon width={20} height={20} />
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="px-3 py-1"
|
||||||
|
onClick={() => onPageChange && onPageChange(currentPage + 1)}
|
||||||
|
aria-label={`Page ${currentPage + 1} (next page)`}
|
||||||
|
>
|
||||||
|
<ArrowUturnRightIcon width={20} height={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -5,12 +5,15 @@ import { formatNumber } from "@/utils/numberUtils";
|
|||||||
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
|
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
|
||||||
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
|
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
|
|
||||||
export default function SearchPlayer() {
|
export default function SearchPlayer() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [players, setPlayers] = useState([] as ScoresaberPlayer[]);
|
const [players, setPlayers] = useState(
|
||||||
|
undefined as ScoresaberPlayer[] | undefined,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Don't search if the query is too short
|
// Don't search if the query is too short
|
||||||
@ -28,14 +31,20 @@ export default function SearchPlayer() {
|
|||||||
if (id == undefined) return;
|
if (id == undefined) return;
|
||||||
|
|
||||||
const player = await ScoreSaberAPI.fetchPlayerData(id);
|
const player = await ScoreSaberAPI.fetchPlayerData(id);
|
||||||
if (player == undefined) return;
|
if (player == undefined) {
|
||||||
|
setPlayers([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setPlayers([player]);
|
setPlayers([player]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search by name
|
// Search by name
|
||||||
const players = await ScoreSaberAPI.searchByName(search);
|
const players = await ScoreSaberAPI.searchByName(search);
|
||||||
if (players == undefined) return;
|
if (players == undefined) {
|
||||||
|
setPlayers([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setPlayers(players);
|
setPlayers(players);
|
||||||
}
|
}
|
||||||
@ -44,7 +53,7 @@ export default function SearchPlayer() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Take the user to the first account
|
// Take the user to the first account
|
||||||
if (players.length > 0) {
|
if (players && players.length > 0) {
|
||||||
window.location.href = `/player/${players[0].id}/top/1`;
|
window.location.href = `/player/${players[0].id}/top/1`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -67,26 +76,28 @@ export default function SearchPlayer() {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"absolute z-20 mt-7 flex max-h-[200px] min-w-[14rem] flex-col divide-y overflow-y-auto rounded-md bg-gray-700 shadow-sm md:max-h-[300px]",
|
"absolute z-20 mt-7 flex max-h-[200px] min-w-[14rem] flex-col divide-y overflow-y-auto rounded-md bg-popover shadow-sm md:max-h-[300px]",
|
||||||
players.length > 0 ? "flex" : "hidden",
|
players ? "flex" : "hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{players.map((player: ScoresaberPlayer) => (
|
{players && players.length > 0
|
||||||
<a
|
? players.map((player: ScoresaberPlayer) => (
|
||||||
key={player.id}
|
<Link
|
||||||
className="flex min-w-[14rem] items-center gap-2 rounded-md p-2 transition-all hover:bg-gray-600"
|
key={player.id}
|
||||||
href={`/player/${player.id}/top/1`}
|
className="flex min-w-[14rem] items-center gap-2 p-2 transition-all hover:bg-background"
|
||||||
>
|
href={`/player/${player.id}/top/1`}
|
||||||
<Avatar label="Account" size={40} url={player.profilePicture} />
|
>
|
||||||
|
<Avatar label="Account" size={40} url={player.profilePicture} />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">
|
||||||
#{formatNumber(player.rank)}
|
#{formatNumber(player.rank)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm">{player.name}</p>
|
<p className="text-sm">{player.name}</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</Link>
|
||||||
))}
|
))
|
||||||
|
: search.length > 0 && <div className="p-2">No players found</div>}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
@ -16,14 +16,14 @@ export default function BeatSaverLogo({
|
|||||||
version="1.1"
|
version="1.1"
|
||||||
className={className}
|
className={className}
|
||||||
>
|
>
|
||||||
<g fill="none" stroke="#000000" stroke-width="10">
|
<g fill="none" stroke="#000000" strokeWidth="10">
|
||||||
<path d="M 100,7 189,47 100,87 12,47 Z" stroke-linejoin="round"></path>
|
<path d="M 100,7 189,47 100,87 12,47 Z" strokeLinejoin="round"></path>
|
||||||
<path
|
<path
|
||||||
d="M 189,47 189,155 100,196 12,155 12,47"
|
d="M 189,47 189,155 100,196 12,155 12,47"
|
||||||
stroke-linejoin="round"
|
strokeLinejoin="round"
|
||||||
></path>
|
></path>
|
||||||
<path d="M 100,87 100,196" stroke-linejoin="round"></path>
|
<path d="M 100,87 100,196" strokeLinejoin="round"></path>
|
||||||
<path d="M 26,77 85,106 53,130 Z" stroke-linejoin="round"></path>
|
<path d="M 26,77 85,106 53,130 Z" strokeLinejoin="round"></path>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
33
src/components/icons/YouTubeLogo.tsx
Normal file
33
src/components/icons/YouTubeLogo.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
type YouTubeLogoProps = {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function YouTubeLogo({
|
||||||
|
size = 32,
|
||||||
|
className,
|
||||||
|
}: YouTubeLogoProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
height={size}
|
||||||
|
width={size}
|
||||||
|
version="1.1"
|
||||||
|
id="Layer_1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 461.001 461.001"
|
||||||
|
xmlSpace="preserve"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
fill="#F61C0D"
|
||||||
|
d="M365.257,67.393H95.744C42.866,67.393,0,110.259,0,163.137v134.728
|
||||||
|
c0,52.878,42.866,95.744,95.744,95.744h269.513c52.878,0,95.744-42.866,95.744-95.744V163.137
|
||||||
|
C461.001,110.259,418.135,67.393,365.257,67.393z M300.506,237.056l-126.06,60.123c-3.359,1.602-7.239-0.847-7.239-4.568V168.607
|
||||||
|
c0-3.774,3.982-6.22,7.348-4.514l126.06,63.881C304.363,229.873,304.298,235.248,300.506,237.056z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
24
src/components/input/Input.tsx
Normal file
24
src/components/input/Input.tsx
Normal 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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
45
src/components/input/RadioInput.tsx
Normal file
45
src/components/input/RadioInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
27
src/components/input/SwitchInput.tsx
Normal file
27
src/components/input/SwitchInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -96,8 +96,8 @@ export default function Leaderboard({ id, page }: LeaderboardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<div className="flex flex-col gap-2 md:flex-row">
|
<div className="mt-2 flex flex-col gap-2 xl:flex-row">
|
||||||
<Card className="mt-2 flex">
|
<Card outerClassName="h-fit pt-3" className="flex">
|
||||||
<div className="flex min-w-[300px] flex-wrap justify-between gap-2 md:justify-start">
|
<div className="flex min-w-[300px] flex-wrap justify-between gap-2 md:justify-start">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Image
|
<Image
|
||||||
@ -109,17 +109,19 @@ export default function Leaderboard({ id, page }: LeaderboardProps) {
|
|||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<p className="text-xl font-bold">{songName}</p>
|
<p className="text-xl font-bold">{songName}</p>
|
||||||
<p className="text-lg">{songSubName}</p>
|
{/* <p className="text-lg">{songSubName}</p> */}
|
||||||
<p>Mapped By: {levelAuthorName}</p>
|
<p className="text-gray-400">{levelAuthorName}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<p>Status: {ranked ? "Ranked" : "Unranked"}</p>
|
<p>Status: {ranked ? "Ranked" : "Unranked"}</p>
|
||||||
<div className="flex">
|
{ranked && (
|
||||||
<p>Stars:</p>
|
<div className="flex">
|
||||||
<StarIcon width={20} height={20} className="ml-1" />
|
<p>Stars:</p>
|
||||||
<p>{stars}</p>
|
<StarIcon width={20} height={20} className="ml-1" />
|
||||||
</div>
|
<p className="text-pp-blue">{stars}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p>
|
<p>
|
||||||
Plays: {formatNumber(plays)} ({dailyPlays} in the last day)
|
Plays: {formatNumber(plays)} ({dailyPlays} in the last day)
|
||||||
</p>
|
</p>
|
||||||
@ -127,7 +129,7 @@ export default function Leaderboard({ id, page }: LeaderboardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="mt-2 h-fit">
|
<Card className="mt-2 h-fit">
|
||||||
<div className="mb-2 mt-2 flex justify-center gap-2">
|
<div className="mb-2 flex justify-center gap-2">
|
||||||
{difficulties.map((diff) => {
|
{difficulties.map((diff) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -145,7 +147,7 @@ export default function Leaderboard({ id, page }: LeaderboardProps) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 divide-y divide-gray-500">
|
<div className="grid grid-cols-1 divide-y divide-border">
|
||||||
{leaderboardScores?.map((score, index) => {
|
{leaderboardScores?.map((score, index) => {
|
||||||
return (
|
return (
|
||||||
<div key={index}>
|
<div key={index}>
|
||||||
|
@ -21,14 +21,14 @@ export default function LeaderboardScore({
|
|||||||
const accuracy = ((score.baseScore / leaderboard.maxScore) * 100).toFixed(2);
|
const accuracy = ((score.baseScore / leaderboard.maxScore) * 100).toFixed(2);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-1 mt-1 grid grid-cols-[0.6fr_3fr_1.3fr] first:pt-0 last:pb-0 md:grid-cols-[1.2fr_6fr_1.3fr]">
|
<div className="mb-1 mt-1 grid grid-cols-[1.28fr_3fr_1.3fr] first:pt-0 last:pb-0 md:grid-cols-[1.28fr_6fr_1.3fr]">
|
||||||
<div className="flex flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center">
|
||||||
<div className="flex w-fit flex-row items-center justify-center gap-1">
|
<div className="flex w-fit flex-row items-center justify-center gap-1">
|
||||||
<p>#{formatNumber(score.rank)}</p>
|
<p>#{formatNumber(score.rank)}</p>
|
||||||
</div>
|
</div>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<p className="hidden text-sm text-gray-200 md:block">
|
<p className="block text-sm text-gray-200">
|
||||||
{formatTimeAgo(score.timeSet)}
|
{formatTimeAgo(score.timeSet)}
|
||||||
</p>
|
</p>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@ -57,7 +57,7 @@ export default function LeaderboardScore({
|
|||||||
href={`/leaderboard/${leaderboard.id}/1`}
|
href={`/leaderboard/${leaderboard.id}/1`}
|
||||||
className="transform-gpu transition-all hover:opacity-70"
|
className="transform-gpu transition-all hover:opacity-70"
|
||||||
>
|
>
|
||||||
<div className="w-fit truncate text-blue-500">
|
<div className="w-fit truncate">
|
||||||
<p className="font-bold">{player.name}</p>
|
<p className="font-bold">{player.name}</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@ -67,7 +67,7 @@ export default function LeaderboardScore({
|
|||||||
{/* PP */}
|
{/* PP */}
|
||||||
{score.pp > 0 && (
|
{score.pp > 0 && (
|
||||||
<ScoreStatLabel
|
<ScoreStatLabel
|
||||||
className="bg-blue-500 text-center"
|
className="bg-pp-blue text-center"
|
||||||
value={formatNumber(score.pp.toFixed(2)) + "pp"}
|
value={formatNumber(score.pp.toFixed(2)) + "pp"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
66
src/components/navbar/FriendsButton.tsx
Normal file
66
src/components/navbar/FriendsButton.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSettingsStore } from "@/store/settingsStore";
|
||||||
|
import useStore from "@/utils/useStore";
|
||||||
|
import { UserIcon } from "@heroicons/react/20/solid";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Avatar from "../Avatar";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||||
|
import NavbarButton from "./NavbarButton";
|
||||||
|
|
||||||
|
export default function FriendsButton() {
|
||||||
|
const settingsStore = useStore(useSettingsStore, (state) => state);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<NavbarButton
|
||||||
|
ariaLabel="View your friends"
|
||||||
|
text="Friends"
|
||||||
|
icon={<UserIcon height={23} width={23} />}
|
||||||
|
/>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-2">
|
||||||
|
{settingsStore?.friends.length == 0 ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-md font-bold">No friends</p>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Add new friends by clicking below
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link href={"/search"}>
|
||||||
|
<Button className="w-full" size={"sm"}>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
settingsStore?.friends.map((friend) => {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={friend.id}
|
||||||
|
href={`/player/${friend.id}/top/1`}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<div className="flex transform-gpu gap-2 rounded-md p-2 text-left transition-all hover:bg-background">
|
||||||
|
<Avatar
|
||||||
|
url={friend.profilePicture}
|
||||||
|
label="Friend avatar"
|
||||||
|
size={48}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-400">#{friend.rank}</p>
|
||||||
|
<p>{friend.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
46
src/components/navbar/Navbar.tsx
Normal file
46
src/components/navbar/Navbar.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
|
||||||
|
import { GlobeAltIcon, TvIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { Card } from "../ui/card";
|
||||||
|
import FriendsButton from "./FriendsButton";
|
||||||
|
import NavbarButton from "./NavbarButton";
|
||||||
|
import YouButton from "./YouButton";
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="flex h-fit w-full rounded-md">
|
||||||
|
<YouButton />
|
||||||
|
|
||||||
|
<FriendsButton />
|
||||||
|
|
||||||
|
<NavbarButton
|
||||||
|
ariaLabel="View the global ranking"
|
||||||
|
text="Ranking"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="m-auto" />
|
||||||
|
|
||||||
|
<NavbarButton
|
||||||
|
ariaLabel="Search for a player"
|
||||||
|
text="Search"
|
||||||
|
icon={<MagnifyingGlassIcon height={23} width={23} />}
|
||||||
|
href="/search"
|
||||||
|
/>
|
||||||
|
{/* <NavbarButton
|
||||||
|
ariaLabel="View your settings"
|
||||||
|
text="Settings"
|
||||||
|
icon={<CogIcon height={23} width={23} />}
|
||||||
|
href="/settings"
|
||||||
|
/> */}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
26
src/components/navbar/NavbarButton.tsx
Normal file
26
src/components/navbar/NavbarButton.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
interface ButtonProps {
|
||||||
|
text: string;
|
||||||
|
icon?: JSX.Element;
|
||||||
|
href?: string;
|
||||||
|
ariaLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NavbarButton({
|
||||||
|
text,
|
||||||
|
icon,
|
||||||
|
href,
|
||||||
|
ariaLabel,
|
||||||
|
}: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="flex h-full w-fit transform-gpu items-center justify-center gap-1 rounded-md p-[8px] transition-all hover:cursor-pointer hover:bg-blue-500"
|
||||||
|
href={href}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
{icon}
|
||||||
|
<p className="hidden md:block">{text}</p>
|
||||||
|
</>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
29
src/components/navbar/YouButton.tsx
Normal file
29
src/components/navbar/YouButton.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSettingsStore } from "@/store/settingsStore";
|
||||||
|
import { useStore } from "zustand";
|
||||||
|
import Avatar from "../Avatar";
|
||||||
|
import NavbarButton from "./NavbarButton";
|
||||||
|
|
||||||
|
export default function YouButton() {
|
||||||
|
const settingsStore = useStore(useSettingsStore, (state) => state);
|
||||||
|
|
||||||
|
if (!settingsStore || !settingsStore.player) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavbarButton
|
||||||
|
ariaLabel="Your profile"
|
||||||
|
text="You"
|
||||||
|
icon={
|
||||||
|
<Avatar
|
||||||
|
url={settingsStore.player.profilePicture}
|
||||||
|
label="Your avatar"
|
||||||
|
size={23}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
href={`/player/${settingsStore.player.id}/top/1`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
50
src/components/overlay/PlayerStats.tsx
Normal file
50
src/components/overlay/PlayerStats.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { OverlayPlayer } from "@/lib/overlay/type/overlayPlayer";
|
||||||
|
import { formatNumber } from "@/utils/numberUtils";
|
||||||
|
import { GlobeAltIcon } from "@heroicons/react/20/solid";
|
||||||
|
import Image from "next/image";
|
||||||
|
import CountyFlag from "../CountryFlag";
|
||||||
|
|
||||||
|
type PlayerStatsProps = {
|
||||||
|
player: OverlayPlayer;
|
||||||
|
config: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
const leaderboardImages: Record<string, string> = {
|
||||||
|
scoresaber: "/assets/logos/scoresaber.png",
|
||||||
|
beatleader: "/assets/logos/beatleader.png",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PlayerStats({ player, config }: PlayerStatsProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 p-2">
|
||||||
|
<Image
|
||||||
|
alt="Player profile picture"
|
||||||
|
className="rounded-lg"
|
||||||
|
src={player.profilePicture}
|
||||||
|
width={180}
|
||||||
|
height={180}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Image
|
||||||
|
alt="Leaderboard logo"
|
||||||
|
src={leaderboardImages[config.platform]}
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<p className="text-3xl">{formatNumber(player.pp, 2)}pp</p>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
28
src/components/overlay/ScoreStats.tsx
Normal file
28
src/components/overlay/ScoreStats.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { useOverlayDataStore } from "@/store/overlayDataStore";
|
||||||
|
import { formatNumber } from "@/utils/numberUtils";
|
||||||
|
import { accuracyToColor } from "@/utils/songUtils";
|
||||||
|
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 pl-2">
|
||||||
|
<p className="text-2xl font-bold">{formatNumber(scoreStats.score)}</p>
|
||||||
|
<p className="text-2xl">{formatNumber(scoreStats.combo)}x</p>
|
||||||
|
<p className="text-2xl">
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: accuracyToColor(scoreStats.accuracy),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scoreStats.accuracy == 100 ? "SS" : scoreStats.rank}
|
||||||
|
</span>{" "}
|
||||||
|
{scoreStats.accuracy.toFixed(2)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
46
src/components/overlay/SongInfo.tsx
Normal file
46
src/components/overlay/SongInfo.tsx
Normal 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", // make the song info grayscale when paused
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
className="rounded-lg"
|
||||||
|
alt="Song Image"
|
||||||
|
src={songInfo.art}
|
||||||
|
width={140}
|
||||||
|
height={140}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col justify-between pb-2 pt-1">
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{songInfo.songName}</p>
|
||||||
|
<p className="text-2xl text-gray-300">{songInfo.songMapper}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<p
|
||||||
|
className="rounded-md p-[3px] text-xl"
|
||||||
|
style={{
|
||||||
|
backgroundColor: songDifficultyToColor(songInfo.difficulty),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{songInfo.difficulty}
|
||||||
|
</p>
|
||||||
|
<p className="text-xl">!bsr {songInfo.bsr}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,5 +1,9 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
|
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
|
||||||
import { formatNumber } from "@/utils/numberUtils";
|
import { formatNumber } from "@/utils/numberUtils";
|
||||||
|
|
||||||
|
// chartjs
|
||||||
import {
|
import {
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
@ -41,12 +45,20 @@ export const options: any = {
|
|||||||
maxTicksLimit: 8,
|
maxTicksLimit: 8,
|
||||||
stepSize: 1,
|
stepSize: 1,
|
||||||
},
|
},
|
||||||
|
grid: {
|
||||||
|
// gray grid lines
|
||||||
|
color: "#252525",
|
||||||
|
},
|
||||||
reverse: true,
|
reverse: true,
|
||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
ticks: {
|
ticks: {
|
||||||
autoSkip: true,
|
autoSkip: true,
|
||||||
},
|
},
|
||||||
|
grid: {
|
||||||
|
// gray grid lines
|
||||||
|
color: "#252525",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
elements: {
|
elements: {
|
||||||
@ -69,7 +81,7 @@ export const options: any = {
|
|||||||
label(context: any) {
|
label(context: any) {
|
||||||
switch (context.dataset.label) {
|
switch (context.dataset.label) {
|
||||||
case "Rank": {
|
case "Rank": {
|
||||||
return `Rank #${formatNumber(context.parsed.y.toFixed(0))}`;
|
return `Rank #${formatNumber(Number(context.parsed.y))}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -1,117 +1,26 @@
|
|||||||
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
|
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
|
||||||
import { useScoresaberScoresStore } from "@/store/scoresaberScoresStore";
|
|
||||||
import { useSettingsStore } from "@/store/settingsStore";
|
|
||||||
import { formatNumber } from "@/utils/numberUtils";
|
import { formatNumber } from "@/utils/numberUtils";
|
||||||
import { getAveragePp, getHighestPpPlay } from "@/utils/scoresaber/scores";
|
|
||||||
import { normalizedRegionName } from "@/utils/utils";
|
import { normalizedRegionName } from "@/utils/utils";
|
||||||
import {
|
import { GlobeAsiaAustraliaIcon } from "@heroicons/react/20/solid";
|
||||||
GlobeAsiaAustraliaIcon,
|
|
||||||
HomeIcon,
|
|
||||||
UserIcon,
|
|
||||||
XMarkIcon,
|
|
||||||
} from "@heroicons/react/20/solid";
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useRef } from "react";
|
import { Fragment, Suspense } from "react";
|
||||||
import { toast } from "react-toastify";
|
|
||||||
import { useStore } from "zustand";
|
|
||||||
import Avatar from "../Avatar";
|
import Avatar from "../Avatar";
|
||||||
import Button from "../Button";
|
|
||||||
import Card from "../Card";
|
import Card from "../Card";
|
||||||
import CountyFlag from "../CountryFlag";
|
import CountyFlag from "../CountryFlag";
|
||||||
import Label from "../Label";
|
import Label from "../Label";
|
||||||
|
|
||||||
const PPGainLabel = dynamic(() => import("./PPGainLabel"));
|
const PlayerInfoExtraLabels = dynamic(() => import("./PlayerInfoExtraLabels"));
|
||||||
|
const PlayerSettingsButtons = dynamic(() => import("./PlayerSettingsButtons"));
|
||||||
|
|
||||||
type PlayerInfoProps = {
|
type PlayerInfoProps = {
|
||||||
playerData: ScoresaberPlayer;
|
playerData: ScoresaberPlayer;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PlayerInfo({ playerData }: PlayerInfoProps) {
|
export default function PlayerInfo({ playerData }: PlayerInfoProps) {
|
||||||
const playerId = playerData.id;
|
|
||||||
const settingsStore = useStore(useSettingsStore, (store) => store);
|
|
||||||
const playerScoreStore = useStore(useScoresaberScoresStore, (store) => store);
|
|
||||||
|
|
||||||
// Whether we have scores for this player in the local database
|
|
||||||
const hasLocalScores = playerScoreStore?.exists(playerId);
|
|
||||||
|
|
||||||
const toastId: any = useRef(null);
|
|
||||||
|
|
||||||
async function claimProfile() {
|
|
||||||
settingsStore?.setProfile(playerData);
|
|
||||||
addProfile(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addFriend() {
|
|
||||||
const friend = await settingsStore?.addFriend(playerData.id);
|
|
||||||
if (!friend) {
|
|
||||||
toast.error(`Failed to add ${playerData.name} as a friend`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
addProfile(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeFriend() {
|
|
||||||
settingsStore?.removeFriend(playerData.id);
|
|
||||||
|
|
||||||
toast.success(`Successfully removed ${playerData.name} as a friend`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addProfile(isFriend: boolean) {
|
|
||||||
if (!useScoresaberScoresStore.getState().exists(playerId)) {
|
|
||||||
if (!isFriend) {
|
|
||||||
toast.success(`Successfully set ${playerData.name} as your profile`);
|
|
||||||
} else {
|
|
||||||
toast.success(`Successfully added ${playerData.name} as a friend`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const reponse = await playerScoreStore?.addOrUpdatePlayer(
|
|
||||||
playerId,
|
|
||||||
(page, totalPages) => {
|
|
||||||
const autoClose = page == totalPages ? 5000 : false;
|
|
||||||
|
|
||||||
if (page == 1) {
|
|
||||||
toastId.current = toast.info(
|
|
||||||
`Fetching scores for ${playerData.name} page ${page}/${totalPages}`,
|
|
||||||
{
|
|
||||||
autoClose: autoClose,
|
|
||||||
progress: page / totalPages,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
if (page != totalPages) {
|
|
||||||
toast.update(toastId.current, {
|
|
||||||
progress: page / totalPages,
|
|
||||||
render: `Fetching scores for ${playerData.name} page ${page}/${totalPages}`,
|
|
||||||
autoClose: autoClose,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast.update(toastId.current, {
|
|
||||||
progress: 0,
|
|
||||||
render: `Successfully fetched scores for ${playerData.name}`,
|
|
||||||
autoClose: autoClose,
|
|
||||||
type: "success",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Fetching scores for ${playerId} (${page}/${totalPages})`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (reponse?.error) {
|
|
||||||
toast.error("Failed to fetch scores");
|
|
||||||
console.log(reponse.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isOwnProfile = settingsStore.player?.id == playerId;
|
|
||||||
const scoreStats = playerData.scoreStats;
|
const scoreStats = playerData.scoreStats;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="mt-2">
|
<Card outerClassName="mt-2" className="mt-2">
|
||||||
{/* Player Info */}
|
{/* Player Info */}
|
||||||
<div className="flex flex-col items-center gap-3 md:flex-row md:items-start">
|
<div className="flex flex-col items-center gap-3 md:flex-row md:items-start">
|
||||||
<div className="min-w-fit">
|
<div className="min-w-fit">
|
||||||
@ -122,33 +31,11 @@ export default function PlayerInfo({ playerData }: PlayerInfoProps) {
|
|||||||
|
|
||||||
{/* Settings Buttons */}
|
{/* Settings Buttons */}
|
||||||
<div className="absolute right-3 top-20 flex flex-col justify-end gap-2 md:relative md:right-0 md:top-0 md:mt-2 md:flex-row md:justify-center">
|
<div className="absolute right-3 top-20 flex flex-col justify-end gap-2 md:relative md:right-0 md:top-0 md:mt-2 md:flex-row md:justify-center">
|
||||||
{!isOwnProfile && (
|
<Fragment>
|
||||||
<Button
|
<Suspense>
|
||||||
onClick={claimProfile}
|
<PlayerSettingsButtons playerData={playerData} />
|
||||||
tooltip={<p>Set as your Profile</p>}
|
</Suspense>
|
||||||
icon={<HomeIcon width={24} height={24} />}
|
</Fragment>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isOwnProfile && (
|
|
||||||
<>
|
|
||||||
{!settingsStore?.isFriend(playerId) && (
|
|
||||||
<Button
|
|
||||||
onClick={addFriend}
|
|
||||||
tooltip={<p>Add as Friend</p>}
|
|
||||||
icon={<UserIcon width={24} height={24} />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{settingsStore.isFriend(playerId) && (
|
|
||||||
<Button
|
|
||||||
onClick={removeFriend}
|
|
||||||
tooltip={<p>Remove Friend</p>}
|
|
||||||
icon={<XMarkIcon width={24} height={24} />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -163,7 +50,10 @@ export default function PlayerInfo({ playerData }: PlayerInfoProps) {
|
|||||||
|
|
||||||
<a
|
<a
|
||||||
className="flex transform-gpu items-center gap-1 transition-all hover:text-blue-500"
|
className="flex transform-gpu items-center gap-1 transition-all hover:text-blue-500"
|
||||||
href={`/ranking/global/${Math.round(playerData.rank / 50)}`}
|
href={`/ranking/global/${Math.max(
|
||||||
|
Math.round(playerData.rank / 50),
|
||||||
|
1,
|
||||||
|
)}`}
|
||||||
>
|
>
|
||||||
<p>#{formatNumber(playerData.rank)}</p>
|
<p>#{formatNumber(playerData.rank)}</p>
|
||||||
</a>
|
</a>
|
||||||
@ -173,8 +63,9 @@ export default function PlayerInfo({ playerData }: PlayerInfoProps) {
|
|||||||
<div className="text-gray-300">
|
<div className="text-gray-300">
|
||||||
<a
|
<a
|
||||||
className="flex transform-gpu items-center gap-1 transition-all hover:text-blue-500"
|
className="flex transform-gpu items-center gap-1 transition-all hover:text-blue-500"
|
||||||
href={`/ranking/country/${playerData.country}/${Math.round(
|
href={`/ranking/country/${playerData.country}/${Math.max(
|
||||||
playerData.countryRank / 50,
|
Math.round(playerData.countryRank / 50),
|
||||||
|
1,
|
||||||
)}`}
|
)}`}
|
||||||
>
|
>
|
||||||
<CountyFlag
|
<CountyFlag
|
||||||
@ -241,30 +132,11 @@ export default function PlayerInfo({ playerData }: PlayerInfoProps) {
|
|||||||
value={formatNumber(scoreStats.replaysWatched)}
|
value={formatNumber(scoreStats.replaysWatched)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{hasLocalScores && (
|
<Fragment>
|
||||||
<>
|
<Suspense>
|
||||||
<Label
|
<PlayerInfoExtraLabels playerId={playerData.id} />
|
||||||
title="Top PP"
|
</Suspense>
|
||||||
className="bg-pp-blue"
|
</Fragment>
|
||||||
tooltip={<p>Their highest pp play</p>}
|
|
||||||
value={`${formatNumber(
|
|
||||||
getHighestPpPlay(playerId)?.toFixed(2),
|
|
||||||
)}pp`}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
title="Avg PP"
|
|
||||||
className="bg-pp-blue"
|
|
||||||
tooltip={
|
|
||||||
<p>Average amount of pp per play (best 50 scores)</p>
|
|
||||||
}
|
|
||||||
value={`${formatNumber(
|
|
||||||
getAveragePp(playerId)?.toFixed(2),
|
|
||||||
)}pp`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PPGainLabel playerId={playerId} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
42
src/components/player/PlayerInfoExtraLabels.tsx
Normal file
42
src/components/player/PlayerInfoExtraLabels.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useScoresaberScoresStore } from "@/store/scoresaberScoresStore";
|
||||||
|
import { formatNumber } from "@/utils/numberUtils";
|
||||||
|
import { getAveragePp, getHighestPpPlay } from "@/utils/scoresaber/scores";
|
||||||
|
import { useStore } from "zustand";
|
||||||
|
import Label from "../Label";
|
||||||
|
import PPGainLabel from "./PPGainLabel";
|
||||||
|
|
||||||
|
type PlayerInfoExtraLabelsProps = {
|
||||||
|
playerId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PlayerInfoExtraLabels({
|
||||||
|
playerId,
|
||||||
|
}: PlayerInfoExtraLabelsProps) {
|
||||||
|
const playerScoreStore = useStore(useScoresaberScoresStore, (store) => store);
|
||||||
|
const hasLocalScores = playerScoreStore.exists(playerId);
|
||||||
|
|
||||||
|
if (!hasLocalScores) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Label
|
||||||
|
title="Top PP"
|
||||||
|
className="bg-pp-blue"
|
||||||
|
tooltip={<p>Their highest pp play</p>}
|
||||||
|
value={`${formatNumber(getHighestPpPlay(playerId)?.toFixed(2))}pp`}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
title="Avg PP"
|
||||||
|
className="bg-pp-blue"
|
||||||
|
tooltip={<p>Average amount of pp per play (best 50 scores)</p>}
|
||||||
|
value={`${formatNumber(getAveragePp(playerId)?.toFixed(2))}pp`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PPGainLabel playerId={playerId} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -1,131 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Card from "@/components/Card";
|
|
||||||
import Container from "@/components/Container";
|
|
||||||
import Spinner from "@/components/Spinner";
|
|
||||||
import Scores from "@/components/player/Scores";
|
|
||||||
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
|
|
||||||
import { SortTypes } from "@/types/SortTypes";
|
|
||||||
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/Tooltip";
|
|
||||||
import PlayerChart from "./PlayerChart";
|
|
||||||
import PlayerInfo from "./PlayerInfo";
|
|
||||||
|
|
||||||
const Error = dynamic(() => import("@/components/Error"));
|
|
||||||
|
|
||||||
type PlayerInfo = {
|
|
||||||
loading: boolean;
|
|
||||||
player: ScoresaberPlayer | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PlayerPageProps = {
|
|
||||||
id: string;
|
|
||||||
sort: string;
|
|
||||||
page: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_SORT_TYPE = SortTypes.top;
|
|
||||||
|
|
||||||
export default function PlayerPage({ id, sort, page }: PlayerPageProps) {
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
const [error, setError] = useState(false);
|
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
|
||||||
|
|
||||||
const [player, setPlayer] = useState<PlayerInfo>({
|
|
||||||
loading: true,
|
|
||||||
player: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortType = SortTypes[sort] || DEFAULT_SORT_TYPE;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
if (error || !player.loading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted == true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ScoreSaberAPI.fetchPlayerData(id).then((playerResponse) => {
|
|
||||||
if (!playerResponse) {
|
|
||||||
setError(true);
|
|
||||||
setErrorMessage("Failed to fetch player. Is the ID correct?");
|
|
||||||
setPlayer({ ...player, loading: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPlayer({ ...player, player: playerResponse, loading: false });
|
|
||||||
});
|
|
||||||
}, [error, mounted, id, player]);
|
|
||||||
|
|
||||||
if (player.loading || error || !player.player) {
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<Container>
|
|
||||||
<Card className="mt-2">
|
|
||||||
<div className="p-3 text-center">
|
|
||||||
<div role="status">
|
|
||||||
<div className="flex flex-col items-center justify-center gap-2">
|
|
||||||
{error && <Error errorMessage={errorMessage} />}
|
|
||||||
{!error && <Spinner />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Container>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const playerData = player.player;
|
|
||||||
const badges = playerData.badges;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<Container>
|
|
||||||
<PlayerInfo playerData={playerData} />
|
|
||||||
{/* Chart */}
|
|
||||||
<Card className="mt-2">
|
|
||||||
{/* Badges */}
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"mb-2 mt-2 flex flex-wrap items-center justify-center gap-2",
|
|
||||||
badges.length > 0 ? "block" : "hidden",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{badges.map((badge) => {
|
|
||||||
return (
|
|
||||||
<Tooltip key={badge.image}>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<Image
|
|
||||||
src={badge.image}
|
|
||||||
alt={badge.description}
|
|
||||||
width={80}
|
|
||||||
height={30}
|
|
||||||
/>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{badge.description}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="h-[320px] w-full">
|
|
||||||
<PlayerChart scoresaber={playerData} />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<Scores
|
|
||||||
playerData={playerData}
|
|
||||||
page={Number(page)}
|
|
||||||
sortType={sortType}
|
|
||||||
/>
|
|
||||||
</Container>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
@ -8,25 +8,26 @@ import CountyFlag from "../CountryFlag";
|
|||||||
|
|
||||||
type PlayerRankingProps = {
|
type PlayerRankingProps = {
|
||||||
player: ScoresaberPlayer;
|
player: ScoresaberPlayer;
|
||||||
showCountryFlag?: boolean;
|
isCountry?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Avatar = dynamic(() => import("@/components/Avatar"));
|
const Avatar = dynamic(() => import("@/components/Avatar"));
|
||||||
|
|
||||||
export default function PlayerRanking({
|
export default function PlayerRanking({
|
||||||
player,
|
player,
|
||||||
showCountryFlag,
|
isCountry,
|
||||||
}: PlayerRankingProps) {
|
}: PlayerRankingProps) {
|
||||||
const settingsStore = useStore(useSettingsStore, (store) => store);
|
const settingsStore = useStore(useSettingsStore, (store) => store);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<td className="px-4 py-2">#{formatNumber(player.rank)}</td>
|
<td className="px-4 py-2">
|
||||||
|
#{formatNumber(isCountry ? player.countryRank : player.rank)}{" "}
|
||||||
|
<span className="text-sm">{isCountry && "(#" + player.rank + ")"}</span>
|
||||||
|
</td>
|
||||||
<td className="flex items-center gap-2 px-4 py-2">
|
<td className="flex items-center gap-2 px-4 py-2">
|
||||||
<Avatar url={player.profilePicture} label="Avatar" size={24} />
|
<Avatar url={player.profilePicture} label="Avatar" size={24} />
|
||||||
{showCountryFlag && (
|
<CountyFlag countryCode={player.country} className="!h-5 !w-5" />
|
||||||
<CountyFlag countryCode={player.country} className="!h-5 !w-5" />
|
|
||||||
)}
|
|
||||||
<Link
|
<Link
|
||||||
className="transform-gpu transition-all hover:text-blue-500"
|
className="transform-gpu transition-all hover:text-blue-500"
|
||||||
href={`/player/${player.id}/top/1`}
|
href={`/player/${player.id}/top/1`}
|
||||||
|
@ -1,54 +0,0 @@
|
|||||||
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
|
|
||||||
import { useSettingsStore } from "@/store/settingsStore";
|
|
||||||
import { formatNumber } from "@/utils/numberUtils";
|
|
||||||
import { useStore } from "zustand";
|
|
||||||
import Avatar from "../Avatar";
|
|
||||||
import CountyFlag from "../CountryFlag";
|
|
||||||
import Label from "../Label";
|
|
||||||
|
|
||||||
type PlayerRankingProps = {
|
|
||||||
player: ScoresaberPlayer;
|
|
||||||
showCountryFlag?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PlayerRankingMobile({
|
|
||||||
player,
|
|
||||||
showCountryFlag = true,
|
|
||||||
}: PlayerRankingProps) {
|
|
||||||
const settingsStore = useStore(useSettingsStore, (store) => store);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="m-3 flex flex-col gap-2">
|
|
||||||
<p className="flex items-center gap-2">
|
|
||||||
<p>#{formatNumber(player.rank)}</p>
|
|
||||||
<Avatar url={player.profilePicture} label="Avatar" size={24} />
|
|
||||||
{showCountryFlag && (
|
|
||||||
<CountyFlag countryCode={player.country} className="!h-5 !w-5" />
|
|
||||||
)}
|
|
||||||
<p
|
|
||||||
className={
|
|
||||||
player.id == settingsStore.player?.id
|
|
||||||
? "transform-gpu text-red-500 transition-all hover:text-blue-500"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{player.name}
|
|
||||||
</p>
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap justify-center gap-2">
|
|
||||||
<Label
|
|
||||||
title="PP"
|
|
||||||
tooltip={<p>The total amount of pp this player has</p>}
|
|
||||||
value={`${formatNumber(player.pp)}`}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
title="Total play count"
|
|
||||||
tooltip={<p>The total amount of plays this player has</p>}
|
|
||||||
value={formatNumber(player.scoreStats.totalPlayCount)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
137
src/components/player/PlayerSettingsButtons.tsx
Normal file
137
src/components/player/PlayerSettingsButtons.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
|
||||||
|
import { useScoresaberScoresStore } from "@/store/scoresaberScoresStore";
|
||||||
|
import { useSettingsStore } from "@/store/settingsStore";
|
||||||
|
import { HomeIcon, UserIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { useStore } from "zustand";
|
||||||
|
import Button from "../Button";
|
||||||
|
|
||||||
|
type PlayerSettingsButtonsProps = {
|
||||||
|
playerData: ScoresaberPlayer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PlayerSettingsButtons({
|
||||||
|
playerData,
|
||||||
|
}: PlayerSettingsButtonsProps) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const playerId = playerData.id;
|
||||||
|
|
||||||
|
const settingsStore = useStore(useSettingsStore, (store) => store);
|
||||||
|
const playerScoreStore = useStore(useScoresaberScoresStore, (store) => store);
|
||||||
|
|
||||||
|
const isOwnProfile = settingsStore.player?.id == playerId;
|
||||||
|
const toastId: any = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted || isOwnProfile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claimProfile() {
|
||||||
|
settingsStore?.setProfile(playerData);
|
||||||
|
addProfile(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addFriend() {
|
||||||
|
const friend = await settingsStore?.addFriend(playerData.id);
|
||||||
|
if (!friend) {
|
||||||
|
toast.error(`Failed to add ${playerData.name} as a friend`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addProfile(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFriend() {
|
||||||
|
settingsStore?.removeFriend(playerData.id);
|
||||||
|
|
||||||
|
toast.success(`Successfully removed ${playerData.name} as a friend`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addProfile(isFriend: boolean) {
|
||||||
|
if (!useScoresaberScoresStore.getState().exists(playerId)) {
|
||||||
|
if (!isFriend) {
|
||||||
|
toast.success(`Successfully set ${playerData.name} as your profile`);
|
||||||
|
} else {
|
||||||
|
toast.success(`Successfully added ${playerData.name} as a friend`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reponse = await playerScoreStore?.addOrUpdatePlayer(
|
||||||
|
playerId,
|
||||||
|
(page, totalPages) => {
|
||||||
|
const autoClose = page == totalPages ? 5000 : false;
|
||||||
|
|
||||||
|
if (page == 1) {
|
||||||
|
toastId.current = toast.info(
|
||||||
|
`Fetching scores for ${playerData.name} page ${page}/${totalPages}`,
|
||||||
|
{
|
||||||
|
autoClose: autoClose,
|
||||||
|
progress: page / totalPages,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (page != totalPages) {
|
||||||
|
toast.update(toastId.current, {
|
||||||
|
progress: page / totalPages,
|
||||||
|
render: `Fetching scores for ${playerData.name} page ${page}/${totalPages}`,
|
||||||
|
autoClose: autoClose,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.update(toastId.current, {
|
||||||
|
progress: 0,
|
||||||
|
render: `Successfully fetched scores for ${playerData.name}`,
|
||||||
|
autoClose: autoClose,
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Fetching scores for ${playerId} (${page}/${totalPages})`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (reponse?.error) {
|
||||||
|
toast.error("Failed to fetch scores");
|
||||||
|
console.log(reponse.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={claimProfile}
|
||||||
|
tooltip={<p>Set as your Profile</p>}
|
||||||
|
icon={<HomeIcon width={24} height={24} />}
|
||||||
|
ariaLabel="Set as your Profile"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!settingsStore?.isFriend(playerId) && (
|
||||||
|
<Button
|
||||||
|
onClick={addFriend}
|
||||||
|
tooltip={<p>Add as Friend</p>}
|
||||||
|
icon={<UserIcon width={24} height={24} />}
|
||||||
|
color="bg-green-500"
|
||||||
|
ariaLabel="Add Friend"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settingsStore.isFriend(playerId) && (
|
||||||
|
<Button
|
||||||
|
onClick={removeFriend}
|
||||||
|
tooltip={<p>Remove Friend</p>}
|
||||||
|
icon={<XMarkIcon width={24} height={24} />}
|
||||||
|
color="bg-red-500"
|
||||||
|
ariaLabel="Remove Friend"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -1,33 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
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 { 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";
|
||||||
import useStore from "@/utils/useStore";
|
import useStore from "@/utils/useStore";
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
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";
|
||||||
|
|
||||||
const Spinner = dynamic(() => import("@/components/Spinner"));
|
|
||||||
|
|
||||||
type PageInfo = {
|
type PageInfo = {
|
||||||
loading: boolean;
|
|
||||||
page: number;
|
page: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
sortType: SortType;
|
sortType: SortType;
|
||||||
scores: ScoresaberPlayerScore[];
|
scores: ScoresaberPlayerScore[] | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ScoresProps = {
|
type ScoresProps = {
|
||||||
|
initalScores: ScoresaberPlayerScore[] | undefined;
|
||||||
|
initalPage: number;
|
||||||
|
initalSortType: SortType;
|
||||||
|
initalTotalPages?: number;
|
||||||
playerData: ScoresaberPlayer;
|
playerData: ScoresaberPlayer;
|
||||||
page: number;
|
|
||||||
sortType: SortType;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Scores({ playerData, page, sortType }: ScoresProps) {
|
export default function Scores({
|
||||||
|
initalScores,
|
||||||
|
initalPage,
|
||||||
|
initalSortType,
|
||||||
|
initalTotalPages,
|
||||||
|
playerData,
|
||||||
|
}: ScoresProps) {
|
||||||
const settingsStore = useStore(useSettingsStore, (store) => store);
|
const settingsStore = useStore(useSettingsStore, (store) => store);
|
||||||
const playerId = playerData.id;
|
const playerId = playerData.id;
|
||||||
|
|
||||||
@ -36,28 +42,37 @@ export default function Scores({ playerData, page, sortType }: ScoresProps) {
|
|||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
const [scores, setScores] = useState<PageInfo>({
|
const [scores, setScores] = useState<PageInfo>({
|
||||||
loading: true,
|
page: initalPage,
|
||||||
page: page,
|
totalPages: initalTotalPages || 1,
|
||||||
totalPages: 1,
|
sortType: initalSortType,
|
||||||
sortType: sortType,
|
scores: initalScores,
|
||||||
scores: [],
|
|
||||||
});
|
});
|
||||||
|
const [changedPage, setChangedPage] = useState(false);
|
||||||
|
|
||||||
const updateScoresPage = useCallback(
|
const updateScoresPage = useCallback(
|
||||||
(sortType: SortType, page: any) => {
|
(sortType: SortType, page: any) => {
|
||||||
|
if (
|
||||||
|
page == initalPage &&
|
||||||
|
sortType == initalSortType &&
|
||||||
|
initalScores &&
|
||||||
|
!changedPage
|
||||||
|
) {
|
||||||
|
console.log("Already loaded scores, not fetching");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ScoreSaberAPI.fetchScores(playerId, page, sortType.value, 10).then(
|
ScoreSaberAPI.fetchScores(playerId, page, sortType.value, 10).then(
|
||||||
(scoresResponse) => {
|
(scoresResponse) => {
|
||||||
if (!scoresResponse) {
|
if (!scoresResponse) {
|
||||||
setError(true);
|
setError(true);
|
||||||
setErrorMessage("No Scores");
|
setErrorMessage("No Scores");
|
||||||
setScores({ ...scores, loading: false });
|
setScores({ ...scores });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setScores({
|
setScores({
|
||||||
...scores,
|
...scores,
|
||||||
scores: scoresResponse.scores,
|
scores: scoresResponse.scores,
|
||||||
totalPages: scoresResponse.pageInfo.totalPages,
|
totalPages: scoresResponse.pageInfo.totalPages,
|
||||||
loading: false,
|
|
||||||
page: page,
|
page: page,
|
||||||
sortType: sortType,
|
sortType: sortType,
|
||||||
});
|
});
|
||||||
@ -67,12 +82,21 @@ export default function Scores({ playerData, page, sortType }: ScoresProps) {
|
|||||||
"",
|
"",
|
||||||
`/player/${playerId}/${sortType.value}/${page}`,
|
`/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}`);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[playerId, scores, settingsStore],
|
[
|
||||||
|
changedPage,
|
||||||
|
initalPage,
|
||||||
|
initalScores,
|
||||||
|
initalSortType,
|
||||||
|
playerId,
|
||||||
|
scores,
|
||||||
|
settingsStore,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -97,9 +121,9 @@ export default function Scores({ playerData, page, sortType }: ScoresProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="mt-2 w-full items-center md:flex-col">
|
<Card outerClassName="mt-2" className="w-full items-center md:flex-col">
|
||||||
{/* Sort */}
|
{/* Sort */}
|
||||||
<div className="m-4 w-full text-sm">
|
<div className="mb-2 mt-1 w-full text-sm">
|
||||||
<div className="flex justify-center gap-2">
|
<div className="flex justify-center gap-2">
|
||||||
{Object.values(SortTypes).map((sortType) => {
|
{Object.values(SortTypes).map((sortType) => {
|
||||||
return (
|
return (
|
||||||
@ -122,45 +146,39 @@ export default function Scores({ playerData, page, sortType }: ScoresProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full p-1">
|
<div className="flex h-full w-full flex-col items-center justify-center">
|
||||||
{scores.loading ? (
|
<>
|
||||||
<div className="flex justify-center">
|
{scores.scores ? (
|
||||||
<Spinner />
|
<>
|
||||||
</div>
|
<div className="grid min-w-full grid-cols-1 divide-y divide-border">
|
||||||
) : (
|
{scores.scores.map((scoreData, id) => {
|
||||||
<div className="grid grid-cols-1 divide-y divide-gray-800">
|
const { score, leaderboard } = scoreData;
|
||||||
{!scores.loading && scores.scores.length == 0 ? (
|
|
||||||
<p className="text-red-400">{errorMessage}</p>
|
|
||||||
) : (
|
|
||||||
scores.scores.map((scoreData, id) => {
|
|
||||||
const { score, leaderboard } = scoreData;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Score
|
<Score
|
||||||
key={id}
|
key={id}
|
||||||
player={playerData}
|
player={playerData}
|
||||||
score={score}
|
score={score}
|
||||||
leaderboard={leaderboard}
|
leaderboard={leaderboard}
|
||||||
ownProfile={settingsStore?.player}
|
/>
|
||||||
/>
|
);
|
||||||
);
|
})}
|
||||||
})
|
</div>
|
||||||
)}
|
{/* Pagination */}
|
||||||
</div>
|
<div className="pt-3">
|
||||||
)}
|
<Pagination
|
||||||
</div>
|
currentPage={scores.page}
|
||||||
|
totalPages={scores.totalPages}
|
||||||
{/* Pagination */}
|
onPageChange={(page) => {
|
||||||
<div>
|
updateScoresPage(scores.sortType, page);
|
||||||
<div className="pt-3">
|
}}
|
||||||
<Pagination
|
/>
|
||||||
currentPage={scores.page}
|
</div>
|
||||||
totalPages={scores.totalPages}
|
</>
|
||||||
onPageChange={(page) => {
|
) : (
|
||||||
updateScoresPage(scores.sortType, page);
|
<p>No Scores!</p>
|
||||||
}}
|
)}
|
||||||
/>
|
</>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
34
src/components/player/score/CopyBsrButton.tsx
Normal file
34
src/components/player/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] bg-neutral-700 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>
|
||||||
|
);
|
||||||
|
}
|
98
src/components/player/score/MapButtons.tsx
Normal file
98
src/components/player/score/MapButtons.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import BeatSaverLogo from "@/components/icons/BeatSaverLogo";
|
||||||
|
import YouTubeLogo from "@/components/icons/YouTubeLogo";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/Tooltip";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScoresaberLeaderboardInfo } from "@/schemas/scoresaber/leaderboard";
|
||||||
|
import { songNameToYouTubeLink } from "@/utils/songUtils";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import CopyBsrButton from "./CopyBsrButton";
|
||||||
|
|
||||||
|
type MapButtonsProps = {
|
||||||
|
leaderboard: ScoresaberLeaderboardInfo;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MapButtons({ leaderboard }: MapButtonsProps) {
|
||||||
|
const [mapId, setMapId] = useState<string | undefined>(undefined);
|
||||||
|
const hash = leaderboard.songHash;
|
||||||
|
|
||||||
|
const getMapId = useCallback(async () => {
|
||||||
|
const beatSaberMap = await fetch(
|
||||||
|
`/api/beatsaver/mapdata?hashes=${hash}&idonly=true`,
|
||||||
|
);
|
||||||
|
if (!beatSaberMap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const json = await beatSaberMap.json();
|
||||||
|
if (json.maps[hash] == null || json.maps[hash] == undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(json);
|
||||||
|
setMapId(json.maps[hash].id);
|
||||||
|
}, [hash]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getMapId();
|
||||||
|
}, [getMapId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hidden flex-col items-center gap-2 p-1 md:flex md:items-start">
|
||||||
|
{mapId && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Link
|
||||||
|
href={`https://beatsaver.com/maps/${mapId}`}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
className="h-[30px] w-[30px] bg-neutral-700 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 gap-2">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Link
|
||||||
|
href={`${songNameToYouTubeLink(
|
||||||
|
leaderboard.songName,
|
||||||
|
leaderboard.songSubName,
|
||||||
|
leaderboard.songAuthorName,
|
||||||
|
)}`}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
className="h-[30px] w-[30px] bg-neutral-700 p-1"
|
||||||
|
variant={"secondary"}
|
||||||
|
>
|
||||||
|
<YouTubeLogo size={20} />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Click to view the song on YouTube</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -17,23 +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 HeadsetIcon from "../icons/HeadsetIcon";
|
import { Suspense } from "react";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/Tooltip";
|
import HeadsetIcon from "../../icons/HeadsetIcon";
|
||||||
import ScoreStatLabel from "./ScoreStatLabel";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/Tooltip";
|
||||||
|
import ScoreStatLabel from "../ScoreStatLabel";
|
||||||
|
import MapButtons from "./MapButtons";
|
||||||
|
|
||||||
type ScoreProps = {
|
type ScoreProps = {
|
||||||
score: ScoresaberScore;
|
score: ScoresaberScore;
|
||||||
player: ScoresaberPlayer;
|
player: ScoresaberPlayer;
|
||||||
leaderboard: ScoresaberLeaderboardInfo;
|
leaderboard: ScoresaberLeaderboardInfo;
|
||||||
ownProfile?: ScoresaberPlayer;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Score({
|
export default function Score({ score, player, leaderboard }: ScoreProps) {
|
||||||
score,
|
|
||||||
player,
|
|
||||||
leaderboard,
|
|
||||||
ownProfile,
|
|
||||||
}: ScoreProps) {
|
|
||||||
const isFullCombo = score.missedNotes + score.badCuts === 0;
|
const isFullCombo = score.missedNotes + score.badCuts === 0;
|
||||||
const diffName = scoresaberDifficultyNumberToName(
|
const diffName = scoresaberDifficultyNumberToName(
|
||||||
leaderboard.difficulty.difficulty,
|
leaderboard.difficulty.difficulty,
|
||||||
@ -44,7 +40,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} />
|
||||||
@ -74,7 +70,7 @@ export default function Score({
|
|||||||
className="h-fit min-w-[60px] rounded-md"
|
className="h-fit min-w-[60px] rounded-md"
|
||||||
width={60}
|
width={60}
|
||||||
height={60}
|
height={60}
|
||||||
loading="lazy"
|
priority
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="absolute mt-12 flex w-[55px] cursor-default items-center justify-center divide-x divide-y rounded-sm pl-[3px] pr-[3px] text-[0.8rem] opacity-90"
|
className="absolute mt-12 flex w-[55px] cursor-default items-center justify-center divide-x divide-y rounded-sm pl-[3px] pr-[3px] text-[0.8rem] opacity-90"
|
||||||
@ -91,10 +87,10 @@ export default function Score({
|
|||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<div>
|
<>
|
||||||
<p className="font-bold">Difficulty</p>
|
<p className="font-bold">Difficulty</p>
|
||||||
<p>{diffName}</p>
|
<p>{diffName}</p>
|
||||||
</div>
|
</>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
@ -117,6 +113,10 @@ export default function Score({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Suspense fallback={<div />}>
|
||||||
|
<MapButtons leaderboard={leaderboard} />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
<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 */}
|
||||||
@ -149,7 +149,7 @@ export default function Score({
|
|||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
{score.pp > 0 && (
|
{score.pp > 0 && (
|
||||||
<ScoreStatLabel
|
<ScoreStatLabel
|
||||||
className="bg-blue-500 text-center"
|
className="bg-pp-blue text-center"
|
||||||
value={formatNumber(score.pp.toFixed(2)) + "pp"}
|
value={formatNumber(score.pp.toFixed(2)) + "pp"}
|
||||||
tooltip={
|
tooltip={
|
||||||
<div>
|
<div>
|
@ -18,7 +18,7 @@ const TooltipContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md",
|
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
56
src/components/ui/button.tsx
Normal file
56
src/components/ui/button.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/utils/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
@ -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<
|
const Card = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@ -10,12 +10,12 @@ const Card = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
Card.displayName = "Card"
|
Card.displayName = "Card";
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<
|
const CardHeader = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@ -26,8 +26,8 @@ const CardHeader = React.forwardRef<
|
|||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CardHeader.displayName = "CardHeader"
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<
|
const CardTitle = React.forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLParagraphElement,
|
||||||
@ -37,12 +37,12 @@ const CardTitle = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-2xl font-semibold leading-none tracking-tight",
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CardTitle.displayName = "CardTitle"
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
const CardDescription = React.forwardRef<
|
const CardDescription = React.forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLParagraphElement,
|
||||||
@ -53,16 +53,16 @@ const CardDescription = React.forwardRef<
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CardDescription.displayName = "CardDescription"
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
const CardContent = React.forwardRef<
|
const CardContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
))
|
));
|
||||||
CardContent.displayName = "CardContent"
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
const CardFooter = React.forwardRef<
|
const CardFooter = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@ -73,7 +73,14 @@ const CardFooter = React.forwardRef<
|
|||||||
className={cn("flex items-center p-6 pt-0", className)}
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CardFooter.displayName = "CardFooter"
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
export {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
};
|
||||||
|
25
src/components/ui/input.tsx
Normal file
25
src/components/ui/input.tsx
Normal 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 }
|
26
src/components/ui/label.tsx
Normal file
26
src/components/ui/label.tsx
Normal 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 }
|
44
src/components/ui/radio-group.tsx
Normal file
44
src/components/ui/radio-group.tsx
Normal 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 }
|
29
src/components/ui/switch.tsx
Normal file
29
src/components/ui/switch.tsx
Normal 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 }
|
26
src/lib/db/redis.ts
Normal file
26
src/lib/db/redis.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { createClient } from "redis";
|
||||||
|
|
||||||
|
let redisClient = await connectRedis();
|
||||||
|
|
||||||
|
async function connectRedis(): Promise<any> {
|
||||||
|
// console.log("Connecting to redis");
|
||||||
|
const client = createClient({
|
||||||
|
url: process.env.REDIS_URL,
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
console.log("Connected to redis");
|
||||||
|
|
||||||
|
client.on("error", (error) => {
|
||||||
|
console.error("There was an error connecting to redis: " + error);
|
||||||
|
setTimeout(async () => {
|
||||||
|
redisClient = await connectRedis();
|
||||||
|
}, 30_000); // 30 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Redis = {
|
||||||
|
client: redisClient,
|
||||||
|
connectRedis,
|
||||||
|
};
|
133
src/lib/overlay/httpSiraStatus.ts
Normal file
133
src/lib/overlay/httpSiraStatus.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
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({
|
||||||
|
paused: false,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the state of the overlay
|
||||||
|
*/
|
||||||
|
function resetState() {
|
||||||
|
overlayDataStore.setState({
|
||||||
|
scoreStats: undefined,
|
||||||
|
songInfo: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
resetState();
|
||||||
|
},
|
||||||
|
menu: (data: any) => {
|
||||||
|
resetState();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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...",
|
||||||
|
);
|
||||||
|
resetState();
|
||||||
|
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,
|
||||||
|
};
|
8
src/lib/overlay/type/overlayPlayer.ts
Normal file
8
src/lib/overlay/type/overlayPlayer.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export type OverlayPlayer = {
|
||||||
|
id: string;
|
||||||
|
country: string;
|
||||||
|
profilePicture: string;
|
||||||
|
pp: number;
|
||||||
|
rank: number;
|
||||||
|
countryRank: number;
|
||||||
|
};
|
@ -15,15 +15,6 @@ export function middleware(request: NextRequest) {
|
|||||||
return NextResponse.redirect(new URL("/search", request.url));
|
return NextResponse.redirect(new URL("/search", request.url));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestHeaders = new Headers(request.headers);
|
|
||||||
requestHeaders.set("x-url", request.url);
|
|
||||||
return NextResponse.next({
|
|
||||||
request: {
|
|
||||||
// New request headers
|
|
||||||
headers: requestHeaders,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
30
src/schemas/beatleader/difficulty.ts
Normal file
30
src/schemas/beatleader/difficulty.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { BeatleaderModifierRating } from "./modifierRating";
|
||||||
|
import { BeatleaderModifier } from "./modifiers";
|
||||||
|
|
||||||
|
export type BeatleaderDifficulty = {
|
||||||
|
id: number;
|
||||||
|
value: number;
|
||||||
|
mode: number;
|
||||||
|
difficultyName: string;
|
||||||
|
modeName: string;
|
||||||
|
status: number;
|
||||||
|
modifierValues: BeatleaderModifier;
|
||||||
|
modifiersRating: BeatleaderModifierRating;
|
||||||
|
nominatedTime: number;
|
||||||
|
qualifiedTime: number;
|
||||||
|
rankedTime: number;
|
||||||
|
stars: number;
|
||||||
|
predictedAcc: number;
|
||||||
|
passRating: number;
|
||||||
|
accRating: number;
|
||||||
|
techRating: number;
|
||||||
|
type: number;
|
||||||
|
njs: number;
|
||||||
|
nps: number;
|
||||||
|
notes: number;
|
||||||
|
bombs: number;
|
||||||
|
walls: number;
|
||||||
|
maxScore: number;
|
||||||
|
duration: number;
|
||||||
|
requirements: number;
|
||||||
|
};
|
16
src/schemas/beatleader/leaderboard.ts
Normal file
16
src/schemas/beatleader/leaderboard.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { BeatleaderDifficulty } from "./difficulty";
|
||||||
|
import { BeatleaderSong } from "./song";
|
||||||
|
|
||||||
|
export type BeatleaderLeaderboard = {
|
||||||
|
id: string;
|
||||||
|
song: BeatleaderSong;
|
||||||
|
difficulty: BeatleaderDifficulty;
|
||||||
|
scores: null; // ??
|
||||||
|
changes: null; // ??
|
||||||
|
qualification: null; // ??
|
||||||
|
reweight: null; // ??
|
||||||
|
leaderboardGroup: null; // ??
|
||||||
|
plays: number;
|
||||||
|
clan: null; // ??
|
||||||
|
clanRankingContested: boolean;
|
||||||
|
};
|
5
src/schemas/beatleader/metadata.ts
Normal file
5
src/schemas/beatleader/metadata.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export type BeatleaderMetadata = {
|
||||||
|
itemsPerPage: number;
|
||||||
|
page: number;
|
||||||
|
total: number;
|
||||||
|
};
|
18
src/schemas/beatleader/modifierRating.ts
Normal file
18
src/schemas/beatleader/modifierRating.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
export type BeatleaderModifierRating = {
|
||||||
|
id: number;
|
||||||
|
fsPredictedAcc: number;
|
||||||
|
fsPassRating: number;
|
||||||
|
fsAccRating: number;
|
||||||
|
fsTechRating: number;
|
||||||
|
fsStars: number;
|
||||||
|
ssPredictedAcc: number;
|
||||||
|
ssPassRating: number;
|
||||||
|
ssAccRating: number;
|
||||||
|
ssTechRating: number;
|
||||||
|
ssStars: number;
|
||||||
|
sfPredictedAcc: number;
|
||||||
|
sfPassRating: number;
|
||||||
|
sfAccRating: number;
|
||||||
|
sfTechRating: number;
|
||||||
|
sfStars: number;
|
||||||
|
};
|
16
src/schemas/beatleader/modifiers.ts
Normal file
16
src/schemas/beatleader/modifiers.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export type BeatleaderModifier = {
|
||||||
|
modifierId: number;
|
||||||
|
da: number;
|
||||||
|
fs: number;
|
||||||
|
sf: number;
|
||||||
|
ss: number;
|
||||||
|
gn: number;
|
||||||
|
na: number;
|
||||||
|
nb: number;
|
||||||
|
nf: number;
|
||||||
|
no: number;
|
||||||
|
pm: number;
|
||||||
|
sc: number;
|
||||||
|
sa: number;
|
||||||
|
op: number;
|
||||||
|
};
|
9
src/schemas/beatleader/player.ts
Normal file
9
src/schemas/beatleader/player.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export type BeatLeaderPlayer = {
|
||||||
|
id: string;
|
||||||
|
country: string;
|
||||||
|
avatar: string;
|
||||||
|
pp: number;
|
||||||
|
rank: number;
|
||||||
|
countryRank: number;
|
||||||
|
// todo: finish this
|
||||||
|
};
|
51
src/schemas/beatleader/score.ts
Normal file
51
src/schemas/beatleader/score.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { BeatleaderLeaderboard } from "./leaderboard";
|
||||||
|
import { BeatleaderScoreImprovement } from "./scoreImprovement";
|
||||||
|
import { BeatleaderScoreOffsets } from "./scoreOffsets";
|
||||||
|
|
||||||
|
export type BeatleaderScore = {
|
||||||
|
myScore: null; // ??
|
||||||
|
validContexts: number;
|
||||||
|
leaderboard: BeatleaderLeaderboard;
|
||||||
|
contextExtensions: null; // ??
|
||||||
|
accLeft: number;
|
||||||
|
accRight: number;
|
||||||
|
id: number;
|
||||||
|
baseScore: number;
|
||||||
|
modifiedScore: number;
|
||||||
|
accuracy: number;
|
||||||
|
playerId: string;
|
||||||
|
pp: number;
|
||||||
|
bonusPp: number;
|
||||||
|
passPP: number;
|
||||||
|
accPP: number;
|
||||||
|
techPP: number;
|
||||||
|
rank: number;
|
||||||
|
country: string;
|
||||||
|
fcAccuracy: number;
|
||||||
|
fcPp: number;
|
||||||
|
weight: number;
|
||||||
|
replay: string;
|
||||||
|
modifiers: string;
|
||||||
|
badCuts: number;
|
||||||
|
missedNotes: number;
|
||||||
|
bombCuts: number;
|
||||||
|
wallsHit: number;
|
||||||
|
pauses: number;
|
||||||
|
fullCombo: boolean;
|
||||||
|
platform: string;
|
||||||
|
maxCombo: number;
|
||||||
|
maxStreak: number;
|
||||||
|
hmd: number;
|
||||||
|
controller: number;
|
||||||
|
leaderboardId: string;
|
||||||
|
timeset: string;
|
||||||
|
timepost: number;
|
||||||
|
replaysWatched: number;
|
||||||
|
playCount: number;
|
||||||
|
priority: number;
|
||||||
|
player: null; // ??
|
||||||
|
scoreImprovement: BeatleaderScoreImprovement;
|
||||||
|
rankVoting: null; // ??
|
||||||
|
metadata: null; // ??
|
||||||
|
offsets: BeatleaderScoreOffsets;
|
||||||
|
};
|
19
src/schemas/beatleader/scoreImprovement.ts
Normal file
19
src/schemas/beatleader/scoreImprovement.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
export type BeatleaderScoreImprovement = {
|
||||||
|
id: number;
|
||||||
|
timeset: number;
|
||||||
|
score: number;
|
||||||
|
accuracy: number;
|
||||||
|
pp: number;
|
||||||
|
bonusPp: number;
|
||||||
|
rank: number;
|
||||||
|
accRight: number;
|
||||||
|
accLeft: number;
|
||||||
|
averageRankedAccuracy: number;
|
||||||
|
totalPp: number;
|
||||||
|
totalRank: number;
|
||||||
|
badCuts: number;
|
||||||
|
missedNotes: number;
|
||||||
|
bombCuts: number;
|
||||||
|
wallsHit: number;
|
||||||
|
pauses: number;
|
||||||
|
};
|
8
src/schemas/beatleader/scoreOffsets.ts
Normal file
8
src/schemas/beatleader/scoreOffsets.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export type BeatleaderScoreOffsets = {
|
||||||
|
id: number;
|
||||||
|
frames: number;
|
||||||
|
notes: number;
|
||||||
|
walls: number;
|
||||||
|
heights: number;
|
||||||
|
pauses: number;
|
||||||
|
};
|
0
src/schemas/beatleader/scores.ts
Normal file
0
src/schemas/beatleader/scores.ts
Normal file
5
src/schemas/beatleader/smaller/smallerLeaderboard.ts
Normal file
5
src/schemas/beatleader/smaller/smallerLeaderboard.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { BeatleaderSmallerSong } from "./smallerSong";
|
||||||
|
|
||||||
|
export type BeatleaderSmallerLeaderboard = {
|
||||||
|
song: BeatleaderSmallerSong;
|
||||||
|
};
|
14
src/schemas/beatleader/smaller/smallerScore.ts
Normal file
14
src/schemas/beatleader/smaller/smallerScore.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { BeatleaderSmallerLeaderboard } from "./smallerLeaderboard";
|
||||||
|
import { BeatleaderSmallerScoreImprovement } from "./smallerScoreImprovement";
|
||||||
|
|
||||||
|
export type BeatleaderSmallerScore = {
|
||||||
|
id: number;
|
||||||
|
timepost: number;
|
||||||
|
accLeft: number;
|
||||||
|
accRight: number;
|
||||||
|
fcAccuracy: number;
|
||||||
|
wallsHit: number;
|
||||||
|
replay: string;
|
||||||
|
leaderboard: BeatleaderSmallerLeaderboard;
|
||||||
|
scoreImprovement: BeatleaderSmallerScoreImprovement | null;
|
||||||
|
};
|
@ -0,0 +1,9 @@
|
|||||||
|
export type BeatleaderSmallerScoreImprovement = {
|
||||||
|
score: number;
|
||||||
|
accuracy: number;
|
||||||
|
accRight: number;
|
||||||
|
accLeft: number;
|
||||||
|
badCuts: number;
|
||||||
|
missedNotes: number;
|
||||||
|
bombCuts: number;
|
||||||
|
};
|
4
src/schemas/beatleader/smaller/smallerSong.ts
Normal file
4
src/schemas/beatleader/smaller/smallerSong.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export type BeatleaderSmallerSong = {
|
||||||
|
hash: string;
|
||||||
|
bpm: number;
|
||||||
|
};
|
16
src/schemas/beatleader/song.ts
Normal file
16
src/schemas/beatleader/song.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export type BeatleaderSong = {
|
||||||
|
id: string;
|
||||||
|
hash: string;
|
||||||
|
name: string;
|
||||||
|
subName: string;
|
||||||
|
author: string;
|
||||||
|
mapperId: string;
|
||||||
|
coverImage: string;
|
||||||
|
fullCoverImage: string;
|
||||||
|
downloadUrl: string;
|
||||||
|
bpm: number;
|
||||||
|
duration: number;
|
||||||
|
tags: string;
|
||||||
|
uploadTime: number;
|
||||||
|
difficulties: null; // ??
|
||||||
|
};
|
21
src/schemas/beatsaver/BeatsaverMap.ts
Normal file
21
src/schemas/beatsaver/BeatsaverMap.ts
Normal 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;
|
||||||
|
};
|
22
src/schemas/beatsaver/BeatsaverMapDifficulty.ts
Normal file
22
src/schemas/beatsaver/BeatsaverMapDifficulty.ts
Normal 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;
|
||||||
|
};
|
8
src/schemas/beatsaver/BeatsaverMapMetadata.ts
Normal file
8
src/schemas/beatsaver/BeatsaverMapMetadata.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export type BeatsaverMapMetadata = {
|
||||||
|
bpm: number;
|
||||||
|
duration: number;
|
||||||
|
songName: string;
|
||||||
|
songSubName: string;
|
||||||
|
songAuthorName: string;
|
||||||
|
levelAuthorName: string;
|
||||||
|
};
|
7
src/schemas/beatsaver/BeatsaverMapStats.ts
Normal file
7
src/schemas/beatsaver/BeatsaverMapStats.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export type BeatsaverMapStats = {
|
||||||
|
plays: number;
|
||||||
|
downloads: number;
|
||||||
|
upvotes: number;
|
||||||
|
downvotes: number;
|
||||||
|
score: number;
|
||||||
|
};
|
5
src/schemas/beatsaver/BeatsaverMapSummary.ts
Normal file
5
src/schemas/beatsaver/BeatsaverMapSummary.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export type BeatsaverMapSummary = {
|
||||||
|
errors: number;
|
||||||
|
warns: number;
|
||||||
|
resets: number;
|
||||||
|
};
|
12
src/schemas/beatsaver/BeatsaverMapVersion.ts
Normal file
12
src/schemas/beatsaver/BeatsaverMapVersion.ts
Normal 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;
|
||||||
|
};
|
10
src/schemas/beatsaver/BeatsaverUploader.ts
Normal file
10
src/schemas/beatsaver/BeatsaverUploader.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export type BeatsaverUploader = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hash: string;
|
||||||
|
avatar: string;
|
||||||
|
type: string;
|
||||||
|
admin: boolean;
|
||||||
|
curator: boolean;
|
||||||
|
playlistUrl: string;
|
||||||
|
};
|
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;
|
||||||
|
};
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user