bob
All checks were successful
Deploy / deploy (push) Successful in 5m10s

This commit is contained in:
Lee 2024-10-04 18:25:37 +01:00
parent 4d35ee4050
commit 00462d9ed6
431 changed files with 2836 additions and 9283 deletions

@ -1,3 +0,0 @@
node_modules
.next
.turbo

3
.eslintrc.json Normal file

@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}

@ -1,21 +1,21 @@
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: scoresaber-reloaded-frontend name: scoresaber-reloaded
namespace: public-services namespace: public-services
spec: spec:
replicas: 1 replicas: 1
selector: selector:
matchLabels: matchLabels:
app: scoresaber-reloaded-frontend app: scoresaber-reloaded
template: template:
metadata: metadata:
labels: labels:
app: scoresaber-reloaded-frontend app: scoresaber-reloaded
spec: spec:
containers: containers:
- name: scoresaber-reloaded-frontend-container - name: scoresaber-reloaded-container
image: git.fascinated.cc/fascinated/scoresaber-reloaded-frontend:latest image: git.fascinated.cc/fascinated/scoresaber-reloaded:latest
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 3000 - containerPort: 3000

@ -2,7 +2,7 @@
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
metadata: metadata:
name: scoresaber-reloaded-frontend-ingress name: scoresaber-reloaded-ingress
namespace: public-services namespace: public-services
annotations: annotations:
kubernetes.io/ingress.class: traefik-external kubernetes.io/ingress.class: traefik-external
@ -18,7 +18,7 @@ spec:
- name: compress - name: compress
namespace: traefik namespace: traefik
services: services:
- name: scoresaber-reloaded-frontend-service - name: scoresaber-reloaded-service
port: 3000 port: 3000
tls: tls:
secretName: fascinated-cc secretName: fascinated-cc

@ -3,7 +3,7 @@ apiVersion: bitnami.com/v1alpha1
kind: SealedSecret kind: SealedSecret
metadata: metadata:
creationTimestamp: null creationTimestamp: null
name: ssr-frontend-secret name: ssr-secret
namespace: public-services namespace: public-services
spec: spec:
encryptedData: encryptedData:
@ -15,6 +15,6 @@ spec:
template: template:
metadata: metadata:
creationTimestamp: null creationTimestamp: null
name: ssr-frontend-secret name: ssr-secret
namespace: public-services namespace: public-services
type: Opaque type: Opaque

@ -2,7 +2,7 @@
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: scoresaber-reloaded-frontend-service name: scoresaber-reloaded-service
namespace: public-services namespace: public-services
spec: spec:
type: ClusterIP type: ClusterIP
@ -10,4 +10,4 @@ spec:
- port: 3000 - port: 3000
targetPort: 3000 targetPort: 3000
selector: selector:
app: scoresaber-reloaded-frontend app: scoresaber-reloaded

@ -1,4 +1,4 @@
name: "Deploy Frontend" name: "Deploy"
on: on:
push: push:
@ -26,11 +26,10 @@ jobs:
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./apps/frontend/Dockerfile
push: true push: true
tags: | tags: |
git.fascinated.cc/fascinated/scoresaber-reloaded-frontend:${{ github.sha }} git.fascinated.cc/fascinated/scoresaber-reloaded:${{ github.sha }}
git.fascinated.cc/fascinated/scoresaber-reloaded-frontend:latest git.fascinated.cc/fascinated/scoresaber-reloaded:latest
build-args: | build-args: |
GIT_REV=${{ gitea.sha }} GIT_REV=${{ gitea.sha }}
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
@ -50,9 +49,9 @@ jobs:
action: deploy action: deploy
namespace: public-services namespace: public-services
manifests: | manifests: |
.gitea/kubernetes/frontend/sealed-secrets.yaml .gitea/kubernetes/sealed-secrets.yaml
.gitea/kubernetes/frontend/deployment.yaml .gitea/kubernetes/deployment.yaml
.gitea/kubernetes/frontend/service.yaml .gitea/kubernetes/service.yaml
.gitea/kubernetes/frontend/ingress.yaml .gitea/kubernetes/ingress.yaml
images: | images: |
git.fascinated.cc/fascinated/scoresaber-reloaded-frontend:${{ github.sha }} git.fascinated.cc/fascinated/scoresaber-reloaded:${{ github.sha }}

11
.gitignore vendored

@ -1,15 +1,16 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies # dependencies
node_modules /node_modules
/.pnp /.pnp
.pnp.js .pnp.js
.yarn/install-state.gz
# testing # testing
/coverage /coverage
# next.js # next.js
.next/ /.next/
/out/ /out/
# production # production
@ -33,6 +34,8 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
.turbo
.idea .idea
# Sentry Config File
.env.sentry-build-plugin

65
Dockerfile Normal file

@ -0,0 +1,65 @@
FROM fascinated/docker-images:nodejs_20_with_pnpm AS base
# Install dependencies and build tools for canvas
FROM base AS deps
RUN apk add --no-cache python3 make g++ gcc pkgconfig pixman cairo-dev libjpeg-turbo-dev pango-dev giflib-dev
WORKDIR /app
COPY package.json* pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile --quiet
# Build from source
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Install runtime dependencies
RUN apk add --no-cache cairo pango libjpeg-turbo giflib
ENV NEXT_TELEMETRY_DISABLED=1
# Add the commit hash
ARG GIT_REV
ENV GIT_REV=${GIT_REV}
# Add the sentry auth token
ARG SENTRY_AUTH_TOKEN
ENV SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
# Build the app
RUN pnpm run build
# Final stage to run the app
FROM base AS runner
WORKDIR /app
# Install runtime dependencies
RUN apk add --no-cache cairo pango libjpeg-turbo giflib
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Add the commit hash
ARG GIT_REV
ENV GIT_REV=${GIT_REV}
# Copy the built app from the builder stage
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/next.config.mjs ./next.config.mjs
USER nextjs
EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
ENV PORT=3000
CMD ["pnpm", "start"]

@ -1,3 +0,0 @@
node_modules
.next
.turbo

@ -1,9 +0,0 @@
{
"extends": ["next/core-web-vitals", "next/typescript"],
"rules": {
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-unused-expressions": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-empty-object-type": "off"
}
}

@ -1,41 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.idea
# Sentry Config File
.env.sentry-build-plugin

@ -1,12 +0,0 @@
{
"semi": true,
"tabWidth": 2,
"useTabs": false,
"singleQuote": false,
"trailingComma": "es5",
"printWidth": 120,
"bracketSpacing": true,
"arrowParens": "avoid",
"jsxSingleQuote": false,
"jsxBracketSameLine": false
}

@ -1,26 +0,0 @@
FROM fascinated/docker-images:nodejs_20_with_pnpm
RUN apk update
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
RUN pnpm i -g turbo@^2
COPY . .
# Install depends
RUN pnpm install
# Add the commit hash
ARG GIT_REV
ENV GIT_REV=${GIT_REV}
# Add the sentry auth token
ARG SENTRY_AUTH_TOKEN
ENV SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
RUN pnpm turbo run build --filter=frontend
EXPOSE 3000
CMD node /app/apps/frontend/.next/standalone/server.js

@ -1,65 +0,0 @@
FROM fascinated/docker-images:nodejs_20_with_pnpm AS base
FROM base AS builder
RUN apk update
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
RUN pnpm i -g turbo@^2
COPY . .
# Generate a partial monorepo with a pruned lockfile for a target workspace.
# Assuming "frontend" is the name entered in the project's package.json: { name: "frontend" }
RUN turbo prune frontend --docker
# Add lockfile and package.json's of isolated subworkspace
FROM base AS installer
WORKDIR /app
# Add the commit hash
ARG GIT_REV
ENV GIT_REV=${GIT_REV}
# Add the sentry auth token
ARG SENTRY_AUTH_TOKEN
ENV SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm i -g turbo@^2
# First install the dependencies (as they change less often)
COPY --from=builder /app/out/json/ .
RUN pnpm install
# Build the project
COPY --from=builder /app/out/full/ .
RUN ls -la
RUN pnpm turbo run build --filter=frontend...
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Add the commit hash
ARG GIT_REV
ENV GIT_REV=${GIT_REV}
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer --chown=nextjs:nodejs /app/apps/frontend/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /app/apps/frontend/.next/static ./apps/frontend/.next/static
COPY --from=installer --chown=nextjs:nodejs /app/apps/frontend/public ./apps/frontend/public
CMD node apps/frontend/server.js

@ -1,56 +0,0 @@
import { format } from "@formkit/tempo";
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
experimental: {
webpackMemoryOptimizations: true,
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "cdn.scoresaber.com",
port: "",
pathname: "/**",
},
],
},
env: {
NEXT_PUBLIC_BUILD_ID: process.env.GIT_REV || "bob",
NEXT_PUBLIC_BUILD_TIME: new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
timeZoneName: "short",
}),
NEXT_PUBLIC_BUILD_TIME_SHORT: format(new Date(), {
date: "short",
time: "short",
}),
},
};
export default nextConfig;
// export default withSentryConfig(nextConfig, {
// org: "scoresaber-reloaded",
// project: "frontend",
// sentryUrl: "https://glitchtip.fascinated.cc/",
// silent: !process.env.CI,
// reactComponentAnnotation: {
// enabled: true,
// },
// tunnelRoute: "/monitoring",
// hideSourceMaps: true,
// disableLogger: true,
// sourcemaps: {
// disable: true,
// },
// release: {
// create: false,
// finalize: false,
// },
// });

@ -1,65 +0,0 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@formkit/tempo": "^0.1.2",
"@heroicons/react": "^2.1.5",
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.1.2",
"@sentry/nextjs": "8",
"@tanstack/react-query": "^5.55.4",
"@trigger.dev/nextjs": "^3.0.8",
"@trigger.dev/react": "^3.0.8",
"@trigger.dev/sdk": "^3.0.8",
"@uidotdev/usehooks": "^2.4.1",
"chart.js": "^4.4.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"comlink": "^4.4.1",
"dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7",
"framer-motion": "^11.5.4",
"js-cookie": "^3.0.5",
"ky": "^1.7.2",
"lucide-react": "^0.446.0",
"mongoose": "^8.7.0",
"next": "15.0.0-rc.0",
"next-build-id": "^3.0.0",
"next-themes": "^0.3.0",
"react": "19.0.0-rc-3edc000d-20240926",
"react-chartjs-2": "^5.2.0",
"react-dom": "19.0.0-rc-3edc000d-20240926",
"react-hook-form": "^7.53.0",
"styled-jsx": "^5.1.6",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/js-cookie": "^3.0.6",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.13",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
},
"trigger.dev": {
"endpointId": "scoresaber-reloaded-KB0Z"
}
}

File diff suppressed because it is too large Load Diff

@ -1,3 +0,0 @@
{
"extends": ["config:recommended", ":dependencyDashboard"]
}

@ -1,5 +0,0 @@
"use client";
export default function Home() {
return <main>hi</main>;
}

@ -1,24 +0,0 @@
import { cache } from "react";
import { config } from "../../config";
/**
* Proxies all non-localhost images to make them load faster.
*
* @param originalUrl the original image url
* @returns the new image url
*/
export function getImageUrl(originalUrl: string) {
return `${!config.siteUrl.includes("localhost") ? "https://img.fascinated.cc/upload/q_70/" : ""}${originalUrl}`;
}
/**
* Gets the average color of an image
*
* @param src the image url
* @returns the average color
*/
export const getAverageColor = cache(async (src: string) => {
return {
hex: "#fff",
};
});

@ -1,61 +0,0 @@
const diffColors: Record<string, string> = {
easy: "#3CB371",
normal: "#59b0f4",
hard: "#FF6347",
expert: "#bf2a42",
expertplus: "#8f48db",
};
export type ScoreBadge = {
name: string;
min: number | null;
max: number | null;
color: string;
};
const scoreBadges: ScoreBadge[] = [
{ name: "SS+", min: 95, max: null, color: diffColors.expertplus },
{ name: "SS", min: 90, max: 95, color: diffColors.expert },
{ name: "S+", min: 85, max: 90, color: diffColors.hard },
{ name: "S", min: 80, max: 85, color: diffColors.normal },
{ name: "A", min: 70, max: 80, color: diffColors.easy },
{ name: "-", min: null, max: 70, color: "hsl(var(--accent))" },
];
/**
* Returns the color based on the accuracy provided.
*
* @param acc - The accuracy for the score
* @returns The corresponding color for the accuracy.
*/
export function getScoreBadgeFromAccuracy(acc: number): ScoreBadge {
// Check for SS+ first since it has no upper limit
if (acc >= 95) {
return scoreBadges[0]; // SS+ color
}
// Iterate through the rest of the badges
for (const badge of scoreBadges) {
const min = badge.min ?? -Infinity; // Treat null `min` as -Infinity
const max = badge.max ?? Infinity; // Treat null `max` as Infinity
// Check if the accuracy falls within the badge's range
if (acc >= min && acc < (max === null ? Infinity : max)) {
return badge; // Return the color of the matching badge
}
}
// Fallback color if no badge matches (should not happen)
return scoreBadges[scoreBadges.length - 1];
}
/**
* Turns the difficulty of a song into a color
*
* @param diff the difficulty to get the color for
* @returns the color for the difficulty
*/
export function songDifficultyToColor(diff: string) {
diff = diff.replace("+", "Plus");
return diffColors[diff.toLowerCase() as keyof typeof diffColors];
}

@ -1,20 +0,0 @@
export const CustomizedAxisTick = ({
x,
y,
payload,
rotateAngle = -45,
}: {
x?: number;
y?: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload?: any;
rotateAngle?: number;
}) => {
return (
<g transform={`translate(${x},${y})`}>
<text x={0} y={0} dy={16} textAnchor="end" fill="#666" transform={`rotate(${rotateAngle})`}>
{payload.value}
</text>
</g>
);
};

@ -1,32 +0,0 @@
import ScoreSaberScoreToken from "@/common/model/token/scoresaber/score-saber-score-token";
import Image from "next/image";
import Link from "next/link";
type Props = {
score: ScoreSaberScoreToken;
};
export default function LeaderboardPlayer({ score }: Props) {
const player = score.leaderboardPlayerInfo;
return (
<div className="flex gap-2">
<Image
unoptimized
src={player.profilePicture}
width={48}
height={48}
alt="Song Artwork"
className="rounded-md min-w-[48px]"
priority
/>
<Link
href={`/player/${player.id}`}
target="_blank"
className="h-fit hover:brightness-75 transition-all transform-gpu"
>
<p>{player.name}</p>
</Link>
</div>
);
}

@ -1,73 +0,0 @@
"use client";
import { scoresaberService } from "@/common/service/impl/scoresaber";
import ScoreSaberLeaderboardToken from "@/common/model/token/scoresaber/score-saber-leaderboard-token";
import ScoreSaberLeaderboardScoresPageToken from "@/common/model/token/scoresaber/score-saber-leaderboard-scores-page-token";
import useWindowDimensions from "@/hooks/use-window-dimensions";
import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import { useEffect, useState } from "react";
import Card from "../card";
import Pagination from "../input/pagination";
import LeaderboardScore from "./leaderboard-score";
type Props = {
leaderboard: ScoreSaberLeaderboardToken;
};
export default function LeaderboardScores({ leaderboard }: Props) {
const { width } = useWindowDimensions();
const [currentPage, setCurrentPage] = useState(1);
const [currentScores, setCurrentScores] = useState<ScoreSaberLeaderboardScoresPageToken | undefined>();
const {
data: scores,
isError,
isLoading,
refetch,
} = useQuery({
queryKey: ["playerScores", leaderboard.id, currentPage],
queryFn: () => scoresaberService.lookupLeaderboardScores(leaderboard.id + "", currentPage),
staleTime: 30 * 1000, // Cache data for 30 seconds
});
useEffect(() => {
if (scores) {
setCurrentScores(scores);
}
}, [scores]);
useEffect(() => {
refetch();
}, [leaderboard, currentPage, refetch]);
if (currentScores === undefined) {
return undefined;
}
return (
<motion.div initial={{ opacity: 0, y: -50 }} exit={{ opacity: 0, y: -50 }} animate={{ opacity: 1, y: 0 }}>
<Card className="flex gap-2 border border-input mt-2">
<div className="text-center">
{isError && <p>Oopsies! Something went wrong.</p>}
{currentScores.scores.length === 0 && <p>No scores found. Invalid Page?</p>}
</div>
<div className="grid min-w-full grid-cols-1 divide-y divide-border">
{currentScores.scores.map((playerScore, index) => (
<LeaderboardScore key={index} score={playerScore} leaderboard={leaderboard} />
))}
</div>
<Pagination
mobilePagination={width < 768}
page={currentPage}
totalPages={Math.ceil(currentScores.metadata.total / currentScores.metadata.itemsPerPage)}
loadingPage={isLoading ? currentPage : undefined}
onPageChange={setCurrentPage}
/>
</Card>
</motion.div>
);
}

@ -1,294 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import { formatNumberWithCommas } from "@/common/number-utils";
import { CategoryScale, Chart, Legend, LinearScale, LineElement, PointElement, Title, Tooltip } from "chart.js";
import { Line } from "react-chartjs-2";
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
import { getDaysAgo, parseDate } from "@/common/time-utils";
import { useIsMobile } from "@/hooks/use-is-mobile";
Chart.register(LinearScale, CategoryScale, PointElement, LineElement, Title, Tooltip, Legend);
type AxisPosition = "left" | "right";
/**
* A ChartJS axis
*/
type Axis = {
id?: string;
position?: AxisPosition;
display?: boolean;
grid?: { color?: string; drawOnChartArea?: boolean };
title?: { display: boolean; text: string; color?: string };
ticks?: {
stepSize?: number;
};
reverse?: boolean;
};
/**
* A ChartJS dataset
*/
type Dataset = {
label: string;
data: (number | null)[]; // Allow null values for gaps
borderColor: string;
fill: boolean;
lineTension: number;
spanGaps: boolean;
yAxisID: string;
};
/**
* Generate an axis
*
* @param id the id of the axis
* @param reverse if the axis should be reversed
* @param display if the axis should be displayed
* @param position the position of the axis
* @param displayName the optional name to display for the axis
*/
const generateAxis = (
id: string,
reverse: boolean,
display: boolean,
position: AxisPosition,
displayName: string
): Axis => ({
id,
position,
display,
grid: {
drawOnChartArea: id === "y",
color: id === "y" ? "#252525" : "",
},
title: {
display: true,
text: displayName,
color: "#ffffff",
},
ticks: {
stepSize: 10,
},
reverse,
});
/**
* Generate a dataset
*
* @param label the label of the dataset
* @param data the data of the dataset
* @param borderColor the border color of the dataset
* @param yAxisID the ID of the y-axis
*/
const generateDataset = (label: string, data: (number | null)[], borderColor: string, yAxisID: string): Dataset => ({
label,
data,
borderColor,
fill: false,
lineTension: 0.5,
spanGaps: false, // Set to false, so we can allow gaps
yAxisID,
});
type DatasetConfig = {
title: string;
field: string;
color: string;
axisId: string;
axisConfig: {
reverse: boolean;
display: boolean;
hideOnMobile?: boolean;
displayName: string;
position: AxisPosition;
};
labelFormatter: (value: number) => string;
};
// Configuration array for datasets and axes with label formatters
const datasetConfig: DatasetConfig[] = [
{
title: "Rank",
field: "rank",
color: "#3EC1D3",
axisId: "y",
axisConfig: {
reverse: true,
display: true,
displayName: "Global Rank",
position: "left",
},
labelFormatter: (value: number) => `Rank #${formatNumberWithCommas(value)}`,
},
{
title: "Country Rank",
field: "countryRank",
color: "#FFEA00",
axisId: "y1",
axisConfig: {
reverse: true,
display: false,
displayName: "Country Rank",
position: "left",
},
labelFormatter: (value: number) => `Country Rank #${formatNumberWithCommas(value)}`,
},
{
title: "PP",
field: "pp",
color: "#606fff",
axisId: "y2",
axisConfig: {
reverse: false,
display: true,
hideOnMobile: true,
displayName: "PP",
position: "right",
},
labelFormatter: (value: number) => `PP ${formatNumberWithCommas(value)}pp`,
},
];
type Props = {
player: ScoreSaberPlayer;
};
export default function PlayerRankChart({ player }: Props) {
const isMobile = useIsMobile();
if (!player.statisticHistory || Object.keys(player.statisticHistory).length === 0) {
return (
<div className="flex justify-center">
<p>Unable to load player rank chart, missing data...</p>
</div>
);
}
const labels: string[] = [];
const histories: Record<string, (number | null)[]> = {
rank: [],
countryRank: [],
pp: [],
};
const statisticEntries = Object.entries(player.statisticHistory).sort(
([a], [b]) => parseDate(a).getTime() - parseDate(b).getTime()
);
let previousDate: Date | null = null;
// Create labels and history data
for (const [dateString, history] of statisticEntries) {
const currentDate = parseDate(dateString);
// Insert nulls for missing days
if (previousDate) {
const diffDays = Math.floor((currentDate.getTime() - previousDate.getTime()) / (1000 * 60 * 60 * 24));
for (let i = 1; i < diffDays; i++) {
labels.push(`${getDaysAgo(new Date(currentDate.getTime() - i * 24 * 60 * 60 * 1000))} days ago`);
datasetConfig.forEach(config => {
histories[config.field].push(null);
});
}
}
const daysAgo = getDaysAgo(currentDate);
labels.push(daysAgo === 0 ? "Today" : `${daysAgo} days ago`);
// stupid typescript crying wahh wahh wahh - https://youtu.be/hBEKgHDzm_s?si=ekOdMMdb-lFnA1Yz&t=11
datasetConfig.forEach(config => {
(histories as any)[config.field].push((history as any)[config.field] ?? null);
});
previousDate = currentDate;
}
// Dynamically create axes and datasets based on datasetConfig
const axes: Record<string, Axis> = {
x: {
grid: {
color: "#252525", // gray grid lines
},
reverse: false,
},
};
const datasets: Dataset[] = datasetConfig
.map(config => {
if (histories[config.field].some(value => value !== null)) {
axes[config.axisId] = generateAxis(
config.axisId,
config.axisConfig.reverse,
isMobile && config.axisConfig.hideOnMobile ? false : config.axisConfig.display,
config.axisConfig.position,
config.axisConfig.displayName
);
return generateDataset(config.title, histories[config.field], config.color, config.axisId);
}
return null;
})
.filter(Boolean) as Dataset[];
const options: any = {
maintainAspectRatio: false,
responsive: true,
interaction: {
mode: "index",
intersect: false,
},
scales: axes,
elements: {
point: {
radius: 0,
},
},
plugins: {
legend: {
position: "top" as const,
labels: {
color: "white",
},
},
tooltip: {
callbacks: {
label(context: any) {
const value = Number(context.parsed.y);
const config = datasetConfig.find(cfg => cfg.title === context.dataset.label);
return config?.labelFormatter(value) ?? "";
},
},
},
},
};
const data = {
labels,
datasets,
};
return (
<div className="block h-[320px] w-full relative">
<Line
className="max-w-[100%]"
options={options}
data={data}
plugins={[
{
id: "legend-padding",
beforeInit: (chart: any) => {
const originalFit = chart.legend.fit;
chart.legend.fit = function fit() {
originalFit.bind(chart.legend)();
this.height += 2;
};
},
},
]}
/>
</div>
);
}

61
next.config.mjs Normal file

@ -0,0 +1,61 @@
import { withSentryConfig } from "@sentry/nextjs";
import { format } from "@formkit/tempo";
import nextBuildId from "next-build-id";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file
const __dirname = path.dirname(__filename); // get the name of the directory
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
webpackMemoryOptimizations: true,
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "cdn.scoresaber.com",
port: "",
pathname: "/**",
},
],
},
env: {
NEXT_PUBLIC_BUILD_ID:
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",
timeZoneName: "short",
}),
NEXT_PUBLIC_BUILD_TIME_SHORT: format(new Date(), {
date: "short",
time: "short",
}),
},
};
export default withSentryConfig(nextConfig, {
org: "scoresaber-reloaded",
project: "frontend",
sentryUrl: "https://glitchtip.fascinated.cc/",
silent: !process.env.CI,
reactComponentAnnotation: {
enabled: true,
},
tunnelRoute: "/monitoring",
hideSourceMaps: true,
disableLogger: true,
sourcemaps: {
disable: true,
},
release: {
create: false,
finalize: false,
},
});

@ -1,28 +1,66 @@
{ {
"name": "ssr", "name": "scoresaber-reloadedv3",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"workspaces": {
"nohoist": [
"**/frontend",
"**/frontend/**"
],
"packages": [
"apps/*",
"packages/*"
]
},
"scripts": { "scripts": {
"build": "turbo run build", "dev": "next dev --turbo",
"clean": "turbo run clean", "build": "next build",
"dev": "turbo run dev", "start": "next start",
"format": "prettier --write \"**/*.{ts,tsx,md}\"", "lint": "next lint"
"lint": "turbo run lint", },
"test": "turbo run test" "dependencies": {
"@formkit/tempo": "^0.1.2",
"@heroicons/react": "^2.1.5",
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.1.2",
"@sentry/nextjs": "8",
"@tanstack/react-query": "^5.55.4",
"@trigger.dev/nextjs": "^3.0.8",
"@trigger.dev/react": "^3.0.8",
"@trigger.dev/sdk": "^3.0.8",
"@uidotdev/usehooks": "^2.4.1",
"canvas": "3.0.0-rc2",
"chart.js": "^4.4.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"comlink": "^4.4.1",
"dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7",
"extract-colors": "^4.0.8",
"framer-motion": "^11.5.4",
"js-cookie": "^3.0.5",
"ky": "^1.7.2",
"lucide-react": "^0.447.0",
"mongoose": "^8.7.0",
"next": "15.0.0-rc.0",
"next-build-id": "^3.0.0",
"next-themes": "^0.3.0",
"react": "19.0.0-rc-3edc000d-20240926",
"react-chartjs-2": "^5.2.0",
"react-dom": "19.0.0-rc-3edc000d-20240926",
"react-hook-form": "^7.53.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.2.5", "@types/js-cookie": "^3.0.6",
"turbo": "^2.0.3" "@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.14",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}, },
"packageManager": "pnpm@9.12.0" "trigger.dev": {
} "endpointId": "scoresaber-reloaded-KB0Z"
}
}

1533
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

@ -1,3 +0,0 @@
packages:
- "apps/*"
- "packages/*"

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Before

Width:  |  Height:  |  Size: 841 B

After

Width:  |  Height:  |  Size: 841 B

Before

Width:  |  Height:  |  Size: 132 B

After

Width:  |  Height:  |  Size: 132 B

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 766 B

After

Width:  |  Height:  |  Size: 766 B

Before

Width:  |  Height:  |  Size: 659 B

After

Width:  |  Height:  |  Size: 659 B

Before

Width:  |  Height:  |  Size: 604 B

After

Width:  |  Height:  |  Size: 604 B

Before

Width:  |  Height:  |  Size: 121 B

After

Width:  |  Height:  |  Size: 121 B

Before

Width:  |  Height:  |  Size: 522 B

After

Width:  |  Height:  |  Size: 522 B

Before

Width:  |  Height:  |  Size: 445 B

After

Width:  |  Height:  |  Size: 445 B

Before

Width:  |  Height:  |  Size: 320 B

After

Width:  |  Height:  |  Size: 320 B

Before

Width:  |  Height:  |  Size: 909 B

After

Width:  |  Height:  |  Size: 909 B

Before

Width:  |  Height:  |  Size: 109 B

After

Width:  |  Height:  |  Size: 109 B

Before

Width:  |  Height:  |  Size: 554 B

After

Width:  |  Height:  |  Size: 554 B

Before

Width:  |  Height:  |  Size: 311 B

After

Width:  |  Height:  |  Size: 311 B

Before

Width:  |  Height:  |  Size: 179 B

After

Width:  |  Height:  |  Size: 179 B

Before

Width:  |  Height:  |  Size: 214 B

After

Width:  |  Height:  |  Size: 214 B

Before

Width:  |  Height:  |  Size: 339 B

After

Width:  |  Height:  |  Size: 339 B

Before

Width:  |  Height:  |  Size: 324 B

After

Width:  |  Height:  |  Size: 324 B

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 282 B

Before

Width:  |  Height:  |  Size: 127 B

After

Width:  |  Height:  |  Size: 127 B

Before

Width:  |  Height:  |  Size: 254 B

After

Width:  |  Height:  |  Size: 254 B

Before

Width:  |  Height:  |  Size: 105 B

After

Width:  |  Height:  |  Size: 105 B

Before

Width:  |  Height:  |  Size: 326 B

After

Width:  |  Height:  |  Size: 326 B

Before

Width:  |  Height:  |  Size: 651 B

After

Width:  |  Height:  |  Size: 651 B

Before

Width:  |  Height:  |  Size: 127 B

After

Width:  |  Height:  |  Size: 127 B

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 132 B

After

Width:  |  Height:  |  Size: 132 B

Before

Width:  |  Height:  |  Size: 810 B

After

Width:  |  Height:  |  Size: 810 B

Before

Width:  |  Height:  |  Size: 792 B

After

Width:  |  Height:  |  Size: 792 B

Before

Width:  |  Height:  |  Size: 287 B

After

Width:  |  Height:  |  Size: 287 B

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before

Width:  |  Height:  |  Size: 206 B

After

Width:  |  Height:  |  Size: 206 B

Before

Width:  |  Height:  |  Size: 139 B

After

Width:  |  Height:  |  Size: 139 B

Before

Width:  |  Height:  |  Size: 377 B

After

Width:  |  Height:  |  Size: 377 B

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 359 B

After

Width:  |  Height:  |  Size: 359 B

Before

Width:  |  Height:  |  Size: 561 B

After

Width:  |  Height:  |  Size: 561 B

Before

Width:  |  Height:  |  Size: 523 B

After

Width:  |  Height:  |  Size: 523 B

Before

Width:  |  Height:  |  Size: 286 B

After

Width:  |  Height:  |  Size: 286 B

Before

Width:  |  Height:  |  Size: 344 B

After

Width:  |  Height:  |  Size: 344 B

Before

Width:  |  Height:  |  Size: 135 B

After

Width:  |  Height:  |  Size: 135 B

Before

Width:  |  Height:  |  Size: 123 B

After

Width:  |  Height:  |  Size: 123 B

Before

Width:  |  Height:  |  Size: 717 B

After

Width:  |  Height:  |  Size: 717 B

Before

Width:  |  Height:  |  Size: 263 B

After

Width:  |  Height:  |  Size: 263 B

Before

Width:  |  Height:  |  Size: 215 B

After

Width:  |  Height:  |  Size: 215 B

Before

Width:  |  Height:  |  Size: 286 B

After

Width:  |  Height:  |  Size: 286 B

Before

Width:  |  Height:  |  Size: 135 B

After

Width:  |  Height:  |  Size: 135 B

Before

Width:  |  Height:  |  Size: 419 B

After

Width:  |  Height:  |  Size: 419 B

Before

Width:  |  Height:  |  Size: 362 B

After

Width:  |  Height:  |  Size: 362 B

Before

Width:  |  Height:  |  Size: 444 B

After

Width:  |  Height:  |  Size: 444 B

Before

Width:  |  Height:  |  Size: 260 B

After

Width:  |  Height:  |  Size: 260 B

Before

Width:  |  Height:  |  Size: 641 B

After

Width:  |  Height:  |  Size: 641 B

Before

Width:  |  Height:  |  Size: 486 B

After

Width:  |  Height:  |  Size: 486 B

Before

Width:  |  Height:  |  Size: 361 B

After

Width:  |  Height:  |  Size: 361 B

Before

Width:  |  Height:  |  Size: 105 B

After

Width:  |  Height:  |  Size: 105 B

Before

Width:  |  Height:  |  Size: 530 B

After

Width:  |  Height:  |  Size: 530 B

Before

Width:  |  Height:  |  Size: 163 B

After

Width:  |  Height:  |  Size: 163 B

Before

Width:  |  Height:  |  Size: 501 B

After

Width:  |  Height:  |  Size: 501 B

Before

Width:  |  Height:  |  Size: 459 B

After

Width:  |  Height:  |  Size: 459 B

Before

Width:  |  Height:  |  Size: 368 B

After

Width:  |  Height:  |  Size: 368 B

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 106 B

After

Width:  |  Height:  |  Size: 106 B

Before

Width:  |  Height:  |  Size: 323 B

After

Width:  |  Height:  |  Size: 323 B

Some files were not shown because too many files have changed in this diff Show More