Initial commit
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
backend/data/
|
||||
frontend/dist/
|
||||
.DS_Store
|
||||
+21
@@ -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"]
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "surfnathanrip-backend",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
||||
"@elysiajs/cors": "latest",
|
||||
"@elysiajs/static": "latest",
|
||||
"elysia": "latest",
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
|
||||
|
||||
"@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
|
||||
|
||||
"@aws-crypto/sha1-browser": ["@aws-crypto/sha1-browser@5.2.0", "", { "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg=="],
|
||||
|
||||
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
|
||||
|
||||
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
|
||||
|
||||
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN
|
||||
|
||||
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
|
||||
|
||||
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1058.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-node": "^3.972.48", "@aws-sdk/middleware-bucket-endpoint": "^3.972.17", "@aws-sdk/middleware-expect-continue": "^3.972.14", "@aws-sdk/middleware-flexible-checksums": "^3.974.23", "@aws-sdk/middleware-location-constraint": "^3.972.11", "@aws-sdk/middleware-sdk-s3": "^3.972.44", "@aws-sdk/middleware-ssec": "^3.972.11", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-AfED3hhaBZ121NuiBImgnlF98kQRMk6hGPMGfj/Oo1hSaoMFRzM+N4nlICCasUSM2R8QaIRZRYGpZ3fy0ilGZQ=="],
|
||||
|
||||
"@aws-sdk/core": ["@aws-sdk/core@3.974.15", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@aws-sdk/xml-builder": "^3.972.26", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.5", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw=="],
|
||||
|
||||
"@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-P+QGozmXn2mZZI7sDgk+aUm+RTI61MPSFB+Ir2vjEjEbEsE4e7hYtzrDvAUxZy9ko81h53e11+F/GYlvwDkaOQ=="],
|
||||
|
||||
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.41", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.43", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-login": "^3.972.45", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hvcgcwOiS0nb2XFb5Op1Pz/vYaWz5K8kKullziGpdNRuG0NwzRXseuPt2CoBqknHGaSPVesu1aOn2OcctEYdCA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.48", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-ini": "^3.972.46", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-QIbtJP0olSLZ2ImEu636pP+7JJbPfaL3xSJIFXhu472CWuondCc4bGOa8OeyhOFet8z4H1D/ZFKXc39FboWwYA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.41", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ=="],
|
||||
|
||||
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/token-providers": "3.1056.0", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ=="],
|
||||
|
||||
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-lbDmWuHenc+kiwCNrxz4MyN6nkxCWyTXPIWuspJN0ibziu+8CXci7vI1bK9MAkwy8cwJOEXNu0gBM5S0uTGRIg=="],
|
||||
|
||||
"@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.14", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-3TNFEVGO4sWZj9TEXOCZLzGEctXHnaO4fk2EQ8KVaboTbwHmEPEQrm17Xb9koImUIXEw0sgi2xtHjg7LuTS3rA=="],
|
||||
|
||||
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.23", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/crc64-nvme": "^3.972.9", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-4nPKARo2lfKvQGUt2fPA5NlS/mEohckdxpuC9ecbjVfj7B7NFFYHeTg+Bf5BEQwdn3yRfUIzFiEkPp8Yuaw3wA=="],
|
||||
|
||||
"@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hkfspNUP4criAH6ton6BGKgnm5dZx+7bUOy1YqlTfejDeUPAM23D81q/IX+hdlS3KUsfwGz5ADTqZWKBEUpf4A=="],
|
||||
|
||||
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-8HQsRg1NpX8vR4vNl1E8pyLnqZroq9VSL2vZQVSgBqp6wv6365LzYD08/c9FFh/9FTg7YRc7aTtEmXF0ir/pqg=="],
|
||||
|
||||
"@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-7PQvGNhtveKlvVqNahqWx5yrwxP7ecwAoB1dYBf8eKwfo2tzzCbNnW+q2nO3N066ktQaB4iBQbDRWtizm+amoQ=="],
|
||||
|
||||
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg=="],
|
||||
|
||||
"@aws-sdk/s3-request-presigner": ["@aws-sdk/s3-request-presigner@3.1058.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-IRgNfn8U3zfsZ0JkpmwjS59R/XyHMHxpuwW6HVuJhik+FsbClhNkujEO0w1WqJvXrF4FX+7qIAwUrvlwNvaZ7Q=="],
|
||||
|
||||
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.30", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-HULDLMVzkmTSEv6
|
||||
|
||||
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1056.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA=="],
|
||||
|
||||
"@aws-sdk/types": ["@aws-sdk/types@3.973.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg=="],
|
||||
|
||||
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="],
|
||||
|
||||
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.26", "", { "dependencies": { "@smithy/types": "^4.14.2", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g=="],
|
||||
|
||||
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
|
||||
|
||||
"@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
|
||||
|
||||
"@elysiajs/cors": ["@elysiajs/cors@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-FTCcbH35brTLigF1W7BYySRZomgI/dBEMK9BgK9RP9Nez7zmpGh4koL/Yr1BFv8nYz7CfhRvcM8d/c+XnwMaVQ=="],
|
||||
|
||||
"@elysiajs/static": ["@elysiajs/static@1.4.10", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-BN/3PZVWjqkL/6TM7RH69Pae89CfU0OqfEYPeBp2hB/GR5Euds39Wy+0ug9Vbvu++GG4tRRANf2QjaLnriJveg=="],
|
||||
|
||||
"@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="],
|
||||
|
||||
"@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="],
|
||||
|
||||
"@smithy/core": ["@smithy/core@3.24.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug=="],
|
||||
|
||||
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-xj8gq/bjFABAh6qWPSDCYcY3kzQIm4b561C+YnHH4zGq8rOgzQ3Shk+JGlpUxSd41UGiO6FkLdUCtNX1FAeHgg=="],
|
||||
|
||||
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g=="],
|
||||
|
||||
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa
|
||||
|
||||
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-3fya8i7GrJilQouk4cZJKdy5k8MWQBpjfXrRNaXDedH8r779tr0jcxyH3+yoTmsluc2+vF4S343yFbnvu8ExDQ=="],
|
||||
|
||||
"@smithy/signature-v4": ["@smithy/signature-v4@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ=="],
|
||||
|
||||
"@smithy/types": ["@smithy/types@4.14.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ=="],
|
||||
|
||||
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
|
||||
|
||||
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||
|
||||
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
|
||||
|
||||
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"elysia": ["elysia@1.4.28", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="],
|
||||
|
||||
"exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="],
|
||||
|
||||
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
||||
|
||||
"fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="],
|
||||
|
||||
"file-type": ["file-type@22.0.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
||||
|
||||
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
|
||||
|
||||
"strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="],
|
||||
|
||||
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
|
||||
|
||||
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
|
||||
|
||||
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="],
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
const MOMENTUM_API = "https://api.momentum-mod.org/v1";
|
||||
|
||||
interface MomentumMapThumbnail {
|
||||
id: string;
|
||||
small: string;
|
||||
medium: string;
|
||||
large: string;
|
||||
xl: string;
|
||||
}
|
||||
|
||||
interface MomentumMapLeaderboard {
|
||||
gamemode: number;
|
||||
trackType: number;
|
||||
trackNum: number;
|
||||
style: number;
|
||||
tier: number | null;
|
||||
tags: number[];
|
||||
type: number;
|
||||
linear: boolean | null;
|
||||
}
|
||||
|
||||
interface MomentumMapInfo {
|
||||
description: string;
|
||||
youtubeID: string;
|
||||
creationDate: string;
|
||||
approvedDate: string;
|
||||
requiredGames: number[];
|
||||
}
|
||||
|
||||
interface MomentumMap {
|
||||
id: number;
|
||||
name: string;
|
||||
thumbnail: MomentumMapThumbnail | null;
|
||||
images: MomentumMapThumbnail[];
|
||||
info: MomentumMapInfo | null;
|
||||
leaderboards: MomentumMapLeaderboard[];
|
||||
}
|
||||
|
||||
interface MomentumMapsResponse {
|
||||
totalCount: number;
|
||||
returnCount: number;
|
||||
data: MomentumMap[];
|
||||
}
|
||||
|
||||
export interface MapInfo {
|
||||
mapId: number | null;
|
||||
thumbnailUrl: string | null;
|
||||
tier: number | null;
|
||||
mapDate: string | null;
|
||||
}
|
||||
|
||||
const mapInfoCache = new Map<string, MapInfo>();
|
||||
|
||||
function extractSurfTier(
|
||||
leaderboards: MomentumMapLeaderboard[],
|
||||
): number | null {
|
||||
const mainLeaderboard = leaderboards.find(
|
||||
(lb) => lb.gamemode === 1 && lb.trackType === 0 && lb.style === 0,
|
||||
);
|
||||
return mainLeaderboard?.tier ?? null;
|
||||
}
|
||||
|
||||
export async function getMapInfo(mapName: string): Promise<MapInfo> {
|
||||
const cached = mapInfoCache.get(mapName);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const url = `${MOMENTUM_API}/maps?search=${encodeURIComponent(mapName)}&take=1&expand=info,leaderboards`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const info: MapInfo = {
|
||||
mapId: null,
|
||||
thumbnailUrl: null,
|
||||
tier: null,
|
||||
mapDate: null,
|
||||
};
|
||||
mapInfoCache.set(mapName, info);
|
||||
return info;
|
||||
}
|
||||
const json: MomentumMapsResponse = await res.json();
|
||||
const map = json.data?.[0];
|
||||
if (!map || map.name !== mapName) {
|
||||
const info: MapInfo = {
|
||||
mapId: null,
|
||||
thumbnailUrl: null,
|
||||
tier: null,
|
||||
mapDate: null,
|
||||
};
|
||||
mapInfoCache.set(mapName, info);
|
||||
return info;
|
||||
}
|
||||
const thumbnailUrl =
|
||||
map.thumbnail?.small ?? map.images?.[0]?.small ?? null;
|
||||
const tier = extractSurfTier(map.leaderboards ?? []);
|
||||
const mapDate = map.info?.creationDate ?? null;
|
||||
const info: MapInfo = { mapId: map.id, thumbnailUrl, tier, mapDate };
|
||||
mapInfoCache.set(mapName, info);
|
||||
return info;
|
||||
} catch {
|
||||
const info: MapInfo = {
|
||||
mapId: null,
|
||||
thumbnailUrl: null,
|
||||
tier: null,
|
||||
mapDate: null,
|
||||
};
|
||||
mapInfoCache.set(mapName, info);
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMapThumbnail(mapName: string): Promise<string | null> {
|
||||
const info = await getMapInfo(mapName);
|
||||
return info.thumbnailUrl;
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
const RUSTFS_ENDPOINT = process.env.RUSTFS_ENDPOINT || "http://localhost:9000";
|
||||
const RUSTFS_ACCESS_KEY = process.env.RUSTFS_ACCESS_KEY || "minioadmin";
|
||||
const RUSTFS_SECRET_KEY = process.env.RUSTFS_SECRET_KEY || "minioadmin";
|
||||
const RUSTFS_BUCKET = process.env.RUSTFS_BUCKET || "surf";
|
||||
const RUSTFS_PUBLIC = (process.env.RUSTFS_PUBLIC || "true") === "true";
|
||||
|
||||
let client: S3Client | null = null;
|
||||
|
||||
function getRustFsClient(): S3Client {
|
||||
if (!client) {
|
||||
client = new S3Client({
|
||||
endpoint: RUSTFS_ENDPOINT,
|
||||
region: "us-east-1",
|
||||
credentials: {
|
||||
accessKeyId: RUSTFS_ACCESS_KEY,
|
||||
secretAccessKey: RUSTFS_SECRET_KEY,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
function getPublicUrl(key: string): string {
|
||||
if (RUSTFS_PUBLIC) {
|
||||
return `${RUSTFS_ENDPOINT}/${RUSTFS_BUCKET}/${key}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function getObjectUrl(key: string): string {
|
||||
const publicUrl = getPublicUrl(key);
|
||||
if (publicUrl) return publicUrl;
|
||||
return `/api/videos/file/${encodeURIComponent(key)}`;
|
||||
}
|
||||
|
||||
export async function uploadObject(
|
||||
key: string,
|
||||
body: Buffer,
|
||||
contentType: string,
|
||||
): Promise<void> {
|
||||
const s3 = getRustFsClient();
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: RUSTFS_BUCKET,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteObject(key: string): Promise<void> {
|
||||
const s3 = getRustFsClient();
|
||||
await s3.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: RUSTFS_BUCKET,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPresignedUploadUrl(
|
||||
key: string,
|
||||
contentType: string,
|
||||
expiresInSeconds = 3600,
|
||||
): Promise<string> {
|
||||
const s3 = getRustFsClient();
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: RUSTFS_BUCKET,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
});
|
||||
return getSignedUrl(s3, command, { expiresIn: expiresInSeconds });
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "surfnathanrip-frontend",
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.5.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.4",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^6.3.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.61.0", "", { "os": "android", "cpu": "arm" }, "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.61.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.61.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.61.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.61.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.61.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.61.0", "", { "os": "linux", "cpu": "arm" }, "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.61.0", "", { "os": "linux", "cpu": "arm" }, "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.61.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.61.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.61.0", "", { "os": "linux", "cpu": "none" }, "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.61.0", "", { "os": "linux", "cpu": "none" }, "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.61.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.61.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.61.0", "", { "os": "linux", "cpu": "none" }, "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.61.0", "", { "os": "linux", "cpu": "none" }, "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.61.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.61.0", "", { "os": "linux", "cpu": "x64" }, "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.61.0", "", { "os": "linux", "cpu": "x64" }, "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.61.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.61.0", "", { "os": "none", "cpu": "arm64" }, "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.61.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.61.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.61.0", "", { "os": "win32", "cpu": "x64" }, "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.61.0", "", { "os": "win32", "cpu": "x64" }, "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.0", "", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.16", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
"autoprefixer": ["autoprefixer@10.5.0", "", { "dependencies": { "browserslist": "^4.28.2", "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.33", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.364", "", {}, "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
|
||||
|
||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
"react-router": ["react-router@7.16.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A=="],
|
||||
|
||||
"react-router-dom": ["react-router-dom@7.16.0", "", { "dependencies": { "react-router": "7.16.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA=="],
|
||||
|
||||
"rollup": ["rollup@4.61.0", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.61.0", "@rollup/rollup-android-arm64": "4.61.0", "@rollup/rollup-darwin-arm64": "4.61.0", "@rollup/rollup-darwin-x64": "4.61.0", "@rollup/rollup-freebsd-arm64": "4.61.0", "@rollup/rollup-freebsd-x64": "4.61.0", "@rollup/rollup-linux-arm-gnueabihf": "4.61.0", "@rollup/rollup-linux-arm-musleabihf": "4.61.0", "@rollup/rollup-linux-arm64-gnu": "4.61.0", "@rollup/rollup-linux-arm64-musl": "4.61.0", "@rollup/rollup-linux-loong64-gnu": "4.61.0", "@rollup/rollup-linux-loong64-musl": "4.61.0", "@rollup/rollup-linux-ppc64-gnu": "4.61.0", "@rollup/rollup-linux-ppc64-musl": "4.61.0", "@rollup/rollup-linux-riscv64-gnu": "4.61.0", "@rollup/rollup-linux-riscv64-musl": "4.61.0", "@rollup/rollup-linux-s390x-gnu": "4.61.0", "@rollup/rollup-linux-x64-gnu": "4.61.0", "@rollup/rollup-linux-x64-musl": "4.61.0", "@rollup/rollup-openbsd-x64": "4.61.0", "@rollup/rollup-openharmony-arm64": "4.61.0", "@rollup/rollup-win32-arm64-msvc": "4.61.0", "@rollup/rollup-win32-ia32-msvc": "4.61.0", "@rollup/rollup-win32-x64-gnu": "4.61.0", "@rollup/rollup-win32-x64-msvc": "4.61.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>surf.nathan.rip</title>
|
||||
</head>
|
||||
<body class="bg-gray-950 text-gray-100 antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#0ea5e9"/>
|
||||
<path d="M6 22 Q10 8 16 16 Q22 24 26 10" stroke="white" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 247 B |
@@ -0,0 +1,19 @@
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import Layout from "./components/Layout";
|
||||
import Home from "./pages/Home";
|
||||
import VideoDetail from "./pages/VideoDetail";
|
||||
import Upload from "./pages/Upload";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="video/:id" element={<VideoDetail />} />
|
||||
<Route path="upload" element={<Upload />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { VideoListItem, VideoDetail } from "../types/video";
|
||||
|
||||
const API_BASE = "/api";
|
||||
|
||||
function getAuthToken(): string | null {
|
||||
return localStorage.getItem("admin_token");
|
||||
}
|
||||
|
||||
function authHeaders(): HeadersInit {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export async function fetchVideos(): Promise<VideoListItem[]> {
|
||||
const res = await fetch(`${API_BASE}/videos`);
|
||||
if (!res.ok) throw new Error("Failed to fetch videos");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchVideo(id: string): Promise<VideoDetail> {
|
||||
const res = await fetch(`${API_BASE}/videos/${id}`);
|
||||
if (!res.ok) throw new Error("Failed to fetch video");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface UploadInitResponse {
|
||||
id: string;
|
||||
presignedUrls: {
|
||||
video: string;
|
||||
};
|
||||
videoUrl: string;
|
||||
mtvUrl: string;
|
||||
thumbnailUrl?: string;
|
||||
title: string;
|
||||
mapName: string;
|
||||
playerName: string;
|
||||
runTime: number;
|
||||
tier?: number | null;
|
||||
}
|
||||
|
||||
export async function uploadVideoInit(
|
||||
mtvFile: File,
|
||||
videoFileName: string,
|
||||
videoContentType: string,
|
||||
runDate?: string,
|
||||
): Promise<UploadInitResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append("mtv", mtvFile);
|
||||
formData.append("videoFileName", videoFileName);
|
||||
formData.append("videoContentType", videoContentType);
|
||||
if (runDate) formData.append("runDate", runDate);
|
||||
|
||||
const res = await fetch(`${API_BASE}/videos/upload-url`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 401)
|
||||
throw new Error("Unauthorized: Invalid admin token");
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Upload init failed");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function uploadVideoComplete(id: string): Promise<VideoDetail> {
|
||||
const res = await fetch(`${API_BASE}/videos/${id}/complete`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 401)
|
||||
throw new Error("Unauthorized: Invalid admin token");
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Upload completion failed");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteVideo(id: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/videos/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 401)
|
||||
throw new Error("Unauthorized: Invalid admin token");
|
||||
throw new Error("Failed to delete video");
|
||||
}
|
||||
}
|
||||
|
||||
export function formatRunTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toFixed(3).padStart(6, "0")}`;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Link, Outlet } from "react-router-dom";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="h-18 shrink-0 flex items-center border-b border-mom-border bg-mom-bg shadow-[1px_0_16px_#0009] sticky top-0 z-50">
|
||||
<div
|
||||
className="mx-auto px-12 w-full flex items-center justify-between"
|
||||
style={{ maxWidth: "1800px" }}
|
||||
>
|
||||
<Link
|
||||
to="/"
|
||||
className="text-lg font-bold tracking-tight no-underline hover:no-underline"
|
||||
>
|
||||
<span className="text-mom-accent">surf</span>
|
||||
<span className="text-white/90">.nathan.rip</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/upload"
|
||||
className="text-sm font-medium text-white/40 hover:text-mom-accent transition-colors no-underline"
|
||||
>
|
||||
Upload
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 py-9 overflow-y-auto">
|
||||
<div className="mx-auto px-12" style={{ maxWidth: "1800px" }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import { formatRunTime } from "../api/client";
|
||||
|
||||
interface RunStatsProps {
|
||||
jsonStats: string;
|
||||
runTime: number;
|
||||
}
|
||||
|
||||
interface SubsegmentStats {
|
||||
maxOverallSpeed?: number;
|
||||
maxHorizontalSpeed?: number;
|
||||
jumps?: number;
|
||||
strafes?: number;
|
||||
}
|
||||
|
||||
interface Subsegment {
|
||||
minorNum: number;
|
||||
timeReached: number;
|
||||
stats?: SubsegmentStats;
|
||||
}
|
||||
|
||||
interface Segment {
|
||||
subsegments: Subsegment[];
|
||||
segmentStats?: SubsegmentStats;
|
||||
}
|
||||
|
||||
interface TrackStats {
|
||||
maxOverallSpeed?: number;
|
||||
maxHorizontalSpeed?: number;
|
||||
jumps?: number;
|
||||
strafes?: number;
|
||||
}
|
||||
|
||||
function formatSpeedExact(v: number): string {
|
||||
return v.toFixed(1);
|
||||
}
|
||||
|
||||
function formatSpeedShort(v: number): string {
|
||||
if (v >= 1000) return `${(v / 1000).toFixed(1)}k`;
|
||||
return v.toFixed(0);
|
||||
}
|
||||
|
||||
interface SplitRow {
|
||||
num: number;
|
||||
split: number;
|
||||
cumulative: number;
|
||||
stats?: SubsegmentStats;
|
||||
}
|
||||
|
||||
function buildChapterRows(subs: Subsegment[], endTime: number): SplitRow[] {
|
||||
return subs.map((sub, i) => {
|
||||
const nextReached =
|
||||
i < subs.length - 1 ? subs[i + 1]!.timeReached : endTime;
|
||||
return {
|
||||
num: i + 1,
|
||||
split: nextReached - sub.timeReached,
|
||||
cumulative: nextReached,
|
||||
stats: sub.stats,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
type TimeMode = "split" | "cumulative";
|
||||
|
||||
function TimeToggle({
|
||||
mode,
|
||||
onChange,
|
||||
}: {
|
||||
mode: TimeMode;
|
||||
onChange: (m: TimeMode) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex rounded border border-white/10 bg-white/5 text-[11px]">
|
||||
<button
|
||||
onClick={() => onChange("split")}
|
||||
className={`px-2.5 py-1 rounded-l transition-colors ${mode === "split" ? "bg-mom-accent/20 text-mom-accent" : "text-white/30 hover:text-white/50"}`}
|
||||
>
|
||||
Split
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onChange("cumulative")}
|
||||
className={`px-2.5 py-1 rounded-r border-l border-white/10 transition-colors ${mode === "cumulative" ? "bg-mom-accent/20 text-mom-accent" : "text-white/30 hover:text-white/50"}`}
|
||||
>
|
||||
Cumulative
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpeedCell({ value }: { value?: number }) {
|
||||
if (value == null)
|
||||
return (
|
||||
<td className="pr-4 py-2 text-right font-mono text-[11px] text-gray-500" />
|
||||
);
|
||||
return (
|
||||
<td
|
||||
className="pr-4 py-2 text-right font-mono text-[11px] text-gray-400"
|
||||
title={`${formatSpeedExact(value)} u/s`}
|
||||
>
|
||||
{formatSpeedShort(value)} u/s
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RunStats({ jsonStats, runTime }: RunStatsProps) {
|
||||
let parsed: Record<string, any>;
|
||||
try {
|
||||
parsed = JSON.parse(jsonStats);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments: Segment[] = parsed.segments ?? [];
|
||||
const trackStats: TrackStats | undefined = parsed.trackStats;
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
return segments.length > 1 ? (
|
||||
<StageSplits
|
||||
segments={segments}
|
||||
runTime={runTime}
|
||||
trackStats={trackStats}
|
||||
/>
|
||||
) : (
|
||||
<ChapterSplits
|
||||
segment={segments[0]!}
|
||||
runTime={runTime}
|
||||
trackStats={trackStats}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ChapterSplits({
|
||||
segment,
|
||||
runTime,
|
||||
trackStats,
|
||||
}: {
|
||||
segment: Segment;
|
||||
runTime: number;
|
||||
trackStats?: TrackStats;
|
||||
}) {
|
||||
const [mode, setMode] = useState<TimeMode>("split");
|
||||
const rows = buildChapterRows(segment.subsegments, runTime);
|
||||
if (rows.length === 0) return null;
|
||||
const hasStats = rows.some((r) => r.stats?.maxOverallSpeed != null);
|
||||
|
||||
return (
|
||||
<div className="rounded border border-white/5 bg-white/5 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-white/5 flex items-center justify-between">
|
||||
<h2 className="text-sm font-medium text-white/50 uppercase tracking-wider">
|
||||
Chapter Splits
|
||||
</h2>
|
||||
<TimeToggle mode={mode} onChange={setMode} />
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/5 text-[10px] uppercase tracking-wider text-gray-500">
|
||||
<th className="text-left font-normal px-4 py-2">
|
||||
Chapter
|
||||
</th>
|
||||
<th className="text-right font-normal px-4 py-2 font-mono">
|
||||
{mode === "split" ? "Split" : "Cumulative"}
|
||||
</th>
|
||||
{hasStats && (
|
||||
<th className="text-right font-normal pr-4 py-2 font-mono">
|
||||
Peak
|
||||
</th>
|
||||
)}
|
||||
{hasStats && (
|
||||
<th className="text-right font-normal pr-4 py-2 font-mono">
|
||||
Jumps
|
||||
</th>
|
||||
)}
|
||||
{hasStats && (
|
||||
<th className="text-right font-normal pr-4 py-2 font-mono">
|
||||
Strafes
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
key={row.num}
|
||||
className="border-t border-white/5 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-2 text-gray-300">
|
||||
Chapter {row.num}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-mom-accent/80">
|
||||
{mode === "split"
|
||||
? formatRunTime(row.split)
|
||||
: formatRunTime(row.cumulative)}
|
||||
</td>
|
||||
{hasStats && (
|
||||
<SpeedCell value={row.stats?.maxOverallSpeed} />
|
||||
)}
|
||||
{hasStats && (
|
||||
<td className="pr-4 py-2 text-right font-mono text-[11px] text-gray-500">
|
||||
{row.stats?.jumps ?? ""}
|
||||
</td>
|
||||
)}
|
||||
{hasStats && (
|
||||
<td className="pr-4 py-2 text-right font-mono text-[11px] text-gray-500">
|
||||
{row.stats?.strafes ?? ""}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-white/10">
|
||||
<td className="px-4 py-2.5 text-gray-200 font-medium">
|
||||
Total
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono font-medium text-mom-accent">
|
||||
{formatRunTime(runTime)}
|
||||
</td>
|
||||
{hasStats && (
|
||||
<SpeedCell value={trackStats?.maxOverallSpeed} />
|
||||
)}
|
||||
{hasStats && (
|
||||
<td className="pr-4 py-2.5 text-right font-mono text-[11px] text-gray-400">
|
||||
{trackStats?.jumps ?? ""}
|
||||
</td>
|
||||
)}
|
||||
{hasStats && (
|
||||
<td className="pr-4 py-2.5 text-right font-mono text-[11px] text-gray-400">
|
||||
{trackStats?.strafes ?? ""}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StageSplits({
|
||||
segments,
|
||||
runTime,
|
||||
trackStats,
|
||||
}: {
|
||||
segments: Segment[];
|
||||
runTime: number;
|
||||
trackStats?: TrackStats;
|
||||
}) {
|
||||
const [mode, setMode] = useState<TimeMode>("split");
|
||||
|
||||
const stageRows: {
|
||||
num: number;
|
||||
split: number;
|
||||
cumulative: number;
|
||||
stats?: SubsegmentStats;
|
||||
chapters: SplitRow[];
|
||||
}[] = [];
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i]!;
|
||||
const stageStart = seg.subsegments[0]?.timeReached ?? 0;
|
||||
const stageEnd =
|
||||
i < segments.length - 1
|
||||
? (segments[i + 1]!.subsegments[0]?.timeReached ?? runTime)
|
||||
: runTime;
|
||||
const chapters =
|
||||
seg.subsegments.length > 1
|
||||
? buildChapterRows(seg.subsegments, stageEnd)
|
||||
: [];
|
||||
stageRows.push({
|
||||
num: i + 1,
|
||||
split: stageEnd - stageStart,
|
||||
cumulative: stageEnd,
|
||||
stats: seg.segmentStats,
|
||||
chapters,
|
||||
});
|
||||
}
|
||||
|
||||
const hasStats = stageRows.some((s) => s.stats?.maxOverallSpeed != null);
|
||||
|
||||
return (
|
||||
<div className="rounded border border-white/5 bg-white/5 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-white/5 flex items-center justify-between">
|
||||
<h2 className="text-sm font-medium text-white/50 uppercase tracking-wider">
|
||||
Stage Splits
|
||||
</h2>
|
||||
<TimeToggle mode={mode} onChange={setMode} />
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/5 text-[10px] uppercase tracking-wider text-gray-500">
|
||||
<th className="text-left font-normal px-4 py-2">
|
||||
Stage
|
||||
</th>
|
||||
<th className="text-right font-normal px-4 py-2 font-mono">
|
||||
{mode === "split" ? "Split" : "Cumulative"}
|
||||
</th>
|
||||
{hasStats && (
|
||||
<th className="text-right font-normal pr-4 py-2 font-mono">
|
||||
Peak
|
||||
</th>
|
||||
)}
|
||||
{hasStats && (
|
||||
<th className="text-right font-normal pr-4 py-2 font-mono">
|
||||
Jumps
|
||||
</th>
|
||||
)}
|
||||
{hasStats && (
|
||||
<th className="text-right font-normal pr-4 py-2 font-mono">
|
||||
Strafes
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stageRows.map((stage) => (
|
||||
<Fragment key={stage.num}>
|
||||
<tr className="border-t border-white/5 hover:bg-white/5 transition-colors">
|
||||
<td className="px-4 py-2 text-gray-300">
|
||||
Stage {stage.num}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-mom-accent/80">
|
||||
{mode === "split"
|
||||
? formatRunTime(stage.split)
|
||||
: formatRunTime(stage.cumulative)}
|
||||
</td>
|
||||
{hasStats && (
|
||||
<SpeedCell
|
||||
value={stage.stats?.maxOverallSpeed}
|
||||
/>
|
||||
)}
|
||||
{hasStats && (
|
||||
<td className="pr-4 py-2 text-right font-mono text-[11px] text-gray-500">
|
||||
{stage.stats?.jumps ?? ""}
|
||||
</td>
|
||||
)}
|
||||
{hasStats && (
|
||||
<td className="pr-4 py-2 text-right font-mono text-[11px] text-gray-500">
|
||||
{stage.stats?.strafes ?? ""}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{stage.chapters.map((ch) => (
|
||||
<tr
|
||||
key={`ch-${stage.num}-${ch.num}`}
|
||||
className="border-t border-white/3 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-1.5 pl-8 text-xs text-gray-500">
|
||||
Ch {ch.num}
|
||||
</td>
|
||||
<td className="px-4 py-1.5 text-right font-mono text-xs text-gray-500">
|
||||
{mode === "split"
|
||||
? formatRunTime(ch.split)
|
||||
: formatRunTime(ch.cumulative)}
|
||||
</td>
|
||||
{hasStats && (
|
||||
<td
|
||||
className="pr-4 py-1.5 text-right font-mono text-[10px] text-gray-600"
|
||||
title={
|
||||
ch.stats?.maxOverallSpeed !=
|
||||
null
|
||||
? `${formatSpeedExact(ch.stats.maxOverallSpeed)} u/s`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{ch.stats?.maxOverallSpeed != null
|
||||
? `${formatSpeedShort(ch.stats.maxOverallSpeed)} u/s`
|
||||
: ""}
|
||||
</td>
|
||||
)}
|
||||
{hasStats && (
|
||||
<td className="pr-4 py-1.5 text-right font-mono text-[10px] text-gray-600">
|
||||
{ch.stats?.jumps ?? ""}
|
||||
</td>
|
||||
)}
|
||||
{hasStats && (
|
||||
<td className="pr-4 py-1.5 text-right font-mono text-[10px] text-gray-600">
|
||||
{ch.stats?.strafes ?? ""}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-white/10">
|
||||
<td className="px-4 py-2.5 text-gray-200 font-medium">
|
||||
Total
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono font-medium text-mom-accent">
|
||||
{formatRunTime(runTime)}
|
||||
</td>
|
||||
{hasStats && (
|
||||
<SpeedCell value={trackStats?.maxOverallSpeed} />
|
||||
)}
|
||||
{hasStats && (
|
||||
<td className="pr-4 py-2.5 text-right font-mono text-[11px] text-gray-400">
|
||||
{trackStats?.jumps ?? ""}
|
||||
</td>
|
||||
)}
|
||||
{hasStats && (
|
||||
<td className="pr-4 py-2.5 text-right font-mono text-[11px] text-gray-400">
|
||||
{trackStats?.strafes ?? ""}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import { useState, useCallback, type FormEvent } from "react";
|
||||
import { uploadVideoInit, uploadVideoComplete } from "../api/client";
|
||||
|
||||
function UploadDropzone({
|
||||
accept,
|
||||
label,
|
||||
sublabel,
|
||||
required,
|
||||
file,
|
||||
onFileChange,
|
||||
}: {
|
||||
accept: string;
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
required?: boolean;
|
||||
file: File | null;
|
||||
onFileChange: (file: File | null) => void;
|
||||
}) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const dropped = e.dataTransfer.files[0];
|
||||
if (dropped) onFileChange(dropped);
|
||||
},
|
||||
[onFileChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold uppercase tracking-[0.15em] text-white/35 mb-2">
|
||||
{label} {required && <span className="text-red-400/50">*</span>}
|
||||
</label>
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = accept;
|
||||
input.onchange = () =>
|
||||
onFileChange(input.files?.[0] ?? null);
|
||||
input.click();
|
||||
}}
|
||||
className={`relative cursor-pointer rounded-lg border-2 border-dashed transition-all duration-200
|
||||
${
|
||||
file
|
||||
? "border-mom-accent/40 bg-mom-accent/5"
|
||||
: isDragOver
|
||||
? "border-mom-accent/40 bg-mom-accent/5"
|
||||
: "border-white/7 bg-white/2 hover:border-white/15 hover:bg-white/3"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="px-5 py-6 text-center">
|
||||
{file ? (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<svg
|
||||
className="w-5 h-5 text-mom-accent"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-white/70 font-medium truncate max-w-full">
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-white/25">
|
||||
{(file.size / 1024 / 1024).toFixed(1)} MB -
|
||||
click or drop to replace
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<svg
|
||||
className="w-6 h-6 text-white/15"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-white/30">
|
||||
Drop file here or click to browse
|
||||
</span>
|
||||
{sublabel && (
|
||||
<span className="text-[10px] text-white/15">
|
||||
{sublabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{file && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFileChange(null);
|
||||
}}
|
||||
className="mt-1.5 text-[10px] text-white/20 hover:text-red-400/60 transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressBar({
|
||||
progress,
|
||||
uploading,
|
||||
}: {
|
||||
progress: number;
|
||||
uploading: boolean;
|
||||
}) {
|
||||
if (!uploading) return null;
|
||||
return (
|
||||
<div className="w-full rounded-full bg-white/5 h-2 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-mom-accent rounded-full transition-all duration-300 ease-out"
|
||||
style={{ width: `${Math.min(progress, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UploadForm() {
|
||||
const [authed, setAuthed] = useState(
|
||||
() => !!localStorage.getItem("admin_token"),
|
||||
);
|
||||
const [tokenInput, setTokenInput] = useState("");
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [mtvFile, setMtvFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleAuth = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!tokenInput.trim()) return;
|
||||
|
||||
localStorage.setItem("admin_token", tokenInput.trim());
|
||||
setAuthError(null);
|
||||
|
||||
fetch("/api/videos/upload-url", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${tokenInput.trim()}` },
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem("admin_token");
|
||||
setAuthError("Invalid token");
|
||||
setAuthed(false);
|
||||
} else {
|
||||
setAuthed(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setAuthed(true);
|
||||
});
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("admin_token");
|
||||
setAuthed(false);
|
||||
setTokenInput("");
|
||||
};
|
||||
|
||||
if (!authed) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="mb-10">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-white/90 mb-2">
|
||||
Upload Surf Run
|
||||
</h1>
|
||||
<p className="text-sm text-white/30 leading-relaxed">
|
||||
Authenticate to access the upload page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{authError && (
|
||||
<div className="mb-5 p-4 rounded-lg border border-red-500/20 bg-red-500/5 text-red-400/80 text-sm">
|
||||
{authError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleAuth} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold uppercase tracking-[0.15em] text-white/35 mb-2">
|
||||
Admin Token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={tokenInput}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
placeholder="Enter admin token"
|
||||
className="w-full rounded-lg border border-white/7 bg-white/3 px-4 py-2.5 text-sm text-white/80 placeholder:text-white/15 focus:border-mom-accent/40 focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-lg bg-mom-accent/15 border border-mom-accent/25 px-4 py-3 text-sm font-semibold text-mom-accent hover:bg-mom-accent/25 hover:border-mom-accent/35 transition-all duration-200"
|
||||
>
|
||||
Authenticate
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!videoFile || !mtvFile) {
|
||||
setError("Video and .mtv files are required");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setUploading(true);
|
||||
setSuccess(false);
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
setProgress(5);
|
||||
const runDate = mtvFile.lastModified
|
||||
? new Date(mtvFile.lastModified).toISOString()
|
||||
: undefined;
|
||||
|
||||
const initRes = await uploadVideoInit(
|
||||
mtvFile,
|
||||
videoFile.name,
|
||||
videoFile.type || "video/webm",
|
||||
runDate,
|
||||
);
|
||||
|
||||
const totalSize = videoFile.size;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
if (e.lengthComputable) {
|
||||
setProgress(
|
||||
5 + Math.round((e.loaded / totalSize) * 90),
|
||||
);
|
||||
}
|
||||
});
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||
else
|
||||
reject(
|
||||
new Error(
|
||||
`Upload failed with status ${xhr.status}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
xhr.addEventListener("error", () =>
|
||||
reject(new Error("Upload failed")),
|
||||
);
|
||||
xhr.open("PUT", initRes.presignedUrls.video);
|
||||
xhr.setRequestHeader(
|
||||
"Content-Type",
|
||||
videoFile.type || "video/webm",
|
||||
);
|
||||
xhr.send(videoFile);
|
||||
});
|
||||
|
||||
setProgress(98);
|
||||
await uploadVideoComplete(initRes.id);
|
||||
setProgress(100);
|
||||
|
||||
setSuccess(true);
|
||||
setVideoFile(null);
|
||||
setMtvFile(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Upload failed");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="mb-10 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-white/90 mb-2">
|
||||
Upload Surf Run
|
||||
</h1>
|
||||
<p className="text-sm text-white/30 leading-relaxed">
|
||||
Drop your replay video and .mtv file. Map name, player,
|
||||
run time, and stage splits are parsed from the .mtv
|
||||
automatically. The run date is taken from the .mtv file.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-[10px] uppercase tracking-[0.15em] text-white/20 hover:text-white/50 transition-colors shrink-0 mt-2"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-5 p-4 rounded-lg border border-red-500/20 bg-red-500/5 text-red-400/80 text-sm flex items-start gap-3">
|
||||
<svg
|
||||
className="w-4 h-4 mt-0.5 shrink-0 text-red-400/60"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-5 p-4 rounded-lg border border-green-500/20 bg-green-500/5 text-green-400/80 text-sm flex items-start gap-3">
|
||||
<svg
|
||||
className="w-4 h-4 mt-0.5 shrink-0 text-green-400/60"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
Upload successful. .mtv metadata parsed automatically.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
<div className="rounded-xl border border-white/7 bg-white/2 p-6 space-y-5">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-[0.15em] text-white/35 mb-1">
|
||||
Files
|
||||
</h2>
|
||||
|
||||
<UploadDropzone
|
||||
accept="video/*"
|
||||
label="Video"
|
||||
sublabel="MP4, WebM, etc."
|
||||
required
|
||||
file={videoFile}
|
||||
onFileChange={setVideoFile}
|
||||
/>
|
||||
|
||||
<UploadDropzone
|
||||
accept=".mtv"
|
||||
label=".mtv Replay"
|
||||
sublabel="Momentum Mod replay file"
|
||||
required
|
||||
file={mtvFile}
|
||||
onFileChange={setMtvFile}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{uploading && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs text-white/40">
|
||||
<span>
|
||||
{success ? "Complete!" : "Uploading..."}
|
||||
</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
progress={progress}
|
||||
uploading={uploading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={uploading || !videoFile || !mtvFile}
|
||||
className="w-full rounded-lg bg-mom-accent/15 border border-mom-accent/25 px-4 py-3 text-sm font-semibold text-mom-accent hover:bg-mom-accent/25 hover:border-mom-accent/35 disabled:opacity-30 disabled:cursor-not-allowed transition-all duration-200"
|
||||
>
|
||||
{uploading ? "Uploading..." : "Upload"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import type { VideoListItem } from "../types/video";
|
||||
import { formatRunTime } from "../api/client";
|
||||
|
||||
const TIER_COLORS: Record<number, string> = {
|
||||
1: "#4caf50",
|
||||
2: "#8bc34a",
|
||||
3: "#ffc107",
|
||||
4: "#ff9800",
|
||||
5: "#f44336",
|
||||
6: "#e91e63",
|
||||
7: "#9c27b0",
|
||||
8: "#673ab7",
|
||||
9: "#ff5722",
|
||||
10: "#880e4f",
|
||||
};
|
||||
|
||||
function TierBadge({ tier }: { tier: number | null | undefined }) {
|
||||
if (tier == null) return null;
|
||||
const color = TIER_COLORS[tier] ?? "#888";
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-8 h-7 rounded border border-white/10 shadow items-center justify-center px-1.5"
|
||||
style={{ background: `linear-gradient(${color}b3, ${color}bf)` }}
|
||||
>
|
||||
<span
|
||||
className="font-bold text-white leading-none"
|
||||
style={{
|
||||
fontSize: "0.875rem",
|
||||
textShadow: "0 1px 3px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
T{tier}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatMapName(mapName: string): { prefix: string; name: string } {
|
||||
const underscoreIdx = mapName.indexOf("_");
|
||||
if (underscoreIdx >= 0) {
|
||||
return {
|
||||
prefix: mapName.slice(0, underscoreIdx).toUpperCase(),
|
||||
name: mapName.slice(underscoreIdx + 1).toUpperCase(),
|
||||
};
|
||||
}
|
||||
return { prefix: "", name: mapName.toUpperCase() };
|
||||
}
|
||||
|
||||
export default function VideoCard({ video }: { video: VideoListItem }) {
|
||||
const { prefix, name: mapName } = formatMapName(video.mapName);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/video/${video.id}`}
|
||||
className="group flex h-full flex-col shadow no-underline"
|
||||
>
|
||||
<div className="relative aspect-video overflow-hidden rounded-t">
|
||||
{video.thumbnailUrl ? (
|
||||
<img
|
||||
src={video.thumbnailUrl}
|
||||
alt={video.title}
|
||||
className="h-full w-full object-cover transition-all group-hover:brightness-110"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-white/5" />
|
||||
)}
|
||||
|
||||
{video.tier != null && (
|
||||
<div className="absolute top-2 left-2">
|
||||
<TierBadge tier={video.tier} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-2 right-2 rounded border border-white/10 px-2 py-0.5 bg-mom-bg">
|
||||
<span className="font-mono text-xs text-white/90">
|
||||
{formatRunTime(video.runTime)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex grow flex-col rounded-b border border-white/5 bg-white/5 p-4 pt-3.5 backdrop-blur-xl transition-colors group-hover:bg-white/10">
|
||||
<div className="mb-2 flex items-center min-h-8">
|
||||
<p
|
||||
className="font-bold leading-none text-gray-100 truncate"
|
||||
style={{ fontSize: "2rem" }}
|
||||
>
|
||||
{prefix && (
|
||||
<span className="mr-1.5 text-gray-400">
|
||||
{prefix}{" "}
|
||||
</span>
|
||||
)}
|
||||
{mapName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex grow items-center justify-between text-sm">
|
||||
<p className="text-gray-400">
|
||||
Surfed by{" "}
|
||||
<span className="text-gray-100">
|
||||
{video.playerName}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-gray-500">
|
||||
{new Date(video.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState, useRef } from "react";
|
||||
import type { VideoDetail } from "../types/video";
|
||||
import { formatRunTime } from "../api/client";
|
||||
|
||||
export default function VideoPlayer({ video }: { video: VideoDetail }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-white/5 overflow-hidden">
|
||||
<div className="aspect-video bg-black relative">
|
||||
{!loaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10">
|
||||
{video.thumbnailUrl ? (
|
||||
<img
|
||||
src={video.thumbnailUrl}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-cover blur-sm opacity-50"
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative z-20 flex flex-col items-center gap-2">
|
||||
<div className="w-10 h-10 rounded-full border-2 border-white/20 border-t-white/80 animate-spin" />
|
||||
<span className="text-xs text-white/40">
|
||||
Loading video…
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={video.videoUrl}
|
||||
controls
|
||||
className="w-full h-full"
|
||||
poster={video.thumbnailUrl}
|
||||
onCanPlay={() => setLoaded(true)}
|
||||
>
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 flex items-center justify-between border-t border-white/5 bg-white/3">
|
||||
<a
|
||||
href={video.mtvUrl}
|
||||
download
|
||||
className="inline-flex items-center gap-2 rounded-md bg-white/4 border border-white/7 px-3 py-1.5 text-xs font-medium text-white/50 hover:bg-white/8 hover:text-white/80 hover:border-white/15 transition-colors no-underline"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
.mtv replay
|
||||
</a>
|
||||
|
||||
<span className="font-mono text-sm text-white/80">
|
||||
{formatRunTime(video.runTime)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import type { VideoDetail } from "../types/video";
|
||||
import { formatRunTime } from "../api/client";
|
||||
import RunStats from "./RunStats";
|
||||
|
||||
const TIER_COLORS: Record<number, string> = {
|
||||
1: "#4caf50",
|
||||
2: "#8bc34a",
|
||||
3: "#ffc107",
|
||||
4: "#ff9800",
|
||||
5: "#f44336",
|
||||
6: "#e91e63",
|
||||
7: "#9c27b0",
|
||||
8: "#673ab7",
|
||||
9: "#ff5722",
|
||||
10: "#880e4f",
|
||||
};
|
||||
|
||||
function TierBadge({ tier }: { tier: number | null | undefined }) {
|
||||
if (tier == null) return null;
|
||||
const color = TIER_COLORS[tier] ?? "#888";
|
||||
return (
|
||||
<Link to={`/?tier=${tier}`} className="inline-block no-underline">
|
||||
<div
|
||||
className="flex min-w-8 h-7 rounded border border-white/10 shadow items-center justify-center px-1.5 hover:brightness-125 transition-all"
|
||||
style={{
|
||||
background: `linear-gradient(${color}b3, ${color}bf)`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="font-bold text-white leading-none"
|
||||
style={{
|
||||
fontSize: "0.875rem",
|
||||
textShadow: "0 1px 3px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
T{tier}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function getMomUrl(mapName: string): string {
|
||||
return `https://dashboard.momentum-mod.org/maps/${mapName}`;
|
||||
}
|
||||
|
||||
export default function VideoSidebar({ video }: { video: VideoDetail }) {
|
||||
const hasStats = video.jsonStats && video.jsonStats.length > 2;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded border border-white/5 bg-white/5 p-4">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-gray-500 mb-1">
|
||||
Map
|
||||
</div>
|
||||
<a
|
||||
href={getMomUrl(video.mapName)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-100 font-medium text-sm no-underline hover:text-mom-accent transition-colors"
|
||||
>
|
||||
{video.mapName}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-gray-500 mb-1">
|
||||
Time
|
||||
</div>
|
||||
<div className="font-mono text-white/90">
|
||||
{formatRunTime(video.runTime)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-gray-500 mb-1">
|
||||
Player
|
||||
</div>
|
||||
<div className="text-gray-200 text-sm">
|
||||
{video.playerName}
|
||||
</div>
|
||||
</div>
|
||||
{video.tier != null ? (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-gray-500 mb-1">
|
||||
Tier
|
||||
</div>
|
||||
<TierBadge tier={video.tier} />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-gray-500 mb-1">
|
||||
Ticks
|
||||
</div>
|
||||
<div className="font-mono text-gray-400 text-sm">
|
||||
{video.totalTicks}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{video.description && (
|
||||
<div className="mt-3 pt-3 border-t border-white/5">
|
||||
<p className="text-sm text-gray-400 whitespace-pre-wrap leading-relaxed">
|
||||
{video.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasStats && (
|
||||
<RunStats
|
||||
jsonStats={video.jsonStats!}
|
||||
runTime={video.runTime}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import VideoCard from "../components/VideoCard";
|
||||
import { fetchVideos } from "../api/client";
|
||||
import type { VideoListItem } from "../types/video";
|
||||
|
||||
const ALL_TIERS = "all";
|
||||
|
||||
export default function Home() {
|
||||
const [videos, setVideos] = useState<VideoListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const tierFilter = searchParams.get("tier") || ALL_TIERS;
|
||||
|
||||
const setTierFilter = (value: string) => {
|
||||
if (value === ALL_TIERS) {
|
||||
setSearchParams({}, { replace: true });
|
||||
} else {
|
||||
setSearchParams({ tier: value }, { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchVideos()
|
||||
.then(setVideos)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const availableTiers = useMemo(() => {
|
||||
const tiers = new Set<number>();
|
||||
for (const v of videos) {
|
||||
if (v.tier != null) tiers.add(v.tier);
|
||||
}
|
||||
return Array.from(tiers).sort((a, b) => a - b);
|
||||
}, [videos]);
|
||||
|
||||
const filteredVideos = useMemo(() => {
|
||||
if (tierFilter === ALL_TIERS) return videos;
|
||||
const tier = parseInt(tierFilter, 10);
|
||||
return videos.filter((v) => v.tier === tier);
|
||||
}, [videos, tierFilter]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="text-white/20 text-sm">Loading…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="text-red-400/60 text-sm">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{videos.length === 0 ? (
|
||||
<div className="text-center py-24">
|
||||
<p className="text-lg text-white/20 mb-1">
|
||||
No runs uploaded yet
|
||||
</p>
|
||||
<p className="text-sm text-white/15">
|
||||
Upload your first surf run via the{" "}
|
||||
<a href="/upload" className="text-mom-accent">
|
||||
upload page
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{availableTiers.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-6 flex-wrap">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.15em] text-white/30 mr-1">
|
||||
Tier
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setTierFilter(ALL_TIERS)}
|
||||
className={`px-2.5 py-1 rounded text-xs font-medium transition-all duration-150 ${
|
||||
tierFilter === ALL_TIERS
|
||||
? "bg-mom-accent/15 text-mom-accent border border-mom-accent/30"
|
||||
: "bg-white/3 text-white/35 border border-white/7 hover:text-white/50 hover:border-white/15"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{availableTiers.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTierFilter(String(t))}
|
||||
className={`px-2.5 py-1 rounded text-xs font-medium transition-all duration-150 ${
|
||||
tierFilter === String(t)
|
||||
? "bg-mom-accent/15 text-mom-accent border border-mom-accent/30"
|
||||
: "bg-white/3 text-white/35 border border-white/7 hover:text-white/50 hover:border-white/15"
|
||||
}`}
|
||||
>
|
||||
T{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredVideos.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-sm text-white/20">
|
||||
No runs match this tier filter
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{filteredVideos.map((video) => (
|
||||
<VideoCard key={video.id} video={video} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import UploadForm from "../components/UploadForm";
|
||||
|
||||
export default function Upload() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-6 py-10">
|
||||
<UploadForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import VideoSidebar from "../components/VideoSidebar";
|
||||
import { fetchVideo, formatRunTime } from "../api/client";
|
||||
import type { VideoDetail } from "../types/video";
|
||||
|
||||
function getMapDisplayName(mapName: string): string {
|
||||
return mapName.replace(/_/g, " ").toUpperCase();
|
||||
}
|
||||
|
||||
function getMomUrl(mapName: string): string {
|
||||
return `https://dashboard.momentum-mod.org/maps/${mapName}`;
|
||||
}
|
||||
|
||||
function SkeletonBlock({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded bg-white/5 animate-pulse ${className ?? ""}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PageSkeleton() {
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="flex items-baseline gap-3 mb-3">
|
||||
<SkeletonBlock className="h-8 w-64" />
|
||||
</div>
|
||||
<div className="flex flex-col lg:flex-row gap-4 lg:items-start">
|
||||
<div className="lg:w-1/2 min-w-0">
|
||||
<SkeletonBlock className="aspect-video w-full rounded-lg" />
|
||||
</div>
|
||||
<div className="lg:w-1/2 flex flex-col gap-4">
|
||||
<div className="rounded border border-white/5 bg-white/5 p-4">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<div>
|
||||
<SkeletonBlock className="h-2.5 w-8 mb-1.5" />
|
||||
<SkeletonBlock className="h-4 w-28" />
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonBlock className="h-2.5 w-8 mb-1.5" />
|
||||
<SkeletonBlock className="h-4 w-20" />
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonBlock className="h-2.5 w-10 mb-1.5" />
|
||||
<SkeletonBlock className="h-4 w-24" />
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonBlock className="h-2.5 w-8 mb-1.5" />
|
||||
<SkeletonBlock className="h-7 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VideoDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [video, setVideo] = useState<VideoDetail | null>(null);
|
||||
const [videoReady, setVideoReady] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
fetchVideo(id)
|
||||
.then(setVideo)
|
||||
.catch((err) => setError(err.message));
|
||||
}, [id]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-24 text-center">
|
||||
<p className="text-red-400/60 mb-4">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!video || !videoReady) {
|
||||
return (
|
||||
<>
|
||||
<PageSkeleton />
|
||||
{video && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={video.videoUrl}
|
||||
preload="auto"
|
||||
onCanPlay={() => setVideoReady(true)}
|
||||
className="fixed opacity-0 pointer-events-none"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="rounded-lg border border-white/5 bg-white/5 px-4 py-2 inline-block mb-3">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white/90">
|
||||
<a
|
||||
href={getMomUrl(video.mapName)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="no-underline text-white/90 hover:text-mom-accent transition-colors"
|
||||
>
|
||||
{getMapDisplayName(video.mapName)}
|
||||
</a>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-4 lg:items-start">
|
||||
<div className="lg:w-1/2 min-w-0">
|
||||
<div className="rounded-lg border border-white/5 overflow-hidden">
|
||||
<div className="aspect-video bg-black">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={video.videoUrl}
|
||||
controls
|
||||
className="w-full h-full"
|
||||
poster={video.thumbnailUrl}
|
||||
>
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center justify-between border-t border-white/5 bg-white/3">
|
||||
<a
|
||||
href={video.mtvUrl}
|
||||
download
|
||||
className="inline-flex items-center gap-2 rounded-md bg-white/4 border border-white/7 px-3 py-1.5 text-xs font-medium text-white/50 hover:bg-white/8 hover:text-white/80 hover:border-white/15 transition-colors no-underline"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
.mtv replay
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:w-1/2">
|
||||
<VideoSidebar video={video} />
|
||||
{video.previousPbs && video.previousPbs.length > 0 && (
|
||||
<div className="rounded border border-white/5 bg-white/5 p-4 mt-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
|
||||
Previous PBs
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{video.previousPbs.map((pb) => (
|
||||
<li
|
||||
key={pb.createdAt}
|
||||
className="text-sm text-white/60"
|
||||
>
|
||||
<span className="font-mono text-white/90">
|
||||
{formatRunTime(pb.runTime)}
|
||||
</span>
|
||||
{" · "}
|
||||
{new Date(
|
||||
pb.createdAt,
|
||||
).toLocaleDateString()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface PreviousPB {
|
||||
runTime: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface VideoListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
mapName: string;
|
||||
playerName: string;
|
||||
runTime: number;
|
||||
tier?: number | null;
|
||||
thumbnailUrl?: string;
|
||||
createdAt: string;
|
||||
previousPbs?: PreviousPB[];
|
||||
}
|
||||
|
||||
export interface VideoDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
mapName: string;
|
||||
playerName: string;
|
||||
steamId: string;
|
||||
runTime: number;
|
||||
totalTicks: number;
|
||||
tickInterval: number;
|
||||
videoKey: string;
|
||||
mtvKey: string;
|
||||
thumbnailKey?: string;
|
||||
tier?: number | null;
|
||||
videoUrl: string;
|
||||
mtvUrl: string;
|
||||
thumbnailUrl?: string;
|
||||
jsonStats?: string;
|
||||
createdAt: string;
|
||||
previousPbs?: PreviousPB[];
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/Layout.tsx","./src/components/RunStats.tsx","./src/components/UploadForm.tsx","./src/components/VideoCard.tsx","./src/components/VideoPlayer.tsx","./src/components/VideoSidebar.tsx","./src/pages/Home.tsx","./src/pages/Upload.tsx","./src/pages/VideoDetail.tsx","./src/types/video.ts"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3001",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user