commit eb56ad5183b95ac561f7dedd5aa59ed519496306 Author: CallMeVerity Date: Wed Jun 3 00:44:48 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..849b2a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +.env +*.db +*.db-journal +*.db-shm +*.db-wal +backend/data/ +frontend/dist/ +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..add2fa0 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff58705 --- /dev/null +++ b/README.md @@ -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 | \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..db5d05a --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/bun.lock b/backend/bun.lock new file mode 100644 index 0000000..486c555 --- /dev/null +++ b/backend/bun.lock @@ -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//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], + + "@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//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw=="], + + "@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//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@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=="], + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..246f987 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..e50bf3f --- /dev/null +++ b/backend/src/index.ts @@ -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}`); diff --git a/backend/src/routes/videos.ts b/backend/src/routes/videos.ts new file mode 100644 index 0000000..58a7335 --- /dev/null +++ b/backend/src/routes/videos.ts @@ -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 }; + }); diff --git a/backend/src/services/db.ts b/backend/src/services/db.ts new file mode 100644 index 0000000..45ceeba --- /dev/null +++ b/backend/src/services/db.ts @@ -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; +} diff --git a/backend/src/services/momentum-api.ts b/backend/src/services/momentum-api.ts new file mode 100644 index 0000000..dc4809a --- /dev/null +++ b/backend/src/services/momentum-api.ts @@ -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(); + +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 { + 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 { + const info = await getMapInfo(mapName); + return info.thumbnailUrl; +} diff --git a/backend/src/services/mtv-parser.ts b/backend/src/services/mtv-parser.ts new file mode 100644 index 0000000..745d816 --- /dev/null +++ b/backend/src/services/mtv-parser.ts @@ -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")}`; +} diff --git a/backend/src/services/rustfs.ts b/backend/src/services/rustfs.ts new file mode 100644 index 0000000..213c4ad --- /dev/null +++ b/backend/src/services/rustfs.ts @@ -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 { + const s3 = getRustFsClient(); + await s3.send( + new PutObjectCommand({ + Bucket: RUSTFS_BUCKET, + Key: key, + Body: body, + ContentType: contentType, + }), + ); +} + +export async function deleteObject(key: string): Promise { + const s3 = getRustFsClient(); + await s3.send( + new DeleteObjectCommand({ + Bucket: RUSTFS_BUCKET, + Key: key, + }), + ); +} + +export async function getPresignedUploadUrl( + key: string, + contentType: string, + expiresInSeconds = 3600, +): Promise { + const s3 = getRustFsClient(); + const command = new PutObjectCommand({ + Bucket: RUSTFS_BUCKET, + Key: key, + ContentType: contentType, + }); + return getSignedUrl(s3, command, { expiresIn: expiresInSeconds }); +} diff --git a/backend/src/types/video.ts b/backend/src/types/video.ts new file mode 100644 index 0000000..feab933 --- /dev/null +++ b/backend/src/types/video.ts @@ -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[]; +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..21603d6 --- /dev/null +++ b/backend/tsconfig.json @@ -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"], +} diff --git a/frontend/bun.lock b/frontend/bun.lock new file mode 100644 index 0000000..69cbff6 --- /dev/null +++ b/frontend/bun.lock @@ -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//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@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///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@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=="], + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..45549d8 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + surf.nathan.rip + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..88a8af0 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..c823885 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..013acb4 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( + + + }> + } /> + } /> + } /> + + + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..689a403 --- /dev/null +++ b/frontend/src/api/client.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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")}`; +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..f90ae49 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,36 @@ +import { Link, Outlet } from "react-router-dom"; + +export default function Layout() { + return ( +
+
+
+ + surf + .nathan.rip + + +
+
+ +
+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/RunStats.tsx b/frontend/src/components/RunStats.tsx new file mode 100644 index 0000000..a7f8c41 --- /dev/null +++ b/frontend/src/components/RunStats.tsx @@ -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 ( +
+ + +
+ ); +} + +function SpeedCell({ value }: { value?: number }) { + if (value == null) + return ( + + ); + return ( + + {formatSpeedShort(value)} u/s + + ); +} + +export default function RunStats({ jsonStats, runTime }: RunStatsProps) { + let parsed: Record; + 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 ? ( + + ) : ( + + ); +} + +function ChapterSplits({ + segment, + runTime, + trackStats, +}: { + segment: Segment; + runTime: number; + trackStats?: TrackStats; +}) { + const [mode, setMode] = useState("split"); + const rows = buildChapterRows(segment.subsegments, runTime); + if (rows.length === 0) return null; + const hasStats = rows.some((r) => r.stats?.maxOverallSpeed != null); + + return ( +
+
+

+ Chapter Splits +

+ +
+ + + + + + {hasStats && ( + + )} + {hasStats && ( + + )} + {hasStats && ( + + )} + + + + {rows.map((row) => ( + + + + {hasStats && ( + + )} + {hasStats && ( + + )} + {hasStats && ( + + )} + + ))} + + + + + + {hasStats && ( + + )} + {hasStats && ( + + )} + {hasStats && ( + + )} + + +
+ Chapter + + {mode === "split" ? "Split" : "Cumulative"} + + Peak + + Jumps + + Strafes +
+ Chapter {row.num} + + {mode === "split" + ? formatRunTime(row.split) + : formatRunTime(row.cumulative)} + + {row.stats?.jumps ?? ""} + + {row.stats?.strafes ?? ""} +
+ Total + + {formatRunTime(runTime)} + + {trackStats?.jumps ?? ""} + + {trackStats?.strafes ?? ""} +
+
+ ); +} + +function StageSplits({ + segments, + runTime, + trackStats, +}: { + segments: Segment[]; + runTime: number; + trackStats?: TrackStats; +}) { + const [mode, setMode] = useState("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 ( +
+
+

+ Stage Splits +

+ +
+ + + + + + {hasStats && ( + + )} + {hasStats && ( + + )} + {hasStats && ( + + )} + + + + {stageRows.map((stage) => ( + + + + + {hasStats && ( + + )} + {hasStats && ( + + )} + {hasStats && ( + + )} + + {stage.chapters.map((ch) => ( + + + + {hasStats && ( + + )} + {hasStats && ( + + )} + {hasStats && ( + + )} + + ))} + + ))} + + + + + + {hasStats && ( + + )} + {hasStats && ( + + )} + {hasStats && ( + + )} + + +
+ Stage + + {mode === "split" ? "Split" : "Cumulative"} + + Peak + + Jumps + + Strafes +
+ Stage {stage.num} + + {mode === "split" + ? formatRunTime(stage.split) + : formatRunTime(stage.cumulative)} + + {stage.stats?.jumps ?? ""} + + {stage.stats?.strafes ?? ""} +
+ Ch {ch.num} + + {mode === "split" + ? formatRunTime(ch.split) + : formatRunTime(ch.cumulative)} + + {ch.stats?.maxOverallSpeed != null + ? `${formatSpeedShort(ch.stats.maxOverallSpeed)} u/s` + : ""} + + {ch.stats?.jumps ?? ""} + + {ch.stats?.strafes ?? ""} +
+ Total + + {formatRunTime(runTime)} + + {trackStats?.jumps ?? ""} + + {trackStats?.strafes ?? ""} +
+
+ ); +} diff --git a/frontend/src/components/UploadForm.tsx b/frontend/src/components/UploadForm.tsx new file mode 100644 index 0000000..bdc4dfa --- /dev/null +++ b/frontend/src/components/UploadForm.tsx @@ -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 ( +
+ +
{ + 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" + } + `} + > +
+ {file ? ( +
+ + + + + {file.name} + + + {(file.size / 1024 / 1024).toFixed(1)} MB - + click or drop to replace + +
+ ) : ( +
+ + + + + Drop file here or click to browse + + {sublabel && ( + + {sublabel} + + )} +
+ )} +
+
+ {file && ( + + )} +
+ ); +} + +function ProgressBar({ + progress, + uploading, +}: { + progress: number; + uploading: boolean; +}) { + if (!uploading) return null; + return ( +
+
+
+ ); +} + +export default function UploadForm() { + const [authed, setAuthed] = useState( + () => !!localStorage.getItem("admin_token"), + ); + const [tokenInput, setTokenInput] = useState(""); + const [authError, setAuthError] = useState(null); + + const [videoFile, setVideoFile] = useState(null); + const [mtvFile, setMtvFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [progress, setProgress] = useState(0); + const [error, setError] = useState(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 ( +
+
+

+ Upload Surf Run +

+

+ Authenticate to access the upload page. +

+
+ + {authError && ( +
+ {authError} +
+ )} + +
+
+ + 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" + /> +
+ +
+
+ ); + } + + 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((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 ( +
+
+
+

+ Upload Surf Run +

+

+ 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. +

+
+ +
+ + {error && ( +
+ + + + {error} +
+ )} + + {success && ( +
+ + + + + Upload successful. .mtv metadata parsed automatically. + +
+ )} + +
+
+

+ Files +

+ + + + +
+ + {uploading && ( +
+
+ + {success ? "Complete!" : "Uploading..."} + + {progress}% +
+ +
+ )} + + +
+
+ ); +} diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx new file mode 100644 index 0000000..8dfac79 --- /dev/null +++ b/frontend/src/components/VideoCard.tsx @@ -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 = { + 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 ( +
+ + T{tier} + +
+ ); +} + +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 ( + +
+ {video.thumbnailUrl ? ( + {video.title} + ) : ( +
+ )} + + {video.tier != null && ( +
+ +
+ )} + +
+ + {formatRunTime(video.runTime)} + +
+
+ +
+
+

+ {prefix && ( + + {prefix}{" "} + + )} + {mapName} +

+
+
+

+ Surfed by{" "} + + {video.playerName} + +

+

+ {new Date(video.createdAt).toLocaleDateString()} +

+
+
+ + ); +} diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx new file mode 100644 index 0000000..4c528b1 --- /dev/null +++ b/frontend/src/components/VideoPlayer.tsx @@ -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(null); + + return ( +
+
+ {!loaded && ( +
+ {video.thumbnailUrl ? ( + + ) : null} +
+
+ + Loading video… + +
+
+ )} + +
+ +
+ + + + + .mtv replay + + + + {formatRunTime(video.runTime)} + +
+
+ ); +} diff --git a/frontend/src/components/VideoSidebar.tsx b/frontend/src/components/VideoSidebar.tsx new file mode 100644 index 0000000..0e2f35b --- /dev/null +++ b/frontend/src/components/VideoSidebar.tsx @@ -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 = { + 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 ( + +
+ + T{tier} + +
+ + ); +} + +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 ( +
+
+
+
+
+ Map +
+ + {video.mapName} + +
+
+
+ Time +
+
+ {formatRunTime(video.runTime)} +
+
+
+
+ Player +
+
+ {video.playerName} +
+
+ {video.tier != null ? ( +
+
+ Tier +
+ +
+ ) : ( +
+
+ Ticks +
+
+ {video.totalTicks} +
+
+ )} +
+ + {video.description && ( +
+

+ {video.description} +

+
+ )} +
+ + {hasStats && ( + + )} +
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..24c2ba3 --- /dev/null +++ b/frontend/src/index.css @@ -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; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..65dc4ec --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + +); diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx new file mode 100644 index 0000000..1a372a3 --- /dev/null +++ b/frontend/src/pages/Home.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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(); + 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 ( +
+
Loading…
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + return ( +
+ {videos.length === 0 ? ( +
+

+ No runs uploaded yet +

+

+ Upload your first surf run via the{" "} + + upload page + +

+
+ ) : ( + <> + {availableTiers.length > 0 && ( +
+ + Tier + + + {availableTiers.map((t) => ( + + ))} +
+ )} + + {filteredVideos.length === 0 ? ( +
+

+ No runs match this tier filter +

+
+ ) : ( +
+ {filteredVideos.map((video) => ( + + ))} +
+ )} + + )} +
+ ); +} diff --git a/frontend/src/pages/Upload.tsx b/frontend/src/pages/Upload.tsx new file mode 100644 index 0000000..4088993 --- /dev/null +++ b/frontend/src/pages/Upload.tsx @@ -0,0 +1,9 @@ +import UploadForm from "../components/UploadForm"; + +export default function Upload() { + return ( +
+ +
+ ); +} diff --git a/frontend/src/pages/VideoDetail.tsx b/frontend/src/pages/VideoDetail.tsx new file mode 100644 index 0000000..bf6a24c --- /dev/null +++ b/frontend/src/pages/VideoDetail.tsx @@ -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 ( +
+ ); +} + +function PageSkeleton() { + return ( +
+
+ +
+
+
+ +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ ); +} + +export default function VideoDetail() { + const { id } = useParams<{ id: string }>(); + const [video, setVideo] = useState(null); + const [videoReady, setVideoReady] = useState(false); + const [error, setError] = useState(null); + const videoRef = useRef(null); + + useEffect(() => { + if (!id) return; + fetchVideo(id) + .then(setVideo) + .catch((err) => setError(err.message)); + }, [id]); + + if (error) { + return ( +
+

{error}

+
+ ); + } + + if (!video || !videoReady) { + return ( + <> + + {video && ( +