@ -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
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: scoresaber-reloaded-frontend
|
||||
name: scoresaber-reloaded
|
||||
namespace: public-services
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: scoresaber-reloaded-frontend
|
||||
app: scoresaber-reloaded
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: scoresaber-reloaded-frontend
|
||||
app: scoresaber-reloaded
|
||||
spec:
|
||||
containers:
|
||||
- name: scoresaber-reloaded-frontend-container
|
||||
image: git.fascinated.cc/fascinated/scoresaber-reloaded-frontend:latest
|
||||
- name: scoresaber-reloaded-container
|
||||
image: git.fascinated.cc/fascinated/scoresaber-reloaded:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 3000
|
@ -2,7 +2,7 @@
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: scoresaber-reloaded-frontend-ingress
|
||||
name: scoresaber-reloaded-ingress
|
||||
namespace: public-services
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: traefik-external
|
||||
@ -18,7 +18,7 @@ spec:
|
||||
- name: compress
|
||||
namespace: traefik
|
||||
services:
|
||||
- name: scoresaber-reloaded-frontend-service
|
||||
- name: scoresaber-reloaded-service
|
||||
port: 3000
|
||||
tls:
|
||||
secretName: fascinated-cc
|
@ -3,7 +3,7 @@ apiVersion: bitnami.com/v1alpha1
|
||||
kind: SealedSecret
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
name: ssr-frontend-secret
|
||||
name: ssr-secret
|
||||
namespace: public-services
|
||||
spec:
|
||||
encryptedData:
|
||||
@ -15,6 +15,6 @@ spec:
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
name: ssr-frontend-secret
|
||||
name: ssr-secret
|
||||
namespace: public-services
|
||||
type: Opaque
|
@ -2,7 +2,7 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: scoresaber-reloaded-frontend-service
|
||||
name: scoresaber-reloaded-service
|
||||
namespace: public-services
|
||||
spec:
|
||||
type: ClusterIP
|
||||
@ -10,4 +10,4 @@ spec:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
selector:
|
||||
app: scoresaber-reloaded-frontend
|
||||
app: scoresaber-reloaded
|
@ -1,4 +1,4 @@
|
||||
name: "Deploy Frontend"
|
||||
name: "Deploy"
|
||||
|
||||
on:
|
||||
push:
|
||||
@ -26,11 +26,10 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./apps/frontend/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
git.fascinated.cc/fascinated/scoresaber-reloaded-frontend:${{ github.sha }}
|
||||
git.fascinated.cc/fascinated/scoresaber-reloaded-frontend:latest
|
||||
git.fascinated.cc/fascinated/scoresaber-reloaded:${{ github.sha }}
|
||||
git.fascinated.cc/fascinated/scoresaber-reloaded:latest
|
||||
build-args: |
|
||||
GIT_REV=${{ gitea.sha }}
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
@ -50,9 +49,9 @@ jobs:
|
||||
action: deploy
|
||||
namespace: public-services
|
||||
manifests: |
|
||||
.gitea/kubernetes/frontend/sealed-secrets.yaml
|
||||
.gitea/kubernetes/frontend/deployment.yaml
|
||||
.gitea/kubernetes/frontend/service.yaml
|
||||
.gitea/kubernetes/frontend/ingress.yaml
|
||||
.gitea/kubernetes/sealed-secrets.yaml
|
||||
.gitea/kubernetes/deployment.yaml
|
||||
.gitea/kubernetes/service.yaml
|
||||
.gitea/kubernetes/ingress.yaml
|
||||
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.
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
@ -33,6 +34,8 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
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"
|
||||
}
|
||||
}
|
41
apps/frontend/.gitignore
vendored
@ -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"
|
||||
}
|
||||
}
|
7567
apps/frontend/pnpm-lock.yaml
generated
@ -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,
|
||||
},
|
||||
});
|
80
package.json
@ -1,28 +1,66 @@
|
||||
{
|
||||
"name": "ssr",
|
||||
"name": "scoresaber-reloadedv3",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"workspaces": {
|
||||
"nohoist": [
|
||||
"**/frontend",
|
||||
"**/frontend/**"
|
||||
],
|
||||
"packages": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"clean": "turbo run clean",
|
||||
"dev": "turbo run dev",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||
"lint": "turbo run lint",
|
||||
"test": "turbo run test"
|
||||
"dev": "next dev --turbo",
|
||||
"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",
|
||||
"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": {
|
||||
"prettier": "^3.2.5",
|
||||
"turbo": "^2.0.3"
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@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
@ -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 |