65 Commits

Author SHA1 Message Date
622896a5a4 fix(deps): update dependency lucide-react to ^0.370.0 2024-04-17 15:03:38 +00:00
2fedf0e6ff add shortened name for site name
All checks were successful
deploy / deploy (push) Successful in 1m8s
2024-02-01 03:20:44 +00:00
a14ff2f343 Merge branch 'master' of https://git.fascinated.cc/Fascinated/scoresaber-reloaded-v2
All checks were successful
deploy / deploy (push) Successful in 1m2s
2024-02-01 02:23:56 +00:00
be9328bab5 why was this disabled? 2024-02-01 02:23:55 +00:00
Lee
73046d46d4 Merge pull request 'fix(deps): update dependency sharp to ^0.33.0' (#42) from renovate/sharp-0.x into master
All checks were successful
deploy / deploy (push) Successful in 33s
Reviewed-on: #42
2024-02-01 02:11:11 +00:00
Lee
6fcb561085 Merge pull request 'fix(deps): update dependency lucide-react to ^0.320.0' (#41) from renovate/lucide-react-0.x into master
Some checks failed
deploy / deploy (push) Failing after 4s
Reviewed-on: #41
2024-02-01 02:11:04 +00:00
Lee
fac75f1e6a Merge pull request 'chore(deps): update dependency eslint-config-next to v14.1.0' (#43) from renovate/nextjs-monorepo into master
Some checks failed
deploy / deploy (push) Failing after 5s
Reviewed-on: #43
2024-02-01 02:10:56 +00:00
Lee
9b5d38896d Merge pull request 'fix(deps): update dependency date-fns to v3' (#44) from renovate/date-fns-3.x into master
Some checks failed
deploy / deploy (push) Has been cancelled
Reviewed-on: #44
2024-02-01 02:10:48 +00:00
1b42f9f6d9 fix(deps): update dependency date-fns to v3 2024-02-01 02:09:43 +00:00
136fc06469 fix(deps): update dependency sharp to ^0.33.0 2024-02-01 02:09:38 +00:00
e6f2e6a03d fix(deps): update dependency lucide-react to ^0.320.0 2024-02-01 02:09:29 +00:00
c0c8b625be chore(deps): update dependency eslint-config-next to v14.1.0 2024-02-01 02:09:25 +00:00
ae482a48c0 bump nextjs
All checks were successful
deploy / deploy (push) Successful in 1m35s
2024-02-01 01:52:57 +00:00
75bf876fb6 bump depends
All checks were successful
deploy / deploy (push) Successful in 1m31s
2024-02-01 01:51:03 +00:00
Lee
2d9de44fa9 Merge pull request 'fix(deps): update dependency react-toastify to v10' (#45) from renovate/react-toastify-10.x into master
Some checks failed
deploy / deploy (push) Failing after 46s
Reviewed-on: #45
2024-02-01 01:47:11 +00:00
8c9a5de93b i am a goof ball
All checks were successful
deploy / deploy (push) Successful in 1m3s
2024-02-01 01:45:07 +00:00
56f7255918 sentry stuff
All checks were successful
deploy / deploy (push) Successful in 1m14s
2024-02-01 01:41:10 +00:00
f58ea539f9 remove sentry
Some checks failed
deploy / deploy (push) Failing after 27s
2024-02-01 01:39:37 +00:00
0f70a50bd4 remove analytics page
Some checks failed
deploy / deploy (push) Failing after 21s
2024-02-01 01:37:52 +00:00
738a95180f fix(deps): update dependency react-toastify to v10 2024-01-14 18:04:03 +00:00
Lee
4e3ba2d0c7 Update src/components/Footer.tsx
Some checks failed
deploy / deploy (push) Has been cancelled
2023-12-07 16:33:06 +00:00
9e73ff3937 always show country flags in ranking pages
All checks were successful
deploy / deploy (push) Successful in 2m3s
2023-11-27 01:03:27 +00:00
3b7f458d5c update how player ranks are shown in countries
All checks were successful
deploy / deploy (push) Successful in 1m13s
2023-11-27 00:57:57 +00:00
d769b0d15e remove settings on navbar (for now)
All checks were successful
deploy / deploy (push) Successful in 1m10s
2023-11-26 14:31:18 +00:00
cf75dfb06d make padding slighly less on cards
All checks were successful
deploy / deploy (push) Successful in 2m10s
2023-11-26 01:56:05 +00:00
41090360e1 try/catch on sitemap
All checks were successful
deploy / deploy (push) Successful in 1m12s
2023-11-24 21:02:58 +00:00
b5629c0169 fix button alignment on score
Some checks failed
deploy / deploy (push) Failing after 36s
2023-11-24 21:00:50 +00:00
4c0775b000 fix(ssr): still show the youtube button even if the beatsaver map doesn't exist anymore
All checks were successful
deploy / deploy (push) Successful in 1m14s
2023-11-24 20:50:51 +00:00
407bcc866b defer beatsaver map data loading & fix it being fucked
All checks were successful
deploy / deploy (push) Successful in 1m15s
2023-11-24 20:43:23 +00:00
b74b22c0b6 Merge branch 'master' of https://git.fascinated.cc/Fascinated/scoresaber-reloaded-v2
All checks were successful
deploy / deploy (push) Successful in 1m14s
2023-11-24 19:29:40 +00:00
0f7b28ca02 only get ids for beatsaver maps 2023-11-24 19:29:39 +00:00
Lee
d5afdbde69 Merge pull request 'fix(deps): update dependency lucide-react to ^0.293.0' (#40) from renovate/lucide-react-0.x into master
All checks were successful
deploy / deploy (push) Successful in 1m40s
Reviewed-on: #40
2023-11-24 13:42:57 +00:00
31d37feb8e fix(deps): update dependency lucide-react to ^0.293.0 2023-11-24 13:03:53 +00:00
b3c3be4b7c make loading beatsaver map hashes way faster
All checks were successful
deploy / deploy (push) Successful in 2m9s
2023-11-21 12:27:47 +00:00
Lee
14f67af125 Merge pull request 'chore(deps): update nextjs monorepo to v14.0.3' (#39) from renovate/nextjs-monorepo into master
All checks were successful
deploy / deploy (push) Successful in 2m28s
Reviewed-on: #39
2023-11-19 10:02:34 +00:00
bb45a4bd6b chore(deps): update nextjs monorepo to v14.0.3 2023-11-16 19:02:14 +00:00
65e8df76a2 feat(ssr): add tooltip to youtube button
All checks were successful
deploy / deploy (push) Successful in 1m12s
2023-11-16 10:21:08 +00:00
925e423955 feat(ssr): add song sub name to youtube link
All checks were successful
deploy / deploy (push) Successful in 1m11s
2023-11-16 10:19:46 +00:00
ef7c6f4878 add a youtube link button to songs
All checks were successful
deploy / deploy (push) Successful in 1m17s
2023-11-16 10:13:20 +00:00
fd7cbf73a7 part 2
All checks were successful
deploy / deploy (push) Successful in 1m17s
2023-11-16 09:54:17 +00:00
a793847b91 fix(overlay): fix imports
Some checks failed
deploy / deploy (push) Failing after 31s
2023-11-16 09:53:04 +00:00
8c50b484cf fix(overlay): reset state when losing connection to the data provider
Some checks failed
deploy / deploy (push) Failing after 18s
2023-11-16 09:51:40 +00:00
3c5b5be02f fix(overlay): set paused state to false when loading into a song
Some checks failed
deploy / deploy (push) Failing after 39s
2023-11-16 09:49:09 +00:00
da701cf046 7
All checks were successful
deploy / deploy (push) Successful in 1m9s
2023-11-13 07:39:54 +00:00
443b4ce2f7 7
Some checks failed
deploy / deploy (push) Failing after 1m13s
2023-11-13 07:20:04 +00:00
cbcaaa2d8c 7
Some checks failed
deploy / deploy (push) Failing after 33s
2023-11-13 06:41:32 +00:00
4a03b0c97f 7
Some checks failed
deploy / deploy (push) Failing after 35s
2023-11-13 06:36:43 +00:00
dbfc6a93d0 7
Some checks failed
deploy / deploy (push) Failing after 34s
2023-11-13 06:35:08 +00:00
992b97a35d 7
Some checks failed
deploy / deploy (push) Failing after 56s
2023-11-13 06:32:30 +00:00
d1b0d85ecf 77
Some checks failed
deploy / deploy (push) Failing after 35s
2023-11-13 06:24:39 +00:00
7e730ed0c9 7
Some checks failed
deploy / deploy (push) Failing after 35s
2023-11-13 06:22:59 +00:00
bdb2ffc7ba 7
Some checks failed
deploy / deploy (push) Failing after 33s
2023-11-13 06:00:14 +00:00
95a9e103eb 7
Some checks failed
deploy / deploy (push) Failing after 34s
2023-11-13 05:57:46 +00:00
5e817186de many smarts
Some checks failed
deploy / deploy (push) Failing after 33s
2023-11-13 05:55:38 +00:00
75afdfed7d add infiscal
Some checks failed
deploy / deploy (push) Has been cancelled
2023-11-13 05:42:06 +00:00
9c2bf54426 Merge branch 'master' of https://git.fascinated.cc/Fascinated/scoresaber-reloaded-v2
Some checks failed
deploy / deploy (push) Failing after 19s
2023-11-13 03:52:58 +00:00
Lee
0c9fc07e83 Delete .env
Some checks failed
deploy / deploy (push) Failing after 20s
2023-11-13 03:52:39 +00:00
24c787969c sentry 2023-11-13 03:52:20 +00:00
000812b2e5 sentry
Some checks failed
deploy / deploy (push) Failing after 20s
2023-11-13 03:46:08 +00:00
c22dabeb9b sentry stuff
Some checks failed
deploy / deploy (push) Failing after 18s
2023-11-13 03:43:15 +00:00
7a31e158ea re-enable sentry
Some checks failed
deploy / deploy (push) Failing after 19s
2023-11-13 03:41:29 +00:00
b42ba1afdd feat(ssr): add update last updated to analytics embed
All checks were successful
deploy / deploy (push) Successful in 2m10s
2023-11-13 01:10:20 +00:00
426a2b5a2f fix(ssr): fix no players msg showing if there's no search
All checks were successful
deploy / deploy (push) Successful in 1m5s
2023-11-09 20:01:28 +00:00
350fe875fe Merge branch 'master' of https://git.fascinated.cc/Fascinated/scoresaber-reloaded-v2
All checks were successful
deploy / deploy (push) Successful in 1m6s
2023-11-09 19:58:18 +00:00
13b814700f feat(ssr): add no players found msg on search page 2023-11-09 19:58:17 +00:00
41 changed files with 3948 additions and 3376 deletions

4
.env
View File

@ -1,4 +0,0 @@
SENTRY_AUTH_TOKEN=hi
# Redis
REDIS_URL=redis://:bigtitsyes7@10.0.0.203:30004

View File

@ -1,4 +1,4 @@
SENTRY_AUTH_TOKEN=hi
INFISICAL_TOKEN=hi
# Redis
REDIS_URL=redis://localhost:6379/0

2
.gitignore vendored
View File

@ -46,3 +46,5 @@ analyze
# Sitemap
public/sitemap*
.env

5
.infisical.json Normal file
View File

@ -0,0 +1,5 @@
{
"workspaceId": "6551ad1ded9edd83540488e0",
"defaultEnvironment": "",
"gitBranchToEnvironmentMapping": null
}

View File

@ -17,6 +17,9 @@ ENV NEXT_TELEMETRY_DISABLED 1
ARG GIT_REV
ENV GIT_REV ${GIT_REV}
# ARG REDIS_URL
ENV REDIS_URL redis://:bigtitsyes7@10.0.0.203:30004
# Build the app
RUN pnpm run build

View File

@ -4,16 +4,20 @@ 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 somehow?
// todo: cache this on a file somehow?
async function getTopPlayers() {
console.log("Fetching top players...");
const players = [];
const pagesToFetch = 30;
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;

View File

@ -1,16 +1,22 @@
const nextBuildId = require("next-build-id");
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: false,
});
const withBundleAnalyzer = require("@next/bundle-analyzer")({ enabled: false });
/** @type {import('next').NextConfig} */
const nextConfig = {
generateEtags: true,
reactStrictMode: true,
swcMinify: true,
experimental: {
webpackBuildWorker: true,
optimizePackageImports: [
// 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",
@ -21,10 +27,19 @@ const nextConfig = {
"react-chartjs-2",
"country-list",
"@sentry/nextjs",
],
},
compress: false,
];
/** @type {import('next').NextConfig} */
const nextConfig = {
generateEtags: true,
reactStrictMode: true,
swcMinify: true,
compress: true,
poweredByHeader: false,
experimental: {
webpackBuildWorker: true,
optimizePackageImports,
},
env: {
NEXT_PUBLIC_BUILD_ID:
process.env.GIT_REV || nextBuildId.sync({ dir: __dirname }),
@ -36,83 +51,7 @@ const nextConfig = {
minute: "numeric",
}),
},
images: {
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: "/**",
},
{
protocol: "https",
hostname: "eu.cdn.beatsaver.com",
port: "",
pathname: "/**",
},
{
protocol: "https",
hostname: "na.cdn.beatsaver.com",
port: "",
pathname: "/**",
},
{
protocol: "https",
hostname: "avatars.akamai.steamstatic.com",
port: "",
pathname: "/**",
},
],
},
images: { remotePatterns },
};
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,
// },
// );

View File

@ -11,8 +11,8 @@
"lint": "next lint"
},
"dependencies": {
"@boiseitguru/cookie-cutter": "^0.2.1",
"@heroicons/react": "^2.0.18",
"@boiseitguru/cookie-cutter": "^0.2.3",
"@heroicons/react": "^2.1.1",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3",
@ -21,45 +21,45 @@
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7",
"@sentry/nextjs": "^7.74.1",
"chart.js": "^4.4.0",
"chart.js": "^4.4.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"clsx": "^2.1.0",
"country-list": "^2.3.0",
"critters": "^0.0.20",
"date-fns": "^2.30.0",
"date-fns": "^3.0.0",
"encoding": "^0.1.13",
"idb-keyval": "^6.2.1",
"lucide-react": "^0.292.0",
"next": "14.0.2",
"infisical-node": "^1.5.1",
"lucide-react": "^0.370.0",
"next": "^14.1.0",
"next-build-id": "^3.0.0",
"next-sitemap": "^4.2.3",
"next-themes": "^0.2.1",
"react": "^18",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18",
"react-toastify": "^9.1.3",
"redis": "^4.6.10",
"sharp": "^0.32.6",
"tailwind-merge": "^2.0.0",
"react-dom": "^18.2.0",
"react-toastify": "^10.0.4",
"redis": "^4.6.12",
"sharp": "^0.33.0",
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7",
"websocket": "^1.0.34",
"zustand": "^4.4.3"
"zustand": "^4.5.0"
},
"devDependencies": {
"@next/bundle-analyzer": "^14.0.0",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/websocket": "^1.0.8",
"autoprefixer": "^10.4.16",
"@next/bundle-analyzer": "^14.1.0",
"@types/node": "^20.11.14",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/websocket": "^1.0.10",
"autoprefixer": "^10.4.17",
"cross-env": "^7.0.3",
"eslint": "^8",
"eslint-config-next": "14.0.2",
"postcss": "^8.4.31",
"prettier": "^3.0.3",
"prettier-plugin-tailwindcss": "^0.5.6",
"tailwindcss": "^3.3.3",
"typescript": "^5"
"eslint": "^8.56.0",
"eslint-config-next": "14.1.0",
"postcss": "^8.4.33",
"prettier": "^3.2.4",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3"
}
}

6204
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -1,87 +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.json";
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
outerClassName="mt-2"
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>
);
}

View File

@ -1,39 +1,90 @@
import { Redis } from "@/db/redis";
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")?.split(",") ?? undefined;
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 }> = {};
for (const mapHash of mapHashes) {
const fetchMapFromCache = async (mapHash: string) => {
const cachedMap = await (
await Redis.client
).get(`beatsaver:map:${mapHash}`);
if (cachedMap) {
maps[mapHash] = JSON.parse(cachedMap);
totalInCache++;
} else {
const map = await BeatsaverAPI.fetchMapByHash(mapHash);
if (!map) {
continue;
return cachedMap;
};
const fetchAndCacheMap = async (mapHash: string) => {
const beatSaverMap = await BeatsaverAPI.fetchMapByHash(mapHash);
if (beatSaverMap) {
addMap(mapHash, beatSaverMap);
await cacheMap(mapHash, beatSaverMap);
}
maps[mapHash] = map;
};
const cacheMap = async (mapHash: string, map: BeatsaverMap) => {
await (
await Redis.client
).set("beatsaver:map:" + mapHash, JSON.stringify(map), {
EX: 60 * 60 * 24 * 7, // 7 days
});
).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 (idOnly) {
maps[mapHash] = { id: (maps[mapHash] as BeatsaverMap).id };
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);
}
}
}

View File

@ -18,7 +18,7 @@ export const viewport: Viewport = {
export const metadata: Metadata = {
metadataBase: new URL(ssrSettings.siteUrl),
title: {
template: ssrSettings.siteName + " - %s",
template: ssrSettings.siteNameShort + " - %s",
default: ssrSettings.siteName,
},
description: ssrSettings.description,

View File

@ -4,8 +4,8 @@ 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 "@/overlay/httpSiraStatus";
import { OverlayPlayer } from "@/overlay/type/overlayPlayer";
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";

View File

@ -69,12 +69,7 @@ export async function generateMetadata({
*/
async function getData(id: string, page: number, sort: string) {
const playerData = await ScoreSaberAPI.fetchPlayerData(id);
const playerScores = await ScoreSaberAPI.fetchScoresWithBeatsaverData(
id,
page,
sort,
10,
);
const playerScores = await ScoreSaberAPI.fetchScores(id, page, sort, 10);
return {
playerData: playerData,
playerScores: playerScores,

View File

@ -14,7 +14,7 @@ export default function Card({
}: CardProps) {
return (
<CardBase className={outerClassName}>
<CardContent className={clsx(className, "pb-4 pt-2")}>
<CardContent className={clsx(className, "p-3 pb-4 pt-2")}>
{children}
</CardContent>
</CardBase>

View File

@ -22,7 +22,7 @@ const buildId = process.env.NEXT_PUBLIC_BUILD_ID
const buildTime = process.env.NEXT_PUBLIC_BUILD_TIME;
const gitUrl = isProduction()
? `https://git.fascinated.cc/Fascinated/scoresaber-reloaded-v2/commit/${buildId}`
: "https://git.fascinated.cc/Fascinated/scoresaber-reloaded-v2";
: "https://s.fascinated.cc/s/ssr-gitea";
export default function Footer() {
return (
@ -31,7 +31,7 @@ export default function Footer() {
<div className="flex flex-col items-center gap-1 md:flex-row md:items-start md:gap-3">
<a
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}
</a>

View File

@ -57,7 +57,7 @@ export default function GlobalRanking({
{players.map((player) => (
<tr key={player.rank} className="border-b border-border">
<PlayerRanking
showCountryFlag={country ? false : true}
isCountry={country == undefined ? false : true}
player={player}
/>
</tr>

View File

@ -5,12 +5,15 @@ import { formatNumber } from "@/utils/numberUtils";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import clsx from "clsx";
import Link from "next/link";
import { useEffect, useState } from "react";
import Avatar from "./Avatar";
export default function SearchPlayer() {
const [search, setSearch] = useState("");
const [players, setPlayers] = useState([] as ScoresaberPlayer[]);
const [players, setPlayers] = useState(
undefined as ScoresaberPlayer[] | undefined,
);
useEffect(() => {
// Don't search if the query is too short
@ -28,14 +31,20 @@ export default function SearchPlayer() {
if (id == undefined) return;
const player = await ScoreSaberAPI.fetchPlayerData(id);
if (player == undefined) return;
if (player == undefined) {
setPlayers([]);
return;
}
setPlayers([player]);
}
// Search by name
const players = await ScoreSaberAPI.searchByName(search);
if (players == undefined) return;
if (players == undefined) {
setPlayers([]);
return;
}
setPlayers(players);
}
@ -44,7 +53,7 @@ export default function SearchPlayer() {
e.preventDefault();
// 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`;
}
}
@ -68,11 +77,12 @@ export default function SearchPlayer() {
<div
className={clsx(
"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) => (
<a
{players && players.length > 0
? players.map((player: ScoresaberPlayer) => (
<Link
key={player.id}
className="flex min-w-[14rem] items-center gap-2 p-2 transition-all hover:bg-background"
href={`/player/${player.id}/top/1`}
@ -85,8 +95,9 @@ export default function SearchPlayer() {
</p>
<p className="text-sm">{player.name}</p>
</div>
</a>
))}
</Link>
))
: search.length > 0 && <div className="p-2">No players found</div>}
</div>
</form>
);

View 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>
);
}

View File

@ -98,7 +98,7 @@ export default function Leaderboard({ id, page }: LeaderboardProps) {
<Container>
<div className="mt-2 flex flex-col gap-2 xl:flex-row">
<Card outerClassName="h-fit pt-3" className="flex">
<div className="flex min-w-[320px] 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">
<Image
src={coverImage}

View File

@ -1,8 +1,4 @@
import {
CogIcon,
MagnifyingGlassIcon,
ServerIcon,
} from "@heroicons/react/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import { GlobeAltIcon, TvIcon } from "@heroicons/react/24/outline";
import { Card } from "../ui/card";
import FriendsButton from "./FriendsButton";
@ -29,12 +25,6 @@ export default function Navbar() {
icon={<TvIcon height={23} width={23} />}
href="/overlay/builder"
/>
<NavbarButton
ariaLabel="View analytics for Scoresaber"
text="Analytics"
icon={<ServerIcon height={23} width={23} />}
href="/analytics"
/>
<div className="m-auto" />
@ -44,12 +34,12 @@ export default function Navbar() {
icon={<MagnifyingGlassIcon height={23} width={23} />}
href="/search"
/>
<NavbarButton
{/* <NavbarButton
ariaLabel="View your settings"
text="Settings"
icon={<CogIcon height={23} width={23} />}
href="/settings"
/>
/> */}
</Card>
</>
);

View File

@ -1,4 +1,4 @@
import { OverlayPlayer } from "@/overlay/type/overlayPlayer";
import { OverlayPlayer } from "@/lib/overlay/type/overlayPlayer";
import { formatNumber } from "@/utils/numberUtils";
import { GlobeAltIcon } from "@heroicons/react/20/solid";
import Image from "next/image";

View File

@ -8,25 +8,26 @@ import CountyFlag from "../CountryFlag";
type PlayerRankingProps = {
player: ScoresaberPlayer;
showCountryFlag?: boolean;
isCountry?: boolean;
};
const Avatar = dynamic(() => import("@/components/Avatar"));
export default function PlayerRanking({
player,
showCountryFlag,
isCountry,
}: PlayerRankingProps) {
const settingsStore = useStore(useSettingsStore, (store) => store);
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">
<Avatar url={player.profilePicture} label="Avatar" size={24} />
{showCountryFlag && (
<CountyFlag countryCode={player.country} className="!h-5 !w-5" />
)}
<Link
className="transform-gpu transition-all hover:text-blue-500"
href={`/player/${player.id}/top/1`}

View File

@ -1,7 +1,7 @@
"use client";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { ScoresaberScoreWithBeatsaverData } from "@/schemas/scoresaber/scoreWithBeatsaverData";
import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore";
import { useSettingsStore } from "@/store/settingsStore";
import { SortType, SortTypes } from "@/types/SortTypes";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
@ -16,11 +16,11 @@ type PageInfo = {
page: number;
totalPages: number;
sortType: SortType;
scores: Record<string, ScoresaberScoreWithBeatsaverData>;
scores: ScoresaberPlayerScore[] | undefined;
};
type ScoresProps = {
initalScores: Record<string, ScoresaberScoreWithBeatsaverData> | undefined;
initalScores: ScoresaberPlayerScore[] | undefined;
initalPage: number;
initalSortType: SortType;
initalTotalPages?: number;
@ -45,7 +45,7 @@ export default function Scores({
page: initalPage,
totalPages: initalTotalPages || 1,
sortType: initalSortType,
scores: initalScores ? initalScores : {},
scores: initalScores,
});
const [changedPage, setChangedPage] = useState(false);
@ -61,12 +61,8 @@ export default function Scores({
return;
}
ScoreSaberAPI.fetchScoresWithBeatsaverData(
playerId,
page,
sortType.value,
10,
).then((scoresResponse) => {
ScoreSaberAPI.fetchScores(playerId, page, sortType.value, 10).then(
(scoresResponse) => {
if (!scoresResponse) {
setError(true);
setErrorMessage("No Scores");
@ -89,7 +85,8 @@ export default function Scores({
setChangedPage(true);
console.log(`Switched page to ${page} with sort ${sortType.value}`);
});
},
);
},
[
changedPage,
@ -150,10 +147,12 @@ export default function Scores({
</div>
<div className="flex h-full w-full flex-col items-center justify-center">
<>
{scores.scores ? (
<>
<div className="grid min-w-full grid-cols-1 divide-y divide-border">
{Object.values(scores.scores).map((scoreData, id) => {
const { score, leaderboard, mapId } = scoreData;
{scores.scores.map((scoreData, id) => {
const { score, leaderboard } = scoreData;
return (
<Score
@ -161,8 +160,6 @@ export default function Scores({
player={playerData}
score={score}
leaderboard={leaderboard}
mapId={mapId}
ownProfile={settingsStore?.player}
/>
);
})}
@ -178,6 +175,10 @@ export default function Scores({
/>
</div>
</>
) : (
<p>No Scores!</p>
)}
</>
</div>
</Card>
);

View 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>
);
}

View File

@ -17,28 +17,19 @@ import {
import clsx from "clsx";
import Image from "next/image";
import Link from "next/link";
import BeatSaverLogo from "../../icons/BeatSaverLogo";
import { Suspense } from "react";
import HeadsetIcon from "../../icons/HeadsetIcon";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/Tooltip";
import { Button } from "../../ui/button";
import ScoreStatLabel from "../ScoreStatLabel";
import CopyBsrButton from "./CopyBsrButton";
import MapButtons from "./MapButtons";
type ScoreProps = {
score: ScoresaberScore;
player: ScoresaberPlayer;
leaderboard: ScoresaberLeaderboardInfo;
ownProfile?: ScoresaberPlayer;
mapId?: string;
};
export default function Score({
score,
player,
leaderboard,
ownProfile,
mapId,
}: ScoreProps) {
export default function Score({ score, player, leaderboard }: ScoreProps) {
const isFullCombo = score.missedNotes + score.badCuts === 0;
const diffName = scoresaberDifficultyNumberToName(
leaderboard.difficulty.difficulty,
@ -122,32 +113,9 @@ export default function Score({
</Link>
</div>
<div className="hidden items-center justify-between gap-1 p-1 md:flex md:items-start md:justify-end">
{mapId && (
<>
<Tooltip>
<TooltipTrigger>
<Link
href={`https://beatsaver.com/maps/${mapId}`}
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>
<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 flex-col md:hidden">

View File

@ -3,7 +3,7 @@ import { createClient } from "redis";
let redisClient = await connectRedis();
async function connectRedis(): Promise<any> {
console.log("Connecting to redis");
// console.log("Connecting to redis");
const client = createClient({
url: process.env.REDIS_URL,
});
@ -20,8 +20,6 @@ async function connectRedis(): Promise<any> {
return client;
}
// todo: add disconnect handler
export const Redis = {
client: redisClient,
connectRedis,

View File

@ -16,6 +16,7 @@ async function loadIntoSong(data: any) {
beatsaverMapData.versions[beatsaverMapData.versions.length - 1].coverURL;
overlayDataStore.setState({
paused: false,
scoreStats: {
accuracy: performance.relativeScore * 100,
score: performance.rawScore,
@ -33,6 +34,16 @@ async function loadIntoSong(data: any) {
});
}
/**
* Reset the state of the overlay
*/
function resetState() {
overlayDataStore.setState({
scoreStats: undefined,
songInfo: undefined,
});
}
type Handlers = {
[key: string]: (data: any) => void;
};
@ -64,16 +75,10 @@ const handlers: Handlers = {
// Left the song
finished: (data: any) => {
overlayDataStore.setState({
scoreStats: undefined,
songInfo: undefined,
});
resetState();
},
menu: (data: any) => {
overlayDataStore.setState({
scoreStats: undefined,
songInfo: undefined,
});
resetState();
},
// pause & resume
@ -106,6 +111,7 @@ function connectWebSocket() {
console.log(
"Lost connection to HttpSiraStatus, reconnecting in 5 seconds...",
);
resetState();
setTimeout(() => {
connectWebSocket();
}, 5000); // 5 seconds

8
src/secrets.ts Normal file
View File

@ -0,0 +1,8 @@
import InfisicalClient from "infisical-node";
const infisicalClient = new InfisicalClient({
token: process.env.INFISICAL_TOKEN,
siteURL: "https://secrets.fascinated.cc",
});
export default infisicalClient;

View File

@ -1,5 +1,6 @@
{
"siteName": "ScoreSaber Reloaded",
"siteNameShort": "SSR",
"description": "Scoresaber Reloaded is a new way to view your scores and get more stats about your and your plays",
"siteUrl": "https://ssr.fascinated.cc",
"proxy": "https://proxy.fascinated.cc"

View File

@ -9,15 +9,34 @@ if (typeof window !== "undefined") {
}
export const IDBStorage: StateStorage = {
/**
* Fetch an item from the storage
*
* @param name name of the item to be fetched
* @returns the value of the item or null if it doesn't exist
*/
getItem: async (name: string): Promise<string | null> => {
//console.log(name, "has been retrieved");
return (await get(name, storage)) || null;
},
/**
* Save an item to the storage
*
* @param name name of the item to be saved
* @param value value of the item to be saved
*/
setItem: async (name: string, value: string): Promise<void> => {
//console.log(name, "with value", value, "has been saved");
await set(name, value, storage);
},
/**
* Delete an item from the storage
*
* @param name name of the item to be deleted
*/
removeItem: async (name: string): Promise<void> => {
//console.log(name, "has been deleted");
await del(name, storage);

View File

@ -11,16 +11,21 @@ const BS_API_URL = ssrSettings.proxy + "/https://api.beatsaver.com";
export const BS_GET_MAP_BY_HASH_URL = BS_API_URL + "/maps/hash/{}";
/**
* Returns the map info for the provided hash
* Returns the map info for the provided hashes
*
* @param hash the hash of the map
* @param hash the hashes for the maps
* @returns the map info
*/
async function fetchMapByHash(
hash: string,
): Promise<BeatsaverMap | undefined | null> {
async function fetchMapsByHash(
...hash: string[]
): Promise<{ [key: string]: BeatsaverMap } | undefined> {
const hashes = hash.join(",");
const response = await BeatsaverFetchQueue.fetch(
formatString(BS_GET_MAP_BY_HASH_URL, true, hash),
formatString(
BS_GET_MAP_BY_HASH_URL,
true,
hashes.substring(0, hashes.length - 1),
),
);
const json = await response.json();
@ -29,9 +34,31 @@ async function fetchMapByHash(
return undefined;
}
return json as BeatsaverMap;
return json;
}
/**
* Returns the map info for the provided hash
*
* @param hash the hash of the map
* @returns the map info
*/
async function fetchMapByHash(hash: string): Promise<BeatsaverMap | undefined> {
const response = await BeatsaverFetchQueue.fetch(
formatString(BS_GET_MAP_BY_HASH_URL, true, hash),
);
console.log(formatString(BS_GET_MAP_BY_HASH_URL, true, hash));
const json = await response.json();
// Check if there was an error fetching the user data
if (json.error) {
return undefined;
}
return json;
}
export const BeatsaverAPI = {
fetchMapsByHash,
fetchMapByHash,
};

View File

@ -13,7 +13,7 @@ export class FetchQueue {
* @param url - The URL to fetch.
* @returns The response.
*/
public async fetch(url: string, options?: any): Promise<any> {
public async fetch(url: string, options?: any): Promise<Response> {
const now = Date.now();
if (now < this.rateLimitReset) {

View File

@ -178,6 +178,8 @@ async function fetchScoresWithBeatsaverData(
for (const score of scores) {
url += `${score.leaderboard.songHash},`;
}
url = url.substring(0, url.length - 1);
url += "&idonly=true";
const mapResponse = await fetch(url, {
next: {
revalidate: 60 * 60 * 24 * 7, // 1 week

View File

@ -48,3 +48,30 @@ export function scoresaberDifficultyNumberToName(
return "unknown";
}
}
/**
* Turns a song name and author into a YouTube link
*
* @param name the name of the song
* @param songSubName the sub name of the song
* @param author the author of the song
* @returns the YouTube link for the song
*/
export function songNameToYouTubeLink(
name: string,
songSubName: string,
author: string,
) {
const baseUrl = "https://www.youtube.com/results?search_query=";
let query = "";
if (name) {
query += `${name} `;
}
if (songSubName) {
query += `${songSubName} `;
}
if (author) {
query += `${author} `;
}
return encodeURI(baseUrl + query.trim().replaceAll(" ", "+"));
}

View File

@ -31,7 +31,7 @@ export function formatTimeAgo(timestamp: string) {
* @param timestamp the timestamp to format
* @returns the formatted timestamp
*/
export function formatDate(timestamp: string) {
export function formatDate(timestamp: any) {
const date = parseISO(timestamp);
return date.toLocaleDateString("en-US", {
year: "numeric",

View File

@ -23,6 +23,12 @@
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"src/secrets.ts"
],
"exclude": ["node_modules"]
}