Initial commit

This commit is contained in:
CallMeVerity
2026-06-03 00:44:48 +01:00
commit 3369f22f69
36 changed files with 3419 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules/
dist/
.env
*.db
*.db-journal
*.db-shm
*.db-wal
backend/data/
frontend/dist/
.DS_Store
+21
View File
@@ -0,0 +1,21 @@
FROM oven/bun:1 AS frontend-build
WORKDIR /app/frontend
COPY frontend/package.json frontend/bun.lock ./
RUN bun install --frozen-lockfile
COPY frontend/ ./
RUN bun run build
FROM oven/bun:1
WORKDIR /app
COPY backend/package.json backend/bun.lock ./
RUN bun install --production
COPY backend/src/ ./src/
COPY --from=frontend-build /app/frontend/dist /app/public
ENV PORT=3001
EXPOSE 3001
CMD ["bun", "run", "src/index.ts"]
+74
View File
@@ -0,0 +1,74 @@
# surf.nathan.rip
Momentum Mod surf replay hosting - video + `.mtv` replay files with automatic map tier badges, stage splits, and PB history.
## Architecture
| Layer | Stack |
|-------|-------|
| Frontend | React + Vite + Tailwind CSS v4 |
| Backend | ElysiaJS (Bun) |
| Storage | RustFS (S3-compatible) |
| Database | SQLite |
## How it works
1. Upload a video + `.mtv` replay file via the auth-gated upload page
2. Backend parses the `.mtv` binary header for metadata (map, player, time, Steam ID, stage splits)
3. Map tier and thumbnail are fetched from the Momentum Mod API
4. Video uploads go directly to RustFS via presigned URLs (no size limit)
5. If a better time is uploaded for the same map+player, the PB is updated and the old run is archived as a previous PB
## Setup
```bash
# Backend
cd backend
cp .env.example .env # edit with your credentials
bun install
bun run dev
# Frontend
cd frontend
bun install
bun run dev
```
The frontend dev server proxies `/api` to `localhost:3001`.
## Environment variables
| Variable | Required | Description |
|----------|----------|-------------|
| `ADMIN_TOKEN` | Yes | Bearer token for upload/delete auth |
| `RUSTFS_ENDPOINT` | No | S3 endpoint (default: `http://localhost:9000`) |
| `RUSTFS_ACCESS_KEY` | No | S3 access key |
| `RUSTFS_SECRET_KEY` | No | S3 secret key |
| `RUSTFS_BUCKET` | No | S3 bucket name (default: `surf`) |
| `RUSTFS_PUBLIC` | No | Public read access (default: `true`) |
| `PORT` | No | Server port (default: `3001`) |
| `DB_PATH` | No | SQLite path (default: `./data/surf.db`) |
| `STATIC_DIR` | No | Frontend static files (default: `./public`) |
## RustFS setup
1. Create a bucket called `surf` in your RustFS instance
2. Set bucket to public read for direct video URLs
3. Configure CORS on the bucket to allow `PUT` from your frontend origin (required for presigned video uploads)
4. Create an IAM policy with `s3:ListBucket`, `s3:GetObject`, `s3:PutObject`, `s3:DeleteObject` on `surf/*`
## .mtv file format
195-byte binary header followed by JSON stats and LZMA-compressed replay data.
| Offset | Size | Field |
|--------|------|-------|
| 0x00 | 4 | Magic (`MMTV` = 0x56544D4D) |
| 0x04 | 4 | Version |
| 0x10 | 64 | Map name (null-terminated) |
| 0x50 | 41 | Map hash |
| 0x7B | 4 | Tick interval (float) |
| 0x7F | 8 | Steam ID (uint64) |
| 0x87 | 32 | Player name (null-terminated) |
| 0xA9 | 8 | Run time (double) |
| 0xB1 | 4 | Total ticks |
+13
View File
@@ -0,0 +1,13 @@
# RustFS (S3-compatible) configuration
RUSTFS_ENDPOINT=http://localhost:9000
RUSTFS_ACCESS_KEY=minioadmin
RUSTFS_SECRET_KEY=minioadmin
RUSTFS_BUCKET=surf
RUSTFS_PUBLIC=true
# Server
PORT=3001
DB_PATH=./data/surf.db
# Auth - required, protects all upload/delete endpoints
ADMIN_TOKEN=change-me-to-a-real-secret
+162
View File
@@ -0,0 +1,162 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "surfnathanrip-backend",
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/s3-request-presigner": "^3.700.0",
"@elysiajs/cors": "latest",
"@elysiajs/static": "latest",
"elysia": "latest",
},
"devDependencies": {
"bun-types": "latest",
},
},
},
"packages": {
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
"@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
"@aws-crypto/sha1-browser": ["@aws-crypto/sha1-browser@5.2.0", "", { "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg=="],
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1058.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-node": "^3.972.48", "@aws-sdk/middleware-bucket-endpoint": "^3.972.17", "@aws-sdk/middleware-expect-continue": "^3.972.14", "@aws-sdk/middleware-flexible-checksums": "^3.974.23", "@aws-sdk/middleware-location-constraint": "^3.972.11", "@aws-sdk/middleware-sdk-s3": "^3.972.44", "@aws-sdk/middleware-ssec": "^3.972.11", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-AfED3hhaBZ121NuiBImgnlF98kQRMk6hGPMGfj/Oo1hSaoMFRzM+N4nlICCasUSM2R8QaIRZRYGpZ3fy0ilGZQ=="],
"@aws-sdk/core": ["@aws-sdk/core@3.974.15", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@aws-sdk/xml-builder": "^3.972.26", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.5", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw=="],
"@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-P+QGozmXn2mZZI7sDgk+aUm+RTI61MPSFB+Ir2vjEjEbEsE4e7hYtzrDvAUxZy9ko81h53e11+F/GYlvwDkaOQ=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.41", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.43", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-login": "^3.972.45", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hvcgcwOiS0nb2XFb5Op1Pz/vYaWz5K8kKullziGpdNRuG0NwzRXseuPt2CoBqknHGaSPVesu1aOn2OcctEYdCA=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.48", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-ini": "^3.972.46", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-QIbtJP0olSLZ2ImEu636pP+7JJbPfaL3xSJIFXhu472CWuondCc4bGOa8OeyhOFet8z4H1D/ZFKXc39FboWwYA=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.41", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/token-providers": "3.1056.0", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ=="],
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-lbDmWuHenc+kiwCNrxz4MyN6nkxCWyTXPIWuspJN0ibziu+8CXci7vI1bK9MAkwy8cwJOEXNu0gBM5S0uTGRIg=="],
"@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.14", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-3TNFEVGO4sWZj9TEXOCZLzGEctXHnaO4fk2EQ8KVaboTbwHmEPEQrm17Xb9koImUIXEw0sgi2xtHjg7LuTS3rA=="],
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.23", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/crc64-nvme": "^3.972.9", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-4nPKARo2lfKvQGUt2fPA5NlS/mEohckdxpuC9ecbjVfj7B7NFFYHeTg+Bf5BEQwdn3yRfUIzFiEkPp8Yuaw3wA=="],
"@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hkfspNUP4criAH6ton6BGKgnm5dZx+7bUOy1YqlTfejDeUPAM23D81q/IX+hdlS3KUsfwGz5ADTqZWKBEUpf4A=="],
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-8HQsRg1NpX8vR4vNl1E8pyLnqZroq9VSL2vZQVSgBqp6wv6365LzYD08/c9FFh/9FTg7YRc7aTtEmXF0ir/pqg=="],
"@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-7PQvGNhtveKlvVqNahqWx5yrwxP7ecwAoB1dYBf8eKwfo2tzzCbNnW+q2nO3N066ktQaB4iBQbDRWtizm+amoQ=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg=="],
"@aws-sdk/s3-request-presigner": ["@aws-sdk/s3-request-presigner@3.1058.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-IRgNfn8U3zfsZ0JkpmwjS59R/XyHMHxpuwW6HVuJhik+FsbClhNkujEO0w1WqJvXrF4FX+7qIAwUrvlwNvaZ7Q=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.30", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-HULDLMVzkmTSEv6
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1056.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA=="],
"@aws-sdk/types": ["@aws-sdk/types@3.973.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg=="],
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="],
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.26", "", { "dependencies": { "@smithy/types": "^4.14.2", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g=="],
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
"@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
"@elysiajs/cors": ["@elysiajs/cors@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-FTCcbH35brTLigF1W7BYySRZomgI/dBEMK9BgK9RP9Nez7zmpGh4koL/Yr1BFv8nYz7CfhRvcM8d/c+XnwMaVQ=="],
"@elysiajs/static": ["@elysiajs/static@1.4.10", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-BN/3PZVWjqkL/6TM7RH69Pae89CfU0OqfEYPeBp2hB/GR5Euds39Wy+0ug9Vbvu++GG4tRRANf2QjaLnriJveg=="],
"@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="],
"@smithy/core": ["@smithy/core@3.24.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug=="],
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-xj8gq/bjFABAh6qWPSDCYcY3kzQIm4b561C+YnHH4zGq8rOgzQ3Shk+JGlpUxSd41UGiO6FkLdUCtNX1FAeHgg=="],
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g=="],
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-3fya8i7GrJilQouk4cZJKdy5k8MWQBpjfXrRNaXDedH8r779tr0jcxyH3+yoTmsluc2+vF4S343yFbnvu8ExDQ=="],
"@smithy/signature-v4": ["@smithy/signature-v4@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ=="],
"@smithy/types": ["@smithy/types@4.14.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ=="],
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"elysia": ["elysia@1.4.28", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="],
"exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
"fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="],
"fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="],
"file-type": ["file-type@22.0.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
"strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="],
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="],
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "surfnathanrip-backend",
"version": "1.0.0",
"scripts": {
"dev": "bun run --watch src/index.ts",
"build": "bun build src/index.ts --outdir dist --target bun",
"start": "bun run dist/index.js"
},
"dependencies": {
"elysia": "latest",
"@elysiajs/cors": "latest",
"@elysiajs/static": "latest",
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/s3-request-presigner": "^3.700.0"
},
"devDependencies": {
"bun-types": "latest"
}
}
+38
View File
@@ -0,0 +1,38 @@
import { Elysia, status } from "elysia";
import { cors } from "@elysiajs/cors";
import { videoRoutes } from "./routes/videos";
import { initDb } from "./services/db";
import { join } from "path";
import { existsSync } from "fs";
initDb();
const PORT = parseInt(process.env.PORT || "3001");
const STATIC_DIR = process.env.STATIC_DIR || "./public";
const app = new Elysia()
.use(cors())
.onError(({ error }) => {
console.error("Unhandled error:", error);
return status(500, { error: "Internal server error" });
})
.use(videoRoutes)
.get("/api/health", () => ({ status: "ok" }))
.get("*", ({ request }) => {
const url = new URL(request.url);
const pathname = url.pathname;
if (pathname.startsWith("/api")) return status(404, "Not found");
const filePath = join(STATIC_DIR, pathname);
if (existsSync(filePath)) return Bun.file(filePath);
const indexPath = join(STATIC_DIR, "index.html");
if (existsSync(indexPath)) return Bun.file(indexPath);
return status(404, "Not found");
})
.listen(PORT);
console.log(`surf.nathan.rip running on port ${PORT}`);
console.log(`Serving static files from ${STATIC_DIR}`);
+367
View File
@@ -0,0 +1,367 @@
import { Elysia, status } from "elysia";
import {
getAllVideos,
getVideoById,
deleteVideoById,
insertVideo,
updateThumbnailKey,
updateTier,
getVideoByMapAndPlayer,
updateVideoPB,
} from "../services/db";
import {
uploadObject,
deleteObject,
getObjectUrl,
getPresignedUploadUrl,
} from "../services/rustfs";
import { parseMtvFile } from "../services/mtv-parser";
import { getMapInfo } from "../services/momentum-api";
import type { VideoEntry, PreviousPB } from "../types/video";
const ADMIN_TOKEN = process.env.ADMIN_TOKEN;
if (!ADMIN_TOKEN) {
console.error(
"ADMIN_TOKEN environment variable is required. Set it to secure upload/delete endpoints.",
);
process.exit(1);
}
function sanitizeFileName(name: string): string {
return name
.replace(/\s+/g, "_")
.replace(/[^a-zA-Z0-9_.\-]/g, "")
.toLowerCase();
}
export const videoRoutes = new Elysia({ prefix: "/api/videos" })
.guard({
beforeHandle: ({ request }) => {
const isWrite =
request.method !== "GET" &&
request.method !== "HEAD" &&
request.method !== "OPTIONS";
if (isWrite) {
const auth = request.headers.get("authorization");
const token = auth?.startsWith("Bearer ")
? auth.slice(7)
: null;
if (!token || token !== ADMIN_TOKEN)
return status(401, "Unauthorized");
}
},
})
.get("/", () => {
const videos = getAllVideos();
return videos.map((v) => ({
...v,
thumbnailUrl: v.thumbnailUrl
? getObjectUrl(v.thumbnailUrl)
: undefined,
}));
})
.post("/upload-url", async ({ request }) => {
let formData: FormData;
try {
formData = await request.formData();
} catch {
return status(400, { error: "Failed to parse form data" });
}
const mtv = formData.get("mtv") as File | null;
const videoFileName = formData.get("videoFileName") as string | null;
const videoContentType =
(formData.get("videoContentType") as string | null) || "video/webm";
const runDate = formData.get("runDate") as string | null;
if (!mtv || !videoFileName) {
return status(400, {
error: "mtv file and videoFileName are required",
});
}
const mtvBuffer = await mtv.arrayBuffer();
const metadata = parseMtvFile(mtvBuffer);
if (!metadata) {
return status(400, {
error: "Invalid .mtv file: could not parse header",
});
}
const existing = getVideoByMapAndPlayer(
metadata.mapName,
metadata.steamId,
);
if (existing) {
if (existing.runTime <= metadata.runTime) {
return status(409, {
error: `PB not improved. Your current PB on ${metadata.mapName} is ${existing.runTime}, this run is ${metadata.runTime}`,
});
}
const deletes = [
deleteObject(existing.videoKey),
deleteObject(existing.mtvKey),
];
if (existing.thumbnailKey)
deletes.push(deleteObject(existing.thumbnailKey));
await Promise.all(deletes);
const previousPbs: PreviousPB[] = [
{ runTime: existing.runTime, createdAt: existing.createdAt },
...(existing.previousPbs ?? []),
];
const id = existing.id;
const createdAt =
runDate && !isNaN(Date.parse(runDate))
? new Date(runDate).toISOString()
: new Date().toISOString();
const sanitizedVideoName = sanitizeFileName(videoFileName);
const videoKey = `videos/${id}/${sanitizedVideoName}`;
const mtvKey = `videos/${id}/${sanitizeFileName(mtv.name)}`;
await uploadObject(
mtvKey,
Buffer.from(mtvBuffer),
"application/octet-stream",
);
let tier: number | null | undefined;
let mapId: number | null | undefined;
let thumbnailKey: string | undefined;
const mapInfo = await getMapInfo(metadata.mapName);
mapId = mapInfo.mapId ?? undefined;
tier = mapInfo.tier;
if (mapInfo.thumbnailUrl) {
thumbnailKey = `videos/${id}/thumbnail.jpg`;
try {
const imgRes = await fetch(mapInfo.thumbnailUrl);
if (imgRes.ok) {
const imgBuf = Buffer.from(await imgRes.arrayBuffer());
await uploadObject(thumbnailKey, imgBuf, "image/jpeg");
} else {
thumbnailKey = undefined;
}
} catch {
thumbnailKey = undefined;
}
}
const title = `${metadata.mapName} - ${metadata.playerName}`;
const entry: VideoEntry = {
id,
title,
description: "",
mapName: metadata.mapName,
playerName: metadata.playerName,
steamId: metadata.steamId,
runTime: metadata.runTime,
totalTicks: metadata.totalTicks,
tickInterval: metadata.tickInterval,
videoKey,
mtvKey,
thumbnailKey,
tier,
mapId,
jsonStats: metadata.jsonStats,
createdAt,
previousPbs,
};
updateVideoPB(id, entry, previousPbs);
const presignedUrls = {
video: await getPresignedUploadUrl(videoKey, videoContentType),
};
return {
...entry,
presignedUrls,
videoUrl: getObjectUrl(videoKey),
mtvUrl: getObjectUrl(mtvKey),
thumbnailUrl: thumbnailKey
? getObjectUrl(thumbnailKey)
: undefined,
};
}
const id = crypto.randomUUID();
const createdAt =
runDate && !isNaN(Date.parse(runDate))
? new Date(runDate).toISOString()
: new Date().toISOString();
const sanitizedVideoName = sanitizeFileName(videoFileName);
const videoKey = `videos/${id}/${sanitizedVideoName}`;
const mtvKey = `videos/${id}/${sanitizeFileName(mtv.name)}`;
await uploadObject(
mtvKey,
Buffer.from(mtvBuffer),
"application/octet-stream",
);
let tier: number | null | undefined;
let mapId: number | null | undefined;
let thumbnailKey: string | undefined;
const mapInfo = await getMapInfo(metadata.mapName);
mapId = mapInfo.mapId ?? undefined;
tier = mapInfo.tier;
if (mapInfo.thumbnailUrl) {
thumbnailKey = `videos/${id}/thumbnail.jpg`;
try {
const imgRes = await fetch(mapInfo.thumbnailUrl);
if (imgRes.ok) {
const imgBuf = Buffer.from(await imgRes.arrayBuffer());
await uploadObject(thumbnailKey, imgBuf, "image/jpeg");
} else {
thumbnailKey = undefined;
}
} catch {
thumbnailKey = undefined;
}
}
const title = `${metadata.mapName} - ${metadata.playerName}`;
const entry: VideoEntry = {
id,
title,
description: "",
mapName: metadata.mapName,
playerName: metadata.playerName,
steamId: metadata.steamId,
runTime: metadata.runTime,
totalTicks: metadata.totalTicks,
tickInterval: metadata.tickInterval,
videoKey,
mtvKey,
thumbnailKey,
tier,
mapId,
jsonStats: metadata.jsonStats,
createdAt,
};
insertVideo(entry);
const presignedUrls = {
video: await getPresignedUploadUrl(videoKey, videoContentType),
};
return {
...entry,
presignedUrls,
videoUrl: getObjectUrl(videoKey),
mtvUrl: getObjectUrl(mtvKey),
thumbnailUrl: thumbnailKey ? getObjectUrl(thumbnailKey) : undefined,
};
})
.post("/:id/complete", async ({ params: { id } }) => {
const video = getVideoById(id);
if (!video) return status(404, { error: "Not found" });
return {
...video,
videoUrl: getObjectUrl(video.videoKey),
mtvUrl: getObjectUrl(video.mtvKey),
thumbnailUrl: video.thumbnailKey
? getObjectUrl(video.thumbnailKey)
: undefined,
};
})
.get("/:id", ({ params: { id } }) => {
const video = getVideoById(id);
if (!video) return status(404, { error: "Not found" });
return {
...video,
videoUrl: getObjectUrl(video.videoKey),
mtvUrl: getObjectUrl(video.mtvKey),
thumbnailUrl: video.thumbnailKey
? getObjectUrl(video.thumbnailKey)
: undefined,
};
})
.post("/:id/refresh-info", async ({ params: { id } }) => {
const video = getVideoById(id);
if (!video) return status(404, { error: "Not found" });
const mapInfo = await getMapInfo(video.mapName);
let thumbnailKey = video.thumbnailKey;
if (!thumbnailKey && mapInfo.thumbnailUrl) {
thumbnailKey = `videos/${video.id}/thumbnail.jpg`;
try {
const imgRes = await fetch(mapInfo.thumbnailUrl);
if (imgRes.ok) {
const imgBuf = Buffer.from(await imgRes.arrayBuffer());
await uploadObject(thumbnailKey, imgBuf, "image/jpeg");
updateThumbnailKey(id, thumbnailKey);
} else {
thumbnailKey = undefined;
}
} catch {
thumbnailKey = undefined;
}
}
updateTier(id, mapInfo.tier);
return {
success: true,
tier: mapInfo.tier,
thumbnailUrl: thumbnailKey ? getObjectUrl(thumbnailKey) : undefined,
};
})
.post("/:id/refresh-thumbnail", async ({ params: { id } }) => {
const video = getVideoById(id);
if (!video) return status(404, { error: "Not found" });
const mapInfo = await getMapInfo(video.mapName);
if (!mapInfo.thumbnailUrl) {
return status(404, {
error: `No thumbnail found on Momentum Mod for ${video.mapName}`,
});
}
const thumbnailKey = `videos/${video.id}/thumbnail.jpg`;
try {
const imgRes = await fetch(mapInfo.thumbnailUrl);
if (!imgRes.ok)
return status(502, { error: "Failed to download thumbnail" });
const imgBuf = Buffer.from(await imgRes.arrayBuffer());
await uploadObject(thumbnailKey, imgBuf, "image/jpeg");
} catch {
return status(502, { error: "Failed to download thumbnail" });
}
updateThumbnailKey(id, thumbnailKey);
updateTier(id, mapInfo.tier);
return { success: true, thumbnailUrl: getObjectUrl(thumbnailKey) };
})
.delete("/:id", async ({ params: { id } }) => {
const video = getVideoById(id);
if (!video) return status(404, { error: "Not found" });
const deletes = [
deleteObject(video.videoKey),
deleteObject(video.mtvKey),
];
if (video.thumbnailKey) deletes.push(deleteObject(video.thumbnailKey));
await Promise.all(deletes);
deleteVideoById(id);
return { success: true };
});
+229
View File
@@ -0,0 +1,229 @@
import { Database } from "bun:sqlite";
import { mkdirSync } from "fs";
import { dirname } from "path";
import type { VideoEntry, VideoListItem, PreviousPB } from "../types/video";
const DB_PATH = process.env.DB_PATH || "./data/surf.db";
let db: Database | null = null;
export function initDb(): void {
if (db) return;
mkdirSync(dirname(DB_PATH), { recursive: true });
db = new Database(DB_PATH, { create: true });
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA strict_mode = ON");
db.exec(`
CREATE TABLE IF NOT EXISTS videos (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
map_name TEXT NOT NULL,
player_name TEXT NOT NULL,
steam_id TEXT NOT NULL,
run_time REAL NOT NULL,
total_ticks INTEGER NOT NULL,
tick_interval REAL NOT NULL,
video_key TEXT NOT NULL,
mtv_key TEXT NOT NULL,
thumbnail_key TEXT,
tier INTEGER,
json_stats TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_videos_map ON videos(map_name);
CREATE INDEX IF NOT EXISTS idx_videos_created ON videos(created_at DESC);
`);
const columns = db.prepare("PRAGMA table_info(videos)").all() as {
name: string;
}[];
if (!columns.some((col) => col.name === "tier")) {
db.exec("ALTER TABLE videos ADD COLUMN tier INTEGER");
}
if (!columns.some((col) => col.name === "map_id")) {
db.exec("ALTER TABLE videos ADD COLUMN map_id INTEGER");
}
if (!columns.some((col) => col.name === "rank")) {
db.exec("ALTER TABLE videos ADD COLUMN rank INTEGER");
}
if (!columns.some((col) => col.name === "rank_updated_at")) {
db.exec("ALTER TABLE videos ADD COLUMN rank_updated_at TEXT");
}
if (!columns.some((col) => col.name === "previous_pbs")) {
db.exec("ALTER TABLE videos ADD COLUMN previous_pbs TEXT");
}
}
function getDb(): Database {
if (!db) initDb();
return db!;
}
export function insertVideo(entry: VideoEntry): void {
const d = getDb();
d.prepare(
`INSERT INTO videos (id, title, description, map_name, player_name, steam_id, run_time, total_ticks, tick_interval, video_key, mtv_key, thumbnail_key, tier, map_id, json_stats, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
entry.id,
entry.title,
entry.description,
entry.mapName,
entry.playerName,
entry.steamId,
entry.runTime,
entry.totalTicks,
entry.tickInterval,
entry.videoKey,
entry.mtvKey,
entry.thumbnailKey ?? null,
entry.tier ?? null,
entry.mapId ?? null,
entry.jsonStats ?? null,
entry.createdAt,
);
}
export function getAllVideos(): VideoListItem[] {
const d = getDb();
return d
.prepare(
`SELECT id, title, map_name, player_name, run_time, tier, thumbnail_key, created_at
FROM videos ORDER BY created_at DESC`,
)
.all()
.map(rowToListItem);
}
export function getVideoById(id: string): VideoEntry | null {
const d = getDb();
const row = d.prepare(`SELECT * FROM videos WHERE id = ?`).get(id);
return row ? rowToEntry(row as any) : null;
}
export function deleteVideoById(id: string): boolean {
const d = getDb();
const result = d.prepare(`DELETE FROM videos WHERE id = ?`).run(id);
return result.changes > 0;
}
function rowToListItem(row: any): VideoListItem {
return {
id: row.id,
title: row.title,
mapName: row.map_name,
playerName: row.player_name,
runTime: row.run_time,
tier: row.tier ?? undefined,
thumbnailUrl: row.thumbnail_key || undefined,
createdAt: row.created_at,
previousPbs: row.previous_pbs
? JSON.parse(row.previous_pbs)
: undefined,
};
}
function rowToEntry(row: any): VideoEntry {
return {
id: row.id,
title: row.title,
description: row.description,
mapName: row.map_name,
playerName: row.player_name,
steamId: row.steam_id,
runTime: row.run_time,
totalTicks: row.total_ticks,
tickInterval: row.tick_interval,
videoKey: row.video_key,
mtvKey: row.mtv_key,
thumbnailKey: row.thumbnail_key ?? undefined,
tier: row.tier ?? undefined,
mapId: row.map_id ?? undefined,
jsonStats: row.json_stats ?? undefined,
createdAt: row.created_at,
previousPbs: row.previous_pbs
? JSON.parse(row.previous_pbs)
: undefined,
};
}
export function updateThumbnailKey(
id: string,
thumbnailKey: string | null,
): boolean {
const d = getDb();
const result = d
.prepare("UPDATE videos SET thumbnail_key = ? WHERE id = ?")
.run(thumbnailKey, id);
return result.changes > 0;
}
export function getVideoByMapAndPlayer(
mapName: string,
steamId: string,
): VideoEntry | null {
const d = getDb();
const row = d
.prepare(`SELECT * FROM videos WHERE map_name = ? AND steam_id = ?`)
.get(mapName, steamId);
return row ? rowToEntry(row as any) : null;
}
export function updateVideoPB(
id: string,
entry: VideoEntry,
previousPbs: PreviousPB[],
): void {
const d = getDb();
d.prepare(
`UPDATE videos SET
title = ?,
description = ?,
map_name = ?,
player_name = ?,
steam_id = ?,
run_time = ?,
total_ticks = ?,
tick_interval = ?,
video_key = ?,
mtv_key = ?,
thumbnail_key = ?,
tier = ?,
map_id = ?,
json_stats = ?,
created_at = ?,
previous_pbs = ?
WHERE id = ?`,
).run(
entry.title,
entry.description,
entry.mapName,
entry.playerName,
entry.steamId,
entry.runTime,
entry.totalTicks,
entry.tickInterval,
entry.videoKey,
entry.mtvKey,
entry.thumbnailKey ?? null,
entry.tier ?? null,
entry.mapId ?? null,
entry.jsonStats ?? null,
entry.createdAt,
JSON.stringify(previousPbs),
id,
);
}
export function updateTier(id: string, tier: number | null): boolean {
const d = getDb();
const result = d
.prepare("UPDATE videos SET tier = ? WHERE id = ?")
.run(tier, id);
return result.changes > 0;
}
+114
View File
@@ -0,0 +1,114 @@
const MOMENTUM_API = "https://api.momentum-mod.org/v1";
interface MomentumMapThumbnail {
id: string;
small: string;
medium: string;
large: string;
xl: string;
}
interface MomentumMapLeaderboard {
gamemode: number;
trackType: number;
trackNum: number;
style: number;
tier: number | null;
tags: number[];
type: number;
linear: boolean | null;
}
interface MomentumMapInfo {
description: string;
youtubeID: string;
creationDate: string;
approvedDate: string;
requiredGames: number[];
}
interface MomentumMap {
id: number;
name: string;
thumbnail: MomentumMapThumbnail | null;
images: MomentumMapThumbnail[];
info: MomentumMapInfo | null;
leaderboards: MomentumMapLeaderboard[];
}
interface MomentumMapsResponse {
totalCount: number;
returnCount: number;
data: MomentumMap[];
}
export interface MapInfo {
mapId: number | null;
thumbnailUrl: string | null;
tier: number | null;
mapDate: string | null;
}
const mapInfoCache = new Map<string, MapInfo>();
function extractSurfTier(
leaderboards: MomentumMapLeaderboard[],
): number | null {
const mainLeaderboard = leaderboards.find(
(lb) => lb.gamemode === 1 && lb.trackType === 0 && lb.style === 0,
);
return mainLeaderboard?.tier ?? null;
}
export async function getMapInfo(mapName: string): Promise<MapInfo> {
const cached = mapInfoCache.get(mapName);
if (cached) return cached;
try {
const url = `${MOMENTUM_API}/maps?search=${encodeURIComponent(mapName)}&take=1&expand=info,leaderboards`;
const res = await fetch(url);
if (!res.ok) {
const info: MapInfo = {
mapId: null,
thumbnailUrl: null,
tier: null,
mapDate: null,
};
mapInfoCache.set(mapName, info);
return info;
}
const json: MomentumMapsResponse = await res.json();
const map = json.data?.[0];
if (!map || map.name !== mapName) {
const info: MapInfo = {
mapId: null,
thumbnailUrl: null,
tier: null,
mapDate: null,
};
mapInfoCache.set(mapName, info);
return info;
}
const thumbnailUrl =
map.thumbnail?.small ?? map.images?.[0]?.small ?? null;
const tier = extractSurfTier(map.leaderboards ?? []);
const mapDate = map.info?.creationDate ?? null;
const info: MapInfo = { mapId: map.id, thumbnailUrl, tier, mapDate };
mapInfoCache.set(mapName, info);
return info;
} catch {
const info: MapInfo = {
mapId: null,
thumbnailUrl: null,
tier: null,
mapDate: null,
};
mapInfoCache.set(mapName, info);
return info;
}
}
export async function getMapThumbnail(mapName: string): Promise<string | null> {
const info = await getMapInfo(mapName);
return info.thumbnailUrl;
}
+97
View File
@@ -0,0 +1,97 @@
import type { MtvHeader, MtvMetadata } from "../types/video";
const MTV_MAGIC = 0x56544d4d;
const HEADER_SIZE = 0xc3;
function readNullTerminatedString(buffer: ArrayBuffer, offset: number, maxLen: number): string {
const view = new Uint8Array(buffer, offset, maxLen);
let end = 0;
while (end < maxLen && view[end] !== 0) end++;
return new TextDecoder().decode(view.slice(0, end));
}
export function parseMtvHeader(buffer: ArrayBuffer): MtvHeader | null {
if (buffer.byteLength < HEADER_SIZE) return null;
const view = new DataView(buffer);
const magic = view.getUint32(0, true);
if (magic !== MTV_MAGIC) return null;
const version = view.getUint32(4, true);
const unknown08 = view.getFloat32(8, true);
const unknown0c = view.getUint32(0x0c, true);
const mapName = readNullTerminatedString(buffer, 0x10, 64);
const mapHash = readNullTerminatedString(buffer, 0x50, 41);
const unknown79 = view.getUint8(0x79);
const compression = view.getUint8(0x7a);
const tickInterval = view.getFloat32(0x7b, true);
const steamId = view.getBigUint64(0x7f, true);
const playerName = readNullTerminatedString(buffer, 0x87, 32);
const unknownA7 = view.getUint8(0xa7);
const unknownA8 = view.getUint8(0xa8);
const runTime = view.getFloat64(0xa9, true);
const totalTicks = view.getUint32(0xb1, true);
const seekEntryCount = view.getUint32(0xb5, true);
const baselineCount = view.getUint32(0xb9, true);
const entityTypeCount = view.getUint8(0xbd);
const tempEntityCount = view.getUint8(0xbe);
const stringTableCount = view.getUint8(0xbf);
return {
magic,
version,
unknown08,
unknown0c,
mapName,
mapHash,
unknown79,
compression,
tickInterval,
steamId,
playerName,
unknownA7,
unknownA8,
runTime,
totalTicks,
seekEntryCount,
baselineCount,
entityTypeCount,
tempEntityCount,
stringTableCount,
};
}
export function parseMtvFile(buffer: ArrayBuffer): MtvMetadata | null {
const header = parseMtvHeader(buffer);
if (!header) return null;
let jsonStats: string | undefined;
if (buffer.byteLength > HEADER_SIZE + 4) {
const view = new DataView(buffer);
const jsonLen = view.getUint32(HEADER_SIZE, true);
if (jsonLen > 0 && buffer.byteLength >= HEADER_SIZE + 4 + jsonLen) {
const jsonBytes = new Uint8Array(buffer, HEADER_SIZE + 4, jsonLen - 1);
jsonStats = new TextDecoder().decode(jsonBytes);
}
}
return {
mapName: header.mapName,
mapHash: header.mapHash,
playerName: header.playerName,
steamId: header.steamId.toString(),
runTime: header.runTime,
totalTicks: header.totalTicks,
tickInterval: header.tickInterval,
version: header.version,
jsonStats,
};
}
export function formatRunTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toFixed(3).padStart(6, "0")}`;
}
+82
View File
@@ -0,0 +1,82 @@
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const RUSTFS_ENDPOINT = process.env.RUSTFS_ENDPOINT || "http://localhost:9000";
const RUSTFS_ACCESS_KEY = process.env.RUSTFS_ACCESS_KEY || "minioadmin";
const RUSTFS_SECRET_KEY = process.env.RUSTFS_SECRET_KEY || "minioadmin";
const RUSTFS_BUCKET = process.env.RUSTFS_BUCKET || "surf";
const RUSTFS_PUBLIC = (process.env.RUSTFS_PUBLIC || "true") === "true";
let client: S3Client | null = null;
function getRustFsClient(): S3Client {
if (!client) {
client = new S3Client({
endpoint: RUSTFS_ENDPOINT,
region: "us-east-1",
credentials: {
accessKeyId: RUSTFS_ACCESS_KEY,
secretAccessKey: RUSTFS_SECRET_KEY,
},
forcePathStyle: true,
});
}
return client;
}
function getPublicUrl(key: string): string {
if (RUSTFS_PUBLIC) {
return `${RUSTFS_ENDPOINT}/${RUSTFS_BUCKET}/${key}`;
}
return "";
}
export function getObjectUrl(key: string): string {
const publicUrl = getPublicUrl(key);
if (publicUrl) return publicUrl;
return `/api/videos/file/${encodeURIComponent(key)}`;
}
export async function uploadObject(
key: string,
body: Buffer,
contentType: string,
): Promise<void> {
const s3 = getRustFsClient();
await s3.send(
new PutObjectCommand({
Bucket: RUSTFS_BUCKET,
Key: key,
Body: body,
ContentType: contentType,
}),
);
}
export async function deleteObject(key: string): Promise<void> {
const s3 = getRustFsClient();
await s3.send(
new DeleteObjectCommand({
Bucket: RUSTFS_BUCKET,
Key: key,
}),
);
}
export async function getPresignedUploadUrl(
key: string,
contentType: string,
expiresInSeconds = 3600,
): Promise<string> {
const s3 = getRustFsClient();
const command = new PutObjectCommand({
Bucket: RUSTFS_BUCKET,
Key: key,
ContentType: contentType,
});
return getSignedUrl(s3, command, { expiresIn: expiresInSeconds });
}
+71
View File
@@ -0,0 +1,71 @@
export interface MtvHeader {
magic: number;
version: number;
unknown08: number;
unknown0c: number;
mapName: string;
mapHash: string;
unknown79: number;
compression: number;
tickInterval: number;
steamId: bigint;
playerName: string;
unknownA7: number;
unknownA8: number;
runTime: number;
totalTicks: number;
seekEntryCount: number;
baselineCount: number;
entityTypeCount: number;
tempEntityCount: number;
stringTableCount: number;
}
export interface MtvMetadata {
mapName: string;
mapHash: string;
playerName: string;
steamId: string;
runTime: number;
totalTicks: number;
tickInterval: number;
version: number;
jsonStats?: string;
}
export interface PreviousPB {
runTime: number;
createdAt: string;
}
export interface VideoEntry {
id: string;
title: string;
description: string;
mapName: string;
playerName: string;
steamId: string;
runTime: number;
totalTicks: number;
tickInterval: number;
videoKey: string;
mtvKey: string;
thumbnailKey?: string;
tier?: number | null;
mapId?: number | null;
jsonStats?: string;
createdAt: string;
previousPbs?: PreviousPB[];
}
export interface VideoListItem {
id: string;
title: string;
mapName: string;
playerName: string;
runTime: number;
tier?: number | null;
thumbnailUrl?: string;
createdAt: string;
previousPbs?: PreviousPB[];
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"types": ["bun-types"],
},
"include": ["src"],
}
+356
View File
@@ -0,0 +1,356 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "surfnathanrip-frontend",
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0",
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.5.0",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.4",
"tailwindcss": "^4.1.0",
"typescript": "^5.8.0",
"vite": "^6.3.0",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.61.0", "", { "os": "android", "cpu": "arm" }, "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.61.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.61.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.61.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.61.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.61.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.61.0", "", { "os": "linux", "cpu": "arm" }, "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.61.0", "", { "os": "linux", "cpu": "arm" }, "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.61.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.61.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.61.0", "", { "os": "linux", "cpu": "none" }, "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.61.0", "", { "os": "linux", "cpu": "none" }, "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.61.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.61.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.61.0", "", { "os": "linux", "cpu": "none" }, "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.61.0", "", { "os": "linux", "cpu": "none" }, "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.61.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.61.0", "", { "os": "linux", "cpu": "x64" }, "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.61.0", "", { "os": "linux", "cpu": "x64" }, "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.61.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.61.0", "", { "os": "none", "cpu": "arm64" }, "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.61.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.61.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.61.0", "", { "os": "win32", "cpu": "x64" }, "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.61.0", "", { "os": "win32", "cpu": "x64" }, "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.0", "", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/react": ["@types/react@19.2.16", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"autoprefixer": ["autoprefixer@10.5.0", "", { "dependencies": { "browserslist": "^4.28.2", "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.33", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.364", "", {}, "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw=="],
"enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-router": ["react-router@7.16.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A=="],
"react-router-dom": ["react-router-dom@7.16.0", "", { "dependencies": { "react-router": "7.16.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA=="],
"rollup": ["rollup@4.61.0", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.61.0", "@rollup/rollup-android-arm64": "4.61.0", "@rollup/rollup-darwin-arm64": "4.61.0", "@rollup/rollup-darwin-x64": "4.61.0", "@rollup/rollup-freebsd-arm64": "4.61.0", "@rollup/rollup-freebsd-x64": "4.61.0", "@rollup/rollup-linux-arm-gnueabihf": "4.61.0", "@rollup/rollup-linux-arm-musleabihf": "4.61.0", "@rollup/rollup-linux-arm64-gnu": "4.61.0", "@rollup/rollup-linux-arm64-musl": "4.61.0", "@rollup/rollup-linux-loong64-gnu": "4.61.0", "@rollup/rollup-linux-loong64-musl": "4.61.0", "@rollup/rollup-linux-ppc64-gnu": "4.61.0", "@rollup/rollup-linux-ppc64-musl": "4.61.0", "@rollup/rollup-linux-riscv64-gnu": "4.61.0", "@rollup/rollup-linux-riscv64-musl": "4.61.0", "@rollup/rollup-linux-s390x-gnu": "4.61.0", "@rollup/rollup-linux-x64-gnu": "4.61.0", "@rollup/rollup-linux-x64-musl": "4.61.0", "@rollup/rollup-openbsd-x64": "4.61.0", "@rollup/rollup-openharmony-arm64": "4.61.0", "@rollup/rollup-win32-arm64-msvc": "4.61.0", "@rollup/rollup-win32-ia32-msvc": "4.61.0", "@rollup/rollup-win32-x64-gnu": "4.61.0", "@rollup/rollup-win32-x64-msvc": "4.61.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
}
}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>surf.nathan.rip</title>
</head>
<body class="bg-gray-950 text-gray-100 antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{
"name": "surfnathanrip-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.5.0",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.4",
"tailwindcss": "^4.1.0",
"@tailwindcss/vite": "^4.1.0",
"typescript": "^5.8.0",
"vite": "^6.3.0"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#0ea5e9"/>
<path d="M6 22 Q10 8 16 16 Q22 24 26 10" stroke="white" stroke-width="3" fill="none" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 247 B

+19
View File
@@ -0,0 +1,19 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Layout from "./components/Layout";
import Home from "./pages/Home";
import VideoDetail from "./pages/VideoDetail";
import Upload from "./pages/Upload";
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route index element={<Home />} />
<Route path="video/:id" element={<VideoDetail />} />
<Route path="upload" element={<Upload />} />
</Route>
</Routes>
</BrowserRouter>
);
}
+100
View File
@@ -0,0 +1,100 @@
import type { VideoListItem, VideoDetail } from "../types/video";
const API_BASE = "/api";
function getAuthToken(): string | null {
return localStorage.getItem("admin_token");
}
function authHeaders(): HeadersInit {
const token = getAuthToken();
if (token) {
return { Authorization: `Bearer ${token}` };
}
return {};
}
export async function fetchVideos(): Promise<VideoListItem[]> {
const res = await fetch(`${API_BASE}/videos`);
if (!res.ok) throw new Error("Failed to fetch videos");
return res.json();
}
export async function fetchVideo(id: string): Promise<VideoDetail> {
const res = await fetch(`${API_BASE}/videos/${id}`);
if (!res.ok) throw new Error("Failed to fetch video");
return res.json();
}
export interface UploadInitResponse {
id: string;
presignedUrls: {
video: string;
};
videoUrl: string;
mtvUrl: string;
thumbnailUrl?: string;
title: string;
mapName: string;
playerName: string;
runTime: number;
tier?: number | null;
}
export async function uploadVideoInit(
mtvFile: File,
videoFileName: string,
videoContentType: string,
runDate?: string,
): Promise<UploadInitResponse> {
const formData = new FormData();
formData.append("mtv", mtvFile);
formData.append("videoFileName", videoFileName);
formData.append("videoContentType", videoContentType);
if (runDate) formData.append("runDate", runDate);
const res = await fetch(`${API_BASE}/videos/upload-url`, {
method: "POST",
headers: authHeaders(),
body: formData,
});
if (!res.ok) {
if (res.status === 401)
throw new Error("Unauthorized: Invalid admin token");
const err = await res.json();
throw new Error(err.error || "Upload init failed");
}
return res.json();
}
export async function uploadVideoComplete(id: string): Promise<VideoDetail> {
const res = await fetch(`${API_BASE}/videos/${id}/complete`, {
method: "POST",
headers: authHeaders(),
});
if (!res.ok) {
if (res.status === 401)
throw new Error("Unauthorized: Invalid admin token");
const err = await res.json();
throw new Error(err.error || "Upload completion failed");
}
return res.json();
}
export async function deleteVideo(id: string): Promise<void> {
const res = await fetch(`${API_BASE}/videos/${id}`, {
method: "DELETE",
headers: authHeaders(),
});
if (!res.ok) {
if (res.status === 401)
throw new Error("Unauthorized: Invalid admin token");
throw new Error("Failed to delete video");
}
}
export function formatRunTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toFixed(3).padStart(6, "0")}`;
}
+36
View File
@@ -0,0 +1,36 @@
import { Link, Outlet } from "react-router-dom";
export default function Layout() {
return (
<div className="min-h-screen flex flex-col">
<header className="h-18 shrink-0 flex items-center border-b border-mom-border bg-mom-bg shadow-[1px_0_16px_#0009] sticky top-0 z-50">
<div
className="mx-auto px-12 w-full flex items-center justify-between"
style={{ maxWidth: "1800px" }}
>
<Link
to="/"
className="text-lg font-bold tracking-tight no-underline hover:no-underline"
>
<span className="text-mom-accent">surf</span>
<span className="text-white/90">.nathan.rip</span>
</Link>
<nav className="flex items-center gap-4">
<Link
to="/upload"
className="text-sm font-medium text-white/40 hover:text-mom-accent transition-colors no-underline"
>
Upload
</Link>
</nav>
</div>
</header>
<main className="flex-1 py-9 overflow-y-auto">
<div className="mx-auto px-12" style={{ maxWidth: "1800px" }}>
<Outlet />
</div>
</main>
</div>
);
}
+409
View File
@@ -0,0 +1,409 @@
import { Fragment, useState } from "react";
import { formatRunTime } from "../api/client";
interface RunStatsProps {
jsonStats: string;
runTime: number;
}
interface SubsegmentStats {
maxOverallSpeed?: number;
maxHorizontalSpeed?: number;
jumps?: number;
strafes?: number;
}
interface Subsegment {
minorNum: number;
timeReached: number;
stats?: SubsegmentStats;
}
interface Segment {
subsegments: Subsegment[];
segmentStats?: SubsegmentStats;
}
interface TrackStats {
maxOverallSpeed?: number;
maxHorizontalSpeed?: number;
jumps?: number;
strafes?: number;
}
function formatSpeedExact(v: number): string {
return v.toFixed(1);
}
function formatSpeedShort(v: number): string {
if (v >= 1000) return `${(v / 1000).toFixed(1)}k`;
return v.toFixed(0);
}
interface SplitRow {
num: number;
split: number;
cumulative: number;
stats?: SubsegmentStats;
}
function buildChapterRows(subs: Subsegment[], endTime: number): SplitRow[] {
return subs.map((sub, i) => {
const nextReached =
i < subs.length - 1 ? subs[i + 1]!.timeReached : endTime;
return {
num: i + 1,
split: nextReached - sub.timeReached,
cumulative: nextReached,
stats: sub.stats,
};
});
}
type TimeMode = "split" | "cumulative";
function TimeToggle({
mode,
onChange,
}: {
mode: TimeMode;
onChange: (m: TimeMode) => void;
}) {
return (
<div className="inline-flex rounded border border-white/10 bg-white/5 text-[11px]">
<button
onClick={() => onChange("split")}
className={`px-2.5 py-1 rounded-l transition-colors ${mode === "split" ? "bg-mom-accent/20 text-mom-accent" : "text-white/30 hover:text-white/50"}`}
>
Split
</button>
<button
onClick={() => onChange("cumulative")}
className={`px-2.5 py-1 rounded-r border-l border-white/10 transition-colors ${mode === "cumulative" ? "bg-mom-accent/20 text-mom-accent" : "text-white/30 hover:text-white/50"}`}
>
Cumulative
</button>
</div>
);
}
function SpeedCell({ value }: { value?: number }) {
if (value == null)
return (
<td className="pr-4 py-2 text-right font-mono text-[11px] text-gray-500" />
);
return (
<td
className="pr-4 py-2 text-right font-mono text-[11px] text-gray-400"
title={`${formatSpeedExact(value)} u/s`}
>
{formatSpeedShort(value)} u/s
</td>
);
}
export default function RunStats({ jsonStats, runTime }: RunStatsProps) {
let parsed: Record<string, any>;
try {
parsed = JSON.parse(jsonStats);
} catch {
return null;
}
const segments: Segment[] = parsed.segments ?? [];
const trackStats: TrackStats | undefined = parsed.trackStats;
if (segments.length === 0) return null;
return segments.length > 1 ? (
<StageSplits
segments={segments}
runTime={runTime}
trackStats={trackStats}
/>
) : (
<ChapterSplits
segment={segments[0]!}
runTime={runTime}
trackStats={trackStats}
/>
);
}
function ChapterSplits({
segment,
runTime,
trackStats,
}: {
segment: Segment;
runTime: number;
trackStats?: TrackStats;
}) {
const [mode, setMode] = useState<TimeMode>("split");
const rows = buildChapterRows(segment.subsegments, runTime);
if (rows.length === 0) return null;
const hasStats = rows.some((r) => r.stats?.maxOverallSpeed != null);
return (
<div className="rounded border border-white/5 bg-white/5 overflow-hidden">
<div className="px-4 py-3 border-b border-white/5 flex items-center justify-between">
<h2 className="text-sm font-medium text-white/50 uppercase tracking-wider">
Chapter Splits
</h2>
<TimeToggle mode={mode} onChange={setMode} />
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/5 text-[10px] uppercase tracking-wider text-gray-500">
<th className="text-left font-normal px-4 py-2">
Chapter
</th>
<th className="text-right font-normal px-4 py-2 font-mono">
{mode === "split" ? "Split" : "Cumulative"}
</th>
{hasStats && (
<th className="text-right font-normal pr-4 py-2 font-mono">
Peak
</th>
)}
{hasStats && (
<th className="text-right font-normal pr-4 py-2 font-mono">
Jumps
</th>
)}
{hasStats && (
<th className="text-right font-normal pr-4 py-2 font-mono">
Strafes
</th>
)}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.num}
className="border-t border-white/5 hover:bg-white/5 transition-colors"
>
<td className="px-4 py-2 text-gray-300">
Chapter {row.num}
</td>
<td className="px-4 py-2 text-right font-mono text-mom-accent/80">
{mode === "split"
? formatRunTime(row.split)
: formatRunTime(row.cumulative)}
</td>
{hasStats && (
<SpeedCell value={row.stats?.maxOverallSpeed} />
)}
{hasStats && (
<td className="pr-4 py-2 text-right font-mono text-[11px] text-gray-500">
{row.stats?.jumps ?? ""}
</td>
)}
{hasStats && (
<td className="pr-4 py-2 text-right font-mono text-[11px] text-gray-500">
{row.stats?.strafes ?? ""}
</td>
)}
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-white/10">
<td className="px-4 py-2.5 text-gray-200 font-medium">
Total
</td>
<td className="px-4 py-2.5 text-right font-mono font-medium text-mom-accent">
{formatRunTime(runTime)}
</td>
{hasStats && (
<SpeedCell value={trackStats?.maxOverallSpeed} />
)}
{hasStats && (
<td className="pr-4 py-2.5 text-right font-mono text-[11px] text-gray-400">
{trackStats?.jumps ?? ""}
</td>
)}
{hasStats && (
<td className="pr-4 py-2.5 text-right font-mono text-[11px] text-gray-400">
{trackStats?.strafes ?? ""}
</td>
)}
</tr>
</tfoot>
</table>
</div>
);
}
function StageSplits({
segments,
runTime,
trackStats,
}: {
segments: Segment[];
runTime: number;
trackStats?: TrackStats;
}) {
const [mode, setMode] = useState<TimeMode>("split");
const stageRows: {
num: number;
split: number;
cumulative: number;
stats?: SubsegmentStats;
chapters: SplitRow[];
}[] = [];
for (let i = 0; i < segments.length; i++) {
const seg = segments[i]!;
const stageStart = seg.subsegments[0]?.timeReached ?? 0;
const stageEnd =
i < segments.length - 1
? (segments[i + 1]!.subsegments[0]?.timeReached ?? runTime)
: runTime;
const chapters =
seg.subsegments.length > 1
? buildChapterRows(seg.subsegments, stageEnd)
: [];
stageRows.push({
num: i + 1,
split: stageEnd - stageStart,
cumulative: stageEnd,
stats: seg.segmentStats,
chapters,
});
}
const hasStats = stageRows.some((s) => s.stats?.maxOverallSpeed != null);
return (
<div className="rounded border border-white/5 bg-white/5 overflow-hidden">
<div className="px-4 py-3 border-b border-white/5 flex items-center justify-between">
<h2 className="text-sm font-medium text-white/50 uppercase tracking-wider">
Stage Splits
</h2>
<TimeToggle mode={mode} onChange={setMode} />
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/5 text-[10px] uppercase tracking-wider text-gray-500">
<th className="text-left font-normal px-4 py-2">
Stage
</th>
<th className="text-right font-normal px-4 py-2 font-mono">
{mode === "split" ? "Split" : "Cumulative"}
</th>
{hasStats && (
<th className="text-right font-normal pr-4 py-2 font-mono">
Peak
</th>
)}
{hasStats && (
<th className="text-right font-normal pr-4 py-2 font-mono">
Jumps
</th>
)}
{hasStats && (
<th className="text-right font-normal pr-4 py-2 font-mono">
Strafes
</th>
)}
</tr>
</thead>
<tbody>
{stageRows.map((stage) => (
<Fragment key={stage.num}>
<tr className="border-t border-white/5 hover:bg-white/5 transition-colors">
<td className="px-4 py-2 text-gray-300">
Stage {stage.num}
</td>
<td className="px-4 py-2 text-right font-mono text-mom-accent/80">
{mode === "split"
? formatRunTime(stage.split)
: formatRunTime(stage.cumulative)}
</td>
{hasStats && (
<SpeedCell
value={stage.stats?.maxOverallSpeed}
/>
)}
{hasStats && (
<td className="pr-4 py-2 text-right font-mono text-[11px] text-gray-500">
{stage.stats?.jumps ?? ""}
</td>
)}
{hasStats && (
<td className="pr-4 py-2 text-right font-mono text-[11px] text-gray-500">
{stage.stats?.strafes ?? ""}
</td>
)}
</tr>
{stage.chapters.map((ch) => (
<tr
key={`ch-${stage.num}-${ch.num}`}
className="border-t border-white/3 hover:bg-white/5 transition-colors"
>
<td className="px-4 py-1.5 pl-8 text-xs text-gray-500">
Ch {ch.num}
</td>
<td className="px-4 py-1.5 text-right font-mono text-xs text-gray-500">
{mode === "split"
? formatRunTime(ch.split)
: formatRunTime(ch.cumulative)}
</td>
{hasStats && (
<td
className="pr-4 py-1.5 text-right font-mono text-[10px] text-gray-600"
title={
ch.stats?.maxOverallSpeed !=
null
? `${formatSpeedExact(ch.stats.maxOverallSpeed)} u/s`
: undefined
}
>
{ch.stats?.maxOverallSpeed != null
? `${formatSpeedShort(ch.stats.maxOverallSpeed)} u/s`
: ""}
</td>
)}
{hasStats && (
<td className="pr-4 py-1.5 text-right font-mono text-[10px] text-gray-600">
{ch.stats?.jumps ?? ""}
</td>
)}
{hasStats && (
<td className="pr-4 py-1.5 text-right font-mono text-[10px] text-gray-600">
{ch.stats?.strafes ?? ""}
</td>
)}
</tr>
))}
</Fragment>
))}
</tbody>
<tfoot>
<tr className="border-t border-white/10">
<td className="px-4 py-2.5 text-gray-200 font-medium">
Total
</td>
<td className="px-4 py-2.5 text-right font-mono font-medium text-mom-accent">
{formatRunTime(runTime)}
</td>
{hasStats && (
<SpeedCell value={trackStats?.maxOverallSpeed} />
)}
{hasStats && (
<td className="pr-4 py-2.5 text-right font-mono text-[11px] text-gray-400">
{trackStats?.jumps ?? ""}
</td>
)}
{hasStats && (
<td className="pr-4 py-2.5 text-right font-mono text-[11px] text-gray-400">
{trackStats?.strafes ?? ""}
</td>
)}
</tr>
</tfoot>
</table>
</div>
);
}
+414
View File
@@ -0,0 +1,414 @@
import { useState, useCallback, type FormEvent } from "react";
import { uploadVideoInit, uploadVideoComplete } from "../api/client";
function UploadDropzone({
accept,
label,
sublabel,
required,
file,
onFileChange,
}: {
accept: string;
label: string;
sublabel?: string;
required?: boolean;
file: File | null;
onFileChange: (file: File | null) => void;
}) {
const [isDragOver, setIsDragOver] = useState(false);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const dropped = e.dataTransfer.files[0];
if (dropped) onFileChange(dropped);
},
[onFileChange],
);
return (
<div>
<label className="block text-[10px] font-semibold uppercase tracking-[0.15em] text-white/35 mb-2">
{label} {required && <span className="text-red-400/50">*</span>}
</label>
<div
onDragOver={(e) => {
e.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
onClick={() => {
const input = document.createElement("input");
input.type = "file";
input.accept = accept;
input.onchange = () =>
onFileChange(input.files?.[0] ?? null);
input.click();
}}
className={`relative cursor-pointer rounded-lg border-2 border-dashed transition-all duration-200
${
file
? "border-mom-accent/40 bg-mom-accent/5"
: isDragOver
? "border-mom-accent/40 bg-mom-accent/5"
: "border-white/7 bg-white/2 hover:border-white/15 hover:bg-white/3"
}
`}
>
<div className="px-5 py-6 text-center">
{file ? (
<div className="flex flex-col items-center gap-1.5">
<svg
className="w-5 h-5 text-mom-accent"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<span className="text-sm text-white/70 font-medium truncate max-w-full">
{file.name}
</span>
<span className="text-[10px] text-white/25">
{(file.size / 1024 / 1024).toFixed(1)} MB -
click or drop to replace
</span>
</div>
) : (
<div className="flex flex-col items-center gap-1.5">
<svg
className="w-6 h-6 text-white/15"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<span className="text-sm text-white/30">
Drop file here or click to browse
</span>
{sublabel && (
<span className="text-[10px] text-white/15">
{sublabel}
</span>
)}
</div>
)}
</div>
</div>
{file && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onFileChange(null);
}}
className="mt-1.5 text-[10px] text-white/20 hover:text-red-400/60 transition-colors"
>
Remove
</button>
)}
</div>
);
}
function ProgressBar({
progress,
uploading,
}: {
progress: number;
uploading: boolean;
}) {
if (!uploading) return null;
return (
<div className="w-full rounded-full bg-white/5 h-2 overflow-hidden">
<div
className="h-full bg-mom-accent rounded-full transition-all duration-300 ease-out"
style={{ width: `${Math.min(progress, 100)}%` }}
/>
</div>
);
}
export default function UploadForm() {
const [authed, setAuthed] = useState(
() => !!localStorage.getItem("admin_token"),
);
const [tokenInput, setTokenInput] = useState("");
const [authError, setAuthError] = useState<string | null>(null);
const [videoFile, setVideoFile] = useState<File | null>(null);
const [mtvFile, setMtvFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleAuth = (e: FormEvent) => {
e.preventDefault();
if (!tokenInput.trim()) return;
localStorage.setItem("admin_token", tokenInput.trim());
setAuthError(null);
fetch("/api/videos/upload-url", {
method: "POST",
headers: { Authorization: `Bearer ${tokenInput.trim()}` },
})
.then((res) => {
if (res.status === 401) {
localStorage.removeItem("admin_token");
setAuthError("Invalid token");
setAuthed(false);
} else {
setAuthed(true);
}
})
.catch(() => {
setAuthed(true);
});
};
const handleLogout = () => {
localStorage.removeItem("admin_token");
setAuthed(false);
setTokenInput("");
};
if (!authed) {
return (
<div className="max-w-2xl mx-auto">
<div className="mb-10">
<h1 className="text-3xl font-bold tracking-tight text-white/90 mb-2">
Upload Surf Run
</h1>
<p className="text-sm text-white/30 leading-relaxed">
Authenticate to access the upload page.
</p>
</div>
{authError && (
<div className="mb-5 p-4 rounded-lg border border-red-500/20 bg-red-500/5 text-red-400/80 text-sm">
{authError}
</div>
)}
<form onSubmit={handleAuth} className="space-y-4">
<div>
<label className="block text-[10px] font-semibold uppercase tracking-[0.15em] text-white/35 mb-2">
Admin Token
</label>
<input
type="password"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
placeholder="Enter admin token"
className="w-full rounded-lg border border-white/7 bg-white/3 px-4 py-2.5 text-sm text-white/80 placeholder:text-white/15 focus:border-mom-accent/40 focus:outline-none transition-colors"
/>
</div>
<button
type="submit"
className="w-full rounded-lg bg-mom-accent/15 border border-mom-accent/25 px-4 py-3 text-sm font-semibold text-mom-accent hover:bg-mom-accent/25 hover:border-mom-accent/35 transition-all duration-200"
>
Authenticate
</button>
</form>
</div>
);
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!videoFile || !mtvFile) {
setError("Video and .mtv files are required");
return;
}
setError(null);
setUploading(true);
setSuccess(false);
setProgress(0);
try {
setProgress(5);
const runDate = mtvFile.lastModified
? new Date(mtvFile.lastModified).toISOString()
: undefined;
const initRes = await uploadVideoInit(
mtvFile,
videoFile.name,
videoFile.type || "video/webm",
runDate,
);
const totalSize = videoFile.size;
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
setProgress(
5 + Math.round((e.loaded / totalSize) * 90),
);
}
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else
reject(
new Error(
`Upload failed with status ${xhr.status}`,
),
);
});
xhr.addEventListener("error", () =>
reject(new Error("Upload failed")),
);
xhr.open("PUT", initRes.presignedUrls.video);
xhr.setRequestHeader(
"Content-Type",
videoFile.type || "video/webm",
);
xhr.send(videoFile);
});
setProgress(98);
await uploadVideoComplete(initRes.id);
setProgress(100);
setSuccess(true);
setVideoFile(null);
setMtvFile(null);
} catch (err: any) {
setError(err.message || "Upload failed");
} finally {
setUploading(false);
}
};
return (
<div className="max-w-2xl mx-auto">
<div className="mb-10 flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-white/90 mb-2">
Upload Surf Run
</h1>
<p className="text-sm text-white/30 leading-relaxed">
Drop your replay video and .mtv file. Map name, player,
run time, and stage splits are parsed from the .mtv
automatically. The run date is taken from the .mtv file.
</p>
</div>
<button
onClick={handleLogout}
className="text-[10px] uppercase tracking-[0.15em] text-white/20 hover:text-white/50 transition-colors shrink-0 mt-2"
>
Logout
</button>
</div>
{error && (
<div className="mb-5 p-4 rounded-lg border border-red-500/20 bg-red-500/5 text-red-400/80 text-sm flex items-start gap-3">
<svg
className="w-4 h-4 mt-0.5 shrink-0 text-red-400/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{error}</span>
</div>
)}
{success && (
<div className="mb-5 p-4 rounded-lg border border-green-500/20 bg-green-500/5 text-green-400/80 text-sm flex items-start gap-3">
<svg
className="w-4 h-4 mt-0.5 shrink-0 text-green-400/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<span>
Upload successful. .mtv metadata parsed automatically.
</span>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-8">
<div className="rounded-xl border border-white/7 bg-white/2 p-6 space-y-5">
<h2 className="text-xs font-semibold uppercase tracking-[0.15em] text-white/35 mb-1">
Files
</h2>
<UploadDropzone
accept="video/*"
label="Video"
sublabel="MP4, WebM, etc."
required
file={videoFile}
onFileChange={setVideoFile}
/>
<UploadDropzone
accept=".mtv"
label=".mtv Replay"
sublabel="Momentum Mod replay file"
required
file={mtvFile}
onFileChange={setMtvFile}
/>
</div>
{uploading && (
<div className="space-y-2">
<div className="flex justify-between text-xs text-white/40">
<span>
{success ? "Complete!" : "Uploading..."}
</span>
<span>{progress}%</span>
</div>
<ProgressBar
progress={progress}
uploading={uploading}
/>
</div>
)}
<button
type="submit"
disabled={uploading || !videoFile || !mtvFile}
className="w-full rounded-lg bg-mom-accent/15 border border-mom-accent/25 px-4 py-3 text-sm font-semibold text-mom-accent hover:bg-mom-accent/25 hover:border-mom-accent/35 disabled:opacity-30 disabled:cursor-not-allowed transition-all duration-200"
>
{uploading ? "Uploading..." : "Upload"}
</button>
</form>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { Link } from "react-router-dom";
import type { VideoListItem } from "../types/video";
import { formatRunTime } from "../api/client";
const TIER_COLORS: Record<number, string> = {
1: "#4caf50",
2: "#8bc34a",
3: "#ffc107",
4: "#ff9800",
5: "#f44336",
6: "#e91e63",
7: "#9c27b0",
8: "#673ab7",
9: "#ff5722",
10: "#880e4f",
};
function TierBadge({ tier }: { tier: number | null | undefined }) {
if (tier == null) return null;
const color = TIER_COLORS[tier] ?? "#888";
return (
<div
className="flex min-w-8 h-7 rounded border border-white/10 shadow items-center justify-center px-1.5"
style={{ background: `linear-gradient(${color}b3, ${color}bf)` }}
>
<span
className="font-bold text-white leading-none"
style={{
fontSize: "0.875rem",
textShadow: "0 1px 3px rgba(0,0,0,0.5)",
}}
>
T{tier}
</span>
</div>
);
}
function formatMapName(mapName: string): { prefix: string; name: string } {
const underscoreIdx = mapName.indexOf("_");
if (underscoreIdx >= 0) {
return {
prefix: mapName.slice(0, underscoreIdx).toUpperCase(),
name: mapName.slice(underscoreIdx + 1).toUpperCase(),
};
}
return { prefix: "", name: mapName.toUpperCase() };
}
export default function VideoCard({ video }: { video: VideoListItem }) {
const { prefix, name: mapName } = formatMapName(video.mapName);
return (
<Link
to={`/video/${video.id}`}
className="group flex h-full flex-col shadow no-underline"
>
<div className="relative aspect-video overflow-hidden rounded-t">
{video.thumbnailUrl ? (
<img
src={video.thumbnailUrl}
alt={video.title}
className="h-full w-full object-cover transition-all group-hover:brightness-110"
/>
) : (
<div className="h-full w-full bg-white/5" />
)}
{video.tier != null && (
<div className="absolute top-2 left-2">
<TierBadge tier={video.tier} />
</div>
)}
<div className="absolute bottom-2 right-2 rounded border border-white/10 px-2 py-0.5 bg-mom-bg">
<span className="font-mono text-xs text-white/90">
{formatRunTime(video.runTime)}
</span>
</div>
</div>
<div className="flex grow flex-col rounded-b border border-white/5 bg-white/5 p-4 pt-3.5 backdrop-blur-xl transition-colors group-hover:bg-white/10">
<div className="mb-2 flex items-center min-h-8">
<p
className="font-bold leading-none text-gray-100 truncate"
style={{ fontSize: "2rem" }}
>
{prefix && (
<span className="mr-1.5 text-gray-400">
{prefix}{" "}
</span>
)}
{mapName}
</p>
</div>
<div className="flex grow items-center justify-between text-sm">
<p className="text-gray-400">
Surfed by{" "}
<span className="text-gray-100">
{video.playerName}
</span>
</p>
<p className="text-gray-500">
{new Date(video.createdAt).toLocaleDateString()}
</p>
</div>
</div>
</Link>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { useState, useRef } from "react";
import type { VideoDetail } from "../types/video";
import { formatRunTime } from "../api/client";
export default function VideoPlayer({ video }: { video: VideoDetail }) {
const [loaded, setLoaded] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
return (
<div className="rounded-lg border border-white/5 overflow-hidden">
<div className="aspect-video bg-black relative">
{!loaded && (
<div className="absolute inset-0 flex items-center justify-center z-10">
{video.thumbnailUrl ? (
<img
src={video.thumbnailUrl}
alt=""
className="absolute inset-0 w-full h-full object-cover blur-sm opacity-50"
/>
) : null}
<div className="relative z-20 flex flex-col items-center gap-2">
<div className="w-10 h-10 rounded-full border-2 border-white/20 border-t-white/80 animate-spin" />
<span className="text-xs text-white/40">
Loading video
</span>
</div>
</div>
)}
<video
ref={videoRef}
src={video.videoUrl}
controls
className="w-full h-full"
poster={video.thumbnailUrl}
onCanPlay={() => setLoaded(true)}
>
Your browser does not support video playback.
</video>
</div>
<div className="px-4 py-3 flex items-center justify-between border-t border-white/5 bg-white/3">
<a
href={video.mtvUrl}
download
className="inline-flex items-center gap-2 rounded-md bg-white/4 border border-white/7 px-3 py-1.5 text-xs font-medium text-white/50 hover:bg-white/8 hover:text-white/80 hover:border-white/15 transition-colors no-underline"
>
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
.mtv replay
</a>
<span className="font-mono text-sm text-white/80">
{formatRunTime(video.runTime)}
</span>
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { Link } from "react-router-dom";
import type { VideoDetail } from "../types/video";
import { formatRunTime } from "../api/client";
import RunStats from "./RunStats";
const TIER_COLORS: Record<number, string> = {
1: "#4caf50",
2: "#8bc34a",
3: "#ffc107",
4: "#ff9800",
5: "#f44336",
6: "#e91e63",
7: "#9c27b0",
8: "#673ab7",
9: "#ff5722",
10: "#880e4f",
};
function TierBadge({ tier }: { tier: number | null | undefined }) {
if (tier == null) return null;
const color = TIER_COLORS[tier] ?? "#888";
return (
<Link to={`/?tier=${tier}`} className="inline-block no-underline">
<div
className="flex min-w-8 h-7 rounded border border-white/10 shadow items-center justify-center px-1.5 hover:brightness-125 transition-all"
style={{
background: `linear-gradient(${color}b3, ${color}bf)`,
}}
>
<span
className="font-bold text-white leading-none"
style={{
fontSize: "0.875rem",
textShadow: "0 1px 3px rgba(0,0,0,0.5)",
}}
>
T{tier}
</span>
</div>
</Link>
);
}
function getMomUrl(mapName: string): string {
return `https://dashboard.momentum-mod.org/maps/${mapName}`;
}
export default function VideoSidebar({ video }: { video: VideoDetail }) {
const hasStats = video.jsonStats && video.jsonStats.length > 2;
return (
<div className="flex flex-col gap-4">
<div className="rounded border border-white/5 bg-white/5 p-4">
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<div>
<div className="text-[10px] uppercase tracking-wider text-gray-500 mb-1">
Map
</div>
<a
href={getMomUrl(video.mapName)}
target="_blank"
rel="noopener noreferrer"
className="text-gray-100 font-medium text-sm no-underline hover:text-mom-accent transition-colors"
>
{video.mapName}
</a>
</div>
<div>
<div className="text-[10px] uppercase tracking-wider text-gray-500 mb-1">
Time
</div>
<div className="font-mono text-white/90">
{formatRunTime(video.runTime)}
</div>
</div>
<div>
<div className="text-[10px] uppercase tracking-wider text-gray-500 mb-1">
Player
</div>
<div className="text-gray-200 text-sm">
{video.playerName}
</div>
</div>
{video.tier != null ? (
<div>
<div className="text-[10px] uppercase tracking-wider text-gray-500 mb-1">
Tier
</div>
<TierBadge tier={video.tier} />
</div>
) : (
<div>
<div className="text-[10px] uppercase tracking-wider text-gray-500 mb-1">
Ticks
</div>
<div className="font-mono text-gray-400 text-sm">
{video.totalTicks}
</div>
</div>
)}
</div>
{video.description && (
<div className="mt-3 pt-3 border-t border-white/5">
<p className="text-sm text-gray-400 whitespace-pre-wrap leading-relaxed">
{video.description}
</p>
</div>
)}
</div>
{hasStats && (
<RunStats
jsonStats={video.jsonStats!}
runTime={video.runTime}
/>
)}
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
@import "tailwindcss";
@theme {
--color-mom-bg: #0e0e14;
--color-mom-surface: #1a1a24;
--color-mom-surface-hover: #24243a;
--color-mom-border: #ffffff0d;
--color-mom-border-bright: #ffffff1a;
--color-mom-accent: #1896d3;
--width-max: 1800px;
}
body {
font-family:
"Roboto",
system-ui,
-apple-system,
sans-serif;
background-color: var(--color-mom-bg);
color: #b8b8c8;
}
a {
color: #1896d3;
text-decoration: none;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useState, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import VideoCard from "../components/VideoCard";
import { fetchVideos } from "../api/client";
import type { VideoListItem } from "../types/video";
const ALL_TIERS = "all";
export default function Home() {
const [videos, setVideos] = useState<VideoListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const tierFilter = searchParams.get("tier") || ALL_TIERS;
const setTierFilter = (value: string) => {
if (value === ALL_TIERS) {
setSearchParams({}, { replace: true });
} else {
setSearchParams({ tier: value }, { replace: true });
}
};
useEffect(() => {
fetchVideos()
.then(setVideos)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
const availableTiers = useMemo(() => {
const tiers = new Set<number>();
for (const v of videos) {
if (v.tier != null) tiers.add(v.tier);
}
return Array.from(tiers).sort((a, b) => a - b);
}, [videos]);
const filteredVideos = useMemo(() => {
if (tierFilter === ALL_TIERS) return videos;
const tier = parseInt(tierFilter, 10);
return videos.filter((v) => v.tier === tier);
}, [videos, tierFilter]);
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<div className="text-white/20 text-sm">Loading</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center py-24">
<div className="text-red-400/60 text-sm">{error}</div>
</div>
);
}
return (
<div>
{videos.length === 0 ? (
<div className="text-center py-24">
<p className="text-lg text-white/20 mb-1">
No runs uploaded yet
</p>
<p className="text-sm text-white/15">
Upload your first surf run via the{" "}
<a href="/upload" className="text-mom-accent">
upload page
</a>
</p>
</div>
) : (
<>
{availableTiers.length > 0 && (
<div className="flex items-center gap-2 mb-6 flex-wrap">
<span className="text-[10px] font-semibold uppercase tracking-[0.15em] text-white/30 mr-1">
Tier
</span>
<button
onClick={() => setTierFilter(ALL_TIERS)}
className={`px-2.5 py-1 rounded text-xs font-medium transition-all duration-150 ${
tierFilter === ALL_TIERS
? "bg-mom-accent/15 text-mom-accent border border-mom-accent/30"
: "bg-white/3 text-white/35 border border-white/7 hover:text-white/50 hover:border-white/15"
}`}
>
All
</button>
{availableTiers.map((t) => (
<button
key={t}
onClick={() => setTierFilter(String(t))}
className={`px-2.5 py-1 rounded text-xs font-medium transition-all duration-150 ${
tierFilter === String(t)
? "bg-mom-accent/15 text-mom-accent border border-mom-accent/30"
: "bg-white/3 text-white/35 border border-white/7 hover:text-white/50 hover:border-white/15"
}`}
>
T{t}
</button>
))}
</div>
)}
{filteredVideos.length === 0 ? (
<div className="text-center py-16">
<p className="text-sm text-white/20">
No runs match this tier filter
</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{filteredVideos.map((video) => (
<VideoCard key={video.id} video={video} />
))}
</div>
)}
</>
)}
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import UploadForm from "../components/UploadForm";
export default function Upload() {
return (
<div className="max-w-4xl mx-auto px-6 py-10">
<UploadForm />
</div>
);
}
+182
View File
@@ -0,0 +1,182 @@
import { useEffect, useState, useRef } from "react";
import { useParams } from "react-router-dom";
import VideoSidebar from "../components/VideoSidebar";
import { fetchVideo, formatRunTime } from "../api/client";
import type { VideoDetail } from "../types/video";
function getMapDisplayName(mapName: string): string {
return mapName.replace(/_/g, " ").toUpperCase();
}
function getMomUrl(mapName: string): string {
return `https://dashboard.momentum-mod.org/maps/${mapName}`;
}
function SkeletonBlock({ className }: { className?: string }) {
return (
<div
className={`rounded bg-white/5 animate-pulse ${className ?? ""}`}
/>
);
}
function PageSkeleton() {
return (
<div className="py-6">
<div className="flex items-baseline gap-3 mb-3">
<SkeletonBlock className="h-8 w-64" />
</div>
<div className="flex flex-col lg:flex-row gap-4 lg:items-start">
<div className="lg:w-1/2 min-w-0">
<SkeletonBlock className="aspect-video w-full rounded-lg" />
</div>
<div className="lg:w-1/2 flex flex-col gap-4">
<div className="rounded border border-white/5 bg-white/5 p-4">
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<div>
<SkeletonBlock className="h-2.5 w-8 mb-1.5" />
<SkeletonBlock className="h-4 w-28" />
</div>
<div>
<SkeletonBlock className="h-2.5 w-8 mb-1.5" />
<SkeletonBlock className="h-4 w-20" />
</div>
<div>
<SkeletonBlock className="h-2.5 w-10 mb-1.5" />
<SkeletonBlock className="h-4 w-24" />
</div>
<div>
<SkeletonBlock className="h-2.5 w-8 mb-1.5" />
<SkeletonBlock className="h-7 w-16" />
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default function VideoDetail() {
const { id } = useParams<{ id: string }>();
const [video, setVideo] = useState<VideoDetail | null>(null);
const [videoReady, setVideoReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (!id) return;
fetchVideo(id)
.then(setVideo)
.catch((err) => setError(err.message));
}, [id]);
if (error) {
return (
<div className="py-24 text-center">
<p className="text-red-400/60 mb-4">{error}</p>
</div>
);
}
if (!video || !videoReady) {
return (
<>
<PageSkeleton />
{video && (
<video
ref={videoRef}
src={video.videoUrl}
preload="auto"
onCanPlay={() => setVideoReady(true)}
className="fixed opacity-0 pointer-events-none"
aria-hidden
/>
)}
</>
);
}
return (
<div className="py-6">
<div className="rounded-lg border border-white/5 bg-white/5 px-4 py-2 inline-block mb-3">
<h1 className="text-2xl font-bold tracking-tight text-white/90">
<a
href={getMomUrl(video.mapName)}
target="_blank"
rel="noopener noreferrer"
className="no-underline text-white/90 hover:text-mom-accent transition-colors"
>
{getMapDisplayName(video.mapName)}
</a>
</h1>
</div>
<div className="flex flex-col lg:flex-row gap-4 lg:items-start">
<div className="lg:w-1/2 min-w-0">
<div className="rounded-lg border border-white/5 overflow-hidden">
<div className="aspect-video bg-black">
<video
ref={videoRef}
src={video.videoUrl}
controls
className="w-full h-full"
poster={video.thumbnailUrl}
>
Your browser does not support video playback.
</video>
</div>
<div className="px-4 py-3 flex items-center justify-between border-t border-white/5 bg-white/3">
<a
href={video.mtvUrl}
download
className="inline-flex items-center gap-2 rounded-md bg-white/4 border border-white/7 px-3 py-1.5 text-xs font-medium text-white/50 hover:bg-white/8 hover:text-white/80 hover:border-white/15 transition-colors no-underline"
>
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
.mtv replay
</a>
</div>
</div>
</div>
<div className="lg:w-1/2">
<VideoSidebar video={video} />
{video.previousPbs && video.previousPbs.length > 0 && (
<div className="rounded border border-white/5 bg-white/5 p-4 mt-4">
<div className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
Previous PBs
</div>
<ul className="space-y-1">
{video.previousPbs.map((pb) => (
<li
key={pb.createdAt}
className="text-sm text-white/60"
>
<span className="font-mono text-white/90">
{formatRunTime(pb.runTime)}
</span>
{" · "}
{new Date(
pb.createdAt,
).toLocaleDateString()}
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
export interface PreviousPB {
runTime: number;
createdAt: string;
}
export interface VideoListItem {
id: string;
title: string;
mapName: string;
playerName: string;
runTime: number;
tier?: number | null;
thumbnailUrl?: string;
createdAt: string;
previousPbs?: PreviousPB[];
}
export interface VideoDetail {
id: string;
title: string;
description: string;
mapName: string;
playerName: string;
steamId: string;
runTime: number;
totalTicks: number;
tickInterval: number;
videoKey: string;
mtvKey: string;
thumbnailKey?: string;
tier?: number | null;
videoUrl: string;
mtvUrl: string;
thumbnailUrl?: string;
jsonStats?: string;
createdAt: string;
previousPbs?: PreviousPB[];
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true
},
"include": ["src"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/Layout.tsx","./src/components/RunStats.tsx","./src/components/UploadForm.tsx","./src/components/VideoCard.tsx","./src/components/VideoPlayer.tsx","./src/components/VideoSidebar.tsx","./src/pages/Home.tsx","./src/pages/Upload.tsx","./src/pages/VideoDetail.tsx","./src/types/video.ts"],"version":"5.9.3"}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:3001",
changeOrigin: true,
},
},
},
});