From cbf68dbcedd602b766ee7753a19c67102b20bc3e Mon Sep 17 00:00:00 2001 From: vld_voiculescu Date: Tue, 3 Feb 2026 22:02:27 +0200 Subject: [PATCH] ETJump Maps DB --- .gitignore | 11 + SETUP.md | 53 + maps-api/.env.example | 18 + maps-api/bun.lock | 248 ++ maps-api/drizzle.config.ts | 10 + maps-api/package.json | 23 + maps-api/src/db/index.ts | 25 + maps-api/src/db/migrate.ts | 50 + maps-api/src/db/schema.ts | 43 + maps-api/src/index.ts | 59 + maps-api/src/middleware/auth.ts | 32 + maps-api/src/routes/maps.ts | 668 +++++ maps-api/src/services/bsp-parser.ts | 176 ++ maps-api/src/services/pk3-parser.ts | 47 + maps-api/src/services/storage.ts | 93 + maps-api/tsconfig.json | 17 + maps-web/index.html | 13 + maps-web/package-lock.json | 2714 ++++++++++++++++++++ maps-web/package.json | 25 + maps-web/postcss.config.js | 6 + maps-web/public/etjump-logo.svg | 1 + maps-web/src/App.tsx | 17 + maps-web/src/api/maps.ts | 209 ++ maps-web/src/components/Layout.tsx | 25 + maps-web/src/components/MapTable.tsx | 776 ++++++ maps-web/src/components/SecretKeyInput.tsx | 152 ++ maps-web/src/context/AuthContext.tsx | 61 + maps-web/src/main.tsx | 16 + maps-web/src/pages/Home.tsx | 309 +++ maps-web/src/pages/MapDetail.tsx | 233 ++ maps-web/src/pages/Upload.tsx | 687 +++++ maps-web/src/styles/globals.css | 11 + maps-web/src/vite-env.d.ts | 1 + maps-web/tailwind.config.js | 25 + maps-web/tsconfig.json | 21 + maps-web/tsconfig.node.json | 11 + maps-web/vite.config.ts | 15 + 37 files changed, 6901 insertions(+) create mode 100644 SETUP.md create mode 100644 maps-api/.env.example create mode 100644 maps-api/bun.lock create mode 100644 maps-api/drizzle.config.ts create mode 100644 maps-api/package.json create mode 100644 maps-api/src/db/index.ts create mode 100644 maps-api/src/db/migrate.ts create mode 100644 maps-api/src/db/schema.ts create mode 100644 maps-api/src/index.ts create mode 100644 maps-api/src/middleware/auth.ts create mode 100644 maps-api/src/routes/maps.ts create mode 100644 maps-api/src/services/bsp-parser.ts create mode 100644 maps-api/src/services/pk3-parser.ts create mode 100644 maps-api/src/services/storage.ts create mode 100644 maps-api/tsconfig.json create mode 100644 maps-web/index.html create mode 100644 maps-web/package-lock.json create mode 100644 maps-web/package.json create mode 100644 maps-web/postcss.config.js create mode 100644 maps-web/public/etjump-logo.svg create mode 100644 maps-web/src/App.tsx create mode 100644 maps-web/src/api/maps.ts create mode 100644 maps-web/src/components/Layout.tsx create mode 100644 maps-web/src/components/MapTable.tsx create mode 100644 maps-web/src/components/SecretKeyInput.tsx create mode 100644 maps-web/src/context/AuthContext.tsx create mode 100644 maps-web/src/main.tsx create mode 100644 maps-web/src/pages/Home.tsx create mode 100644 maps-web/src/pages/MapDetail.tsx create mode 100644 maps-web/src/pages/Upload.tsx create mode 100644 maps-web/src/styles/globals.css create mode 100644 maps-web/src/vite-env.d.ts create mode 100644 maps-web/tailwind.config.js create mode 100644 maps-web/tsconfig.json create mode 100644 maps-web/tsconfig.node.json create mode 100644 maps-web/vite.config.ts diff --git a/.gitignore b/.gitignore index 3b7bc1e..156ddc0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,13 @@ .idea +.claude/ builds/ + +# Maps API +maps-api/node_modules/ +maps-api/.env +maps-api/data/ +storage/ + +# Maps Web +maps-web/node_modules/ +maps-web/dist/ diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..6beff09 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,53 @@ +# ETJump Maps - Setup Guide + +## Prerequisites + +- [Bun](https://bun.sh/) (v1.0+) - JavaScript runtime and package manager +- [Node.js](https://nodejs.org/) (v18+) - required by the `sharp` image processing library + +```bash +# Linux / macOS +curl -fsSL https://bun.sh/install | bash + +# Windows (via PowerShell) +powershell -c "irm bun.sh/install.ps1 | iex" +``` + +## Backend (maps-api) + +```bash +cd maps-api +bun install +cp .env.example .env +``` + +Edit `.env` and set `UPLOAD_SECRET` to a secret key of your choice. The other defaults work out of the box for local development. + +```bash +# Create the database and tables +bun run db:migrate + +# Start the API (auto-reloads on changes) +bun run dev +``` + +The API runs at `http://localhost:3001`. + +The migration creates the SQLite database file and its tables. It's safe to run repeatedly - it only creates what doesn't exist yet. + +## Frontend (maps-web) + +```bash +cd maps-web +bun install +bun run dev +``` + +The frontend runs at `http://localhost:5173`. API calls to `/api/*` are automatically proxied to the backend during development. + +## Testing the Upload Flow + +1. Open `http://localhost:5173` +2. Click the key icon and enter the secret key you set in `.env` +3. Go to Upload, select a `.pk3` file, fill in details, and save +4. The map appears in the list with metadata extracted from the BSP file diff --git a/maps-api/.env.example b/maps-api/.env.example new file mode 100644 index 0000000..bc4f13a --- /dev/null +++ b/maps-api/.env.example @@ -0,0 +1,18 @@ +# Database (SQLite - just a file path) +DATABASE_PATH=./data/etjump-maps.db + +# Upload secret key (share with trusted uploaders) +UPLOAD_SECRET=your-secret-key-here + +# Storage path for PK3 files +STORAGE_PATH=../storage/maps + +# Server +PORT=3001 + +# CORS origins (comma-separated, defaults to localhost) +# CORS_ORIGINS=http://localhost:5173,http://localhost:3000 + +# File size limits (in MB) +# MAX_PK3_SIZE_MB=500 +# MAX_IMAGE_SIZE_MB=10 diff --git a/maps-api/bun.lock b/maps-api/bun.lock new file mode 100644 index 0000000..83d771c --- /dev/null +++ b/maps-api/bun.lock @@ -0,0 +1,248 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "etjump-maps-api", + "dependencies": { + "drizzle-orm": "^0.31.0", + "hono": "^4.4.0", + "jszip": "^3.10.1", + "sharp": "^0.34.5", + "tga": "^1.0.7", + }, + "devDependencies": { + "@types/bun": "latest", + "drizzle-kit": "^0.22.0", + "typescript": "^5.4.0", + }, + }, + }, + "packages": { + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="], + + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], + + "@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], + + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + + "debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "drizzle-kit": ["drizzle-kit@0.22.8", "", { "dependencies": { "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-VjI4wsJjk3hSqHSa3TwBf+uvH6M6pRHyxyoVbt935GUzP9tUR/BRZ+MhEJNgryqbzN2Za1KP0eJMTgKEPsalYQ=="], + + "drizzle-orm": ["drizzle-orm@0.31.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=3", "@electric-sql/pglite": ">=0.1.1", "@libsql/client": "*", "@neondatabase/serverless": ">=0.1", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": ">=18", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=13.2.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/react", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "react", "sql.js", "sqlite3"] }, "sha512-VGD9SH9aStF2z4QOTnVlVX/WghV/EnuEzTmsH3fSVp2E4fFgc8jl3viQrS/XUJx1ekW4rVVLJMH42SfGQdjX3Q=="], + + "esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="], + + "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], + + "get-tsconfig": ["get-tsconfig@4.13.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w=="], + + "hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], + + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], + + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], + + "ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + + "postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + + "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "restructure": ["restructure@2.0.1", "", {}, "sha512-e0dOpjm5DseomnXx2M5lpdZ5zoHqF1+bqdMJUohoYVVQa7cBdnk7fdmeI6byNWP/kiME72EeTiSypTCVnpLiDg=="], + + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "tga": ["tga@1.0.7", "", { "dependencies": { "debug": "^2.6.1", "restructure": "^2.0.0" } }, "sha512-GFVJwov5aJTMgh8U1QfaRheIELXo+dYc1qYIvQEIqZX4n+S6Fj/SDWsdbelHt7WP08xOR6W1z5aJQ+Ilh5gIeA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + + "esbuild-register/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + + "esbuild-register/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + } +} diff --git a/maps-api/drizzle.config.ts b/maps-api/drizzle.config.ts new file mode 100644 index 0000000..e5c22d4 --- /dev/null +++ b/maps-api/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/db/schema.ts", + out: "./src/db/migrations", + dialect: "sqlite", + dbCredentials: { + url: "./data/etjump-maps.db", + }, +}); diff --git a/maps-api/package.json b/maps-api/package.json new file mode 100644 index 0000000..ff3c732 --- /dev/null +++ b/maps-api/package.json @@ -0,0 +1,23 @@ +{ + "name": "etjump-maps-api", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "db:generate": "drizzle-kit generate", + "db:migrate": "bun src/db/migrate.ts", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "drizzle-orm": "^0.31.0", + "hono": "^4.4.0", + "jszip": "^3.10.1", + "sharp": "^0.34.5" + }, + "devDependencies": { + "@types/bun": "latest", + "drizzle-kit": "^0.22.0", + "typescript": "^5.4.0" + } +} diff --git a/maps-api/src/db/index.ts b/maps-api/src/db/index.ts new file mode 100644 index 0000000..34e328c --- /dev/null +++ b/maps-api/src/db/index.ts @@ -0,0 +1,25 @@ +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { Database } from "bun:sqlite"; +import { existsSync, mkdirSync } from "fs"; +import { dirname } from "path"; +import * as schema from "./schema"; + +const DB_PATH = process.env.DATABASE_PATH || "./data/etjump-maps.db"; + +// Ensure the data directory exists +const dbDir = dirname(DB_PATH); +if (!existsSync(dbDir)) { + mkdirSync(dbDir, { recursive: true }); +} + +const sqlite = new Database(DB_PATH); + +// Enable foreign keys +sqlite.exec("PRAGMA foreign_keys = ON;"); +// Use WAL mode for better concurrent read/write performance +sqlite.exec("PRAGMA journal_mode = WAL;"); +// Wait up to 5 seconds if database is locked by another writer +sqlite.exec("PRAGMA busy_timeout = 5000;"); + +export const db = drizzle(sqlite, { schema }); +export { sqlite }; diff --git a/maps-api/src/db/migrate.ts b/maps-api/src/db/migrate.ts new file mode 100644 index 0000000..debc15b --- /dev/null +++ b/maps-api/src/db/migrate.ts @@ -0,0 +1,50 @@ +import { sqlite } from "./index"; + +// Create tables if they don't exist +sqlite.exec(` + CREATE TABLE IF NOT EXISTS maps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT NOT NULL, + file_path TEXT NOT NULL, + file_size INTEGER NOT NULL, + checksum TEXT NOT NULL, + bsp_name TEXT, + map_name TEXT, + features TEXT DEFAULT '[]', + levelshot_path TEXT, + display_name TEXT, + author TEXT, + release_year INTEGER, + difficulty TEXT, + map_types TEXT DEFAULT '[]', + tags TEXT DEFAULT '[]', + download_count INTEGER DEFAULT 0, + is_published INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_maps_checksum ON maps(checksum); + CREATE INDEX IF NOT EXISTS idx_maps_is_published ON maps(is_published); +`); + +// Add new columns if they don't exist (for existing databases) +const addColumnIfNotExists = (table: string, column: string, type: string) => { + try { + sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`); + console.log(`Added column ${column} to ${table}`); + } catch (e: any) { + // Column already exists, ignore + if (!e.message.includes("duplicate column")) { + throw e; + } + } +}; + +addColumnIfNotExists("maps", "features", "TEXT DEFAULT '[]'"); +addColumnIfNotExists("maps", "levelshot_path", "TEXT"); +addColumnIfNotExists("maps", "release_year", "INTEGER"); +addColumnIfNotExists("maps", "map_types", "TEXT DEFAULT '[]'"); + +console.log("Database migrated successfully!"); +console.log("Database location:", process.env.DATABASE_PATH || "./data/etjump-maps.db"); diff --git a/maps-api/src/db/schema.ts b/maps-api/src/db/schema.ts new file mode 100644 index 0000000..3c0f40b --- /dev/null +++ b/maps-api/src/db/schema.ts @@ -0,0 +1,43 @@ +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; +import { sql } from "drizzle-orm"; + +export const maps = sqliteTable("maps", { + id: integer("id").primaryKey({ autoIncrement: true }), + + // File info + filename: text("filename").notNull(), + filePath: text("file_path").notNull(), + fileSize: integer("file_size").notNull(), + checksum: text("checksum").notNull().unique(), + + // Extracted from BSP + bspName: text("bsp_name"), + mapName: text("map_name"), + + // ETJump features detected from BSP (stored as JSON array) + features: text("features", { mode: "json" }).$type().default([]), + + // Levelshot image path (extracted from PK3) + levelshotPath: text("levelshot_path"), + + // User-provided metadata + displayName: text("display_name"), + author: text("author"), + releaseYear: integer("release_year"), + difficulty: text("difficulty"), + mapTypes: text("map_types", { mode: "json" }).$type().default([]), // gamma, customs, originals + tags: text("tags", { mode: "json" }).$type().default([]), + + // Stats + downloadCount: integer("download_count").default(0), + + // Status (SQLite uses 0/1 for boolean) + isPublished: integer("is_published", { mode: "boolean" }).default(false), + + // Timestamps (stored as ISO strings) + createdAt: text("created_at").default(sql`(datetime('now'))`), + updatedAt: text("updated_at").default(sql`(datetime('now'))`), +}); + +export type Map = typeof maps.$inferSelect; +export type NewMap = typeof maps.$inferInsert; diff --git a/maps-api/src/index.ts b/maps-api/src/index.ts new file mode 100644 index 0000000..1f84c26 --- /dev/null +++ b/maps-api/src/index.ts @@ -0,0 +1,59 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { logger } from "hono/logger"; +import mapsRoutes from "./routes/maps"; +import { ensureStorageDir } from "./services/storage"; + +const app = new Hono(); + +// Global error handler +app.onError((err, c) => { + console.error("Unhandled error:", err); + return c.json({ success: false, error: "Internal server error" }, 500); +}); + +// Middleware +app.use("*", logger()); + +const CORS_ORIGINS = process.env.CORS_ORIGINS + ? process.env.CORS_ORIGINS.split(",").map((o) => o.trim()) + : ["http://localhost:5173", "http://localhost:3000"]; + +app.use( + "*", + cors({ + origin: CORS_ORIGINS, + allowMethods: ["GET", "POST", "PUT", "DELETE"], + allowHeaders: ["Content-Type", "X-Upload-Key"], + }) +); + +// Health check +app.get("/", (c) => { + return c.json({ + name: "ETJump Maps API", + version: "0.1.0", + status: "ok", + }); +}); + +// Routes +app.route("/api/maps", mapsRoutes); + +// Ensure storage directory exists +try { + await ensureStorageDir(); +} catch (err) { + console.error("Failed to create storage directories:", err); + process.exit(1); +} + +// Start server +const port = parseInt(process.env.PORT || "3001"); + +console.log(`ETJump Maps API starting on port ${port}...`); + +export default { + port, + fetch: app.fetch, +}; diff --git a/maps-api/src/middleware/auth.ts b/maps-api/src/middleware/auth.ts new file mode 100644 index 0000000..fa9f92c --- /dev/null +++ b/maps-api/src/middleware/auth.ts @@ -0,0 +1,32 @@ +import { Context, Next } from "hono"; +import { timingSafeEqual } from "crypto"; + +const UPLOAD_SECRET = process.env.UPLOAD_SECRET; + +if (!UPLOAD_SECRET) { + console.warn("WARNING: UPLOAD_SECRET is not set. Protected routes will reject all requests."); +} + +export async function requireAuth(c: Context, next: Next) { + const uploadKey = c.req.header("X-Upload-Key"); + + if (!UPLOAD_SECRET) { + return c.json( + { success: false, error: "Server misconfigured: no upload secret set" }, + 500 + ); + } + + if ( + !uploadKey || + uploadKey.length !== UPLOAD_SECRET.length || + !timingSafeEqual(Buffer.from(uploadKey), Buffer.from(UPLOAD_SECRET)) + ) { + return c.json( + { success: false, error: "Unauthorized: invalid or missing upload key" }, + 401 + ); + } + + await next(); +} diff --git a/maps-api/src/routes/maps.ts b/maps-api/src/routes/maps.ts new file mode 100644 index 0000000..ab654c8 --- /dev/null +++ b/maps-api/src/routes/maps.ts @@ -0,0 +1,668 @@ +import { Hono } from "hono"; +import { eq, asc, sql, and } from "drizzle-orm"; +import sharp from "sharp"; +import { db } from "../db"; +import { maps, type NewMap } from "../db/schema"; +import { requireAuth } from "../middleware/auth"; +import { parsePk3 } from "../services/pk3-parser"; +import { parseBsp } from "../services/bsp-parser"; +import { + computeChecksum, + saveMapFile, + saveLevelshot, + getMapFile, + getLevelshot, + deleteMapFile, + deleteLevelshot, +} from "../services/storage"; + +const app = new Hono(); + +// Available features for frontend (core auto-detected features) - alphabetical +const AVAILABLE_FEATURES = ["portalgun", "pushers", "save_zones", "timerun"]; + +// Available difficulties +const AVAILABLE_DIFFICULTIES = ["Beginner", "Easy", "Medium", "Hard", "Insane"]; + +// Available map types - alphabetical +const AVAILABLE_MAP_TYPES = ["customs", "gamma", "originals"]; + +// Validation helpers +const MIN_YEAR = 2000; +const MAX_YEAR = new Date().getFullYear(); +const MAX_NAME_LENGTH = 30; +const MAX_AUTHOR_LENGTH = 50; // Authors can have longer names (multiple authors) + +// Allowed characters: alphanumeric, spaces, common punctuation +const SAFE_NAME_REGEX = /^[a-zA-Z0-9\s\-_.,!?'"()]+$/; + +function sanitizeString(str: string | undefined, maxLength: number): string | undefined { + if (!str) return undefined; + const trimmed = str.trim(); + if (trimmed.length === 0) return undefined; + if (trimmed.length > maxLength) return trimmed.slice(0, maxLength); + return trimmed; +} + +function validateString(str: string | undefined, fieldName: string, maxLength: number): string | null { + if (!str) return null; + const trimmed = str.trim(); + if (trimmed.length === 0) return null; + if (trimmed.length > maxLength) { + return `${fieldName} must be ${maxLength} characters or less`; + } + if (!SAFE_NAME_REGEX.test(trimmed)) { + return `${fieldName} contains invalid characters`; + } + return null; +} + +function validateYear(year: number | undefined): string | null { + if (year === undefined || year === null) return null; + if (!Number.isInteger(year)) return "Year must be a whole number"; + if (year < MIN_YEAR || year > MAX_YEAR) { + return `Year must be between ${MIN_YEAR} and ${MAX_YEAR}`; + } + return null; +} + +// Fixed image dimensions for levelshots (16:9 aspect ratio for consistency) +const IMAGE_WIDTH = 640; +const IMAGE_HEIGHT = 360; + +// File size limits (configurable via env, defaults: PK3 = 500 MB, image = 10 MB) +const MAX_PK3_SIZE = parseInt(process.env.MAX_PK3_SIZE_MB || "500") * 1024 * 1024; +const MAX_IMAGE_SIZE = parseInt(process.env.MAX_IMAGE_SIZE_MB || "10") * 1024 * 1024; + +// Escape SQL LIKE wildcards in user input +function escapeLike(str: string): string { + return str.replace(/[%_]/g, "\\$&"); +} + +// GET /api/maps - List all maps with pagination +app.get("/", async (c) => { + const page = parseInt(c.req.query("page") || "1"); + const limit = Math.min(parseInt(c.req.query("limit") || "10"), 100); + const search = c.req.query("search"); + const difficulty = c.req.query("difficulty"); + const types = c.req.query("types"); // comma-separated: "gamma,customs" + const year = c.req.query("year"); + const offset = (page - 1) * limit; + + try { + const conditions: any[] = []; + + if (search) { + const escaped = escapeLike(search); + conditions.push( + sql`(${maps.displayName} LIKE ${"%" + escaped + "%"} ESCAPE '\\' OR ${maps.mapName} LIKE ${"%" + escaped + "%"} ESCAPE '\\' OR ${maps.author} LIKE ${"%" + escaped + "%"} ESCAPE '\\')` + ); + } + + if (difficulty) { + conditions.push(eq(maps.difficulty, difficulty)); + } + + // Filter by types (mapTypes is a JSON array, check if any of the requested types are in it) + if (types) { + const typeList = types.split(",").filter(Boolean); + if (typeList.length > 0) { + // Build OR conditions for each type + const typeConditions = typeList.map( + (type) => sql`${maps.mapTypes} LIKE ${"%" + escapeLike(type) + "%"} ESCAPE '\\'` + ); + conditions.push(sql`(${sql.join(typeConditions, sql` OR `)})`); + } + } + + // Filter by release year + if (year) { + conditions.push(eq(maps.releaseYear, parseInt(year))); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const results = await db + .select() + .from(maps) + .where(whereClause) + .orderBy(asc(sql`LOWER(COALESCE(${maps.displayName}, ${maps.mapName}, ${maps.filename}))`)) + .limit(limit) + .offset(offset); + + const countResult = await db + .select({ count: sql`count(*)` }) + .from(maps) + .where(whereClause); + + const total = Number(countResult[0]?.count || 0); + + return c.json({ + success: true, + data: results, + meta: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }); + } catch (error) { + console.error("Error listing maps:", error); + return c.json({ success: false, error: "Failed to list maps" }, 500); + } +}); + +// GET /api/maps/options - Get available options for dropdowns +app.get("/options", (c) => { + return c.json({ + success: true, + data: { + features: AVAILABLE_FEATURES, + difficulties: AVAILABLE_DIFFICULTIES, + mapTypes: AVAILABLE_MAP_TYPES, + }, + }); +}); + +// POST /api/maps/validate-key - Validate upload key +app.post("/validate-key", requireAuth, (c) => { + // If we get here, the key is valid (requireAuth passed) + return c.json({ success: true }); +}); + +// GET /api/maps/:id - Get map details +app.get("/:id", async (c) => { + const id = parseInt(c.req.param("id")); + + if (isNaN(id)) { + return c.json({ success: false, error: "Invalid map ID" }, 400); + } + + try { + const [map] = await db.select().from(maps).where(eq(maps.id, id)).limit(1); + + if (!map) { + return c.json({ success: false, error: "Map not found" }, 404); + } + + return c.json({ success: true, data: map }); + } catch (error) { + console.error("Error getting map:", error); + return c.json({ success: false, error: "Failed to get map" }, 500); + } +}); + +// GET /api/maps/:id/levelshot - Get map levelshot image +app.get("/:id/levelshot", async (c) => { + const id = parseInt(c.req.param("id")); + + if (isNaN(id)) { + return c.json({ success: false, error: "Invalid map ID" }, 400); + } + + try { + const [map] = await db.select().from(maps).where(eq(maps.id, id)).limit(1); + + if (!map || !map.levelshotPath) { + return c.json({ success: false, error: "Levelshot not found" }, 404); + } + + const imageBuffer = await getLevelshot(map.levelshotPath); + + if (!imageBuffer) { + return c.json({ success: false, error: "Levelshot file not found" }, 404); + } + + const ext = map.levelshotPath.split(".").pop()?.toLowerCase(); + let contentType = "image/jpeg"; + if (ext === "png") contentType = "image/png"; + + return new Response(imageBuffer, { + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=86400", + }, + }); + } catch (error) { + console.error("Error getting levelshot:", error); + return c.json({ success: false, error: "Failed to get levelshot" }, 500); + } +}); + +// GET /api/maps/:id/download - Download PK3 file +app.get("/:id/download", async (c) => { + const id = parseInt(c.req.param("id")); + + if (isNaN(id)) { + return c.json({ success: false, error: "Invalid map ID" }, 400); + } + + try { + const [map] = await db.select().from(maps).where(eq(maps.id, id)).limit(1); + + if (!map) { + return c.json({ success: false, error: "Map not found" }, 404); + } + + const fileBuffer = await getMapFile(map.filePath); + + // Increment download count atomically + await db + .update(maps) + .set({ downloadCount: sql`${maps.downloadCount} + 1` }) + .where(eq(maps.id, id)); + + return new Response(fileBuffer, { + headers: { + "Content-Type": "application/octet-stream", + "Content-Disposition": `attachment; filename="${encodeURIComponent(map.filename)}"`, + "Content-Length": fileBuffer.length.toString(), + }, + }); + } catch (error) { + console.error("Error downloading map:", error); + return c.json({ success: false, error: "Failed to download map" }, 500); + } +}); + +// POST /api/maps - Upload new map (protected) +app.post("/", requireAuth, async (c) => { + try { + const formData = await c.req.formData(); + const file = formData.get("file") as File | null; + + if (!file) { + return c.json({ success: false, error: "No file provided" }, 400); + } + + if (!file.name.toLowerCase().endsWith(".pk3")) { + return c.json({ success: false, error: "File must be a .pk3 file" }, 400); + } + + if (file.size > MAX_PK3_SIZE) { + return c.json({ success: false, error: `File exceeds ${process.env.MAX_PK3_SIZE_MB || "500"} MB limit` }, 400); + } + + const buffer = await file.arrayBuffer(); + const checksum = computeChecksum(buffer); + + // Check for duplicate + const [existing] = await db + .select() + .from(maps) + .where(eq(maps.checksum, checksum)) + .limit(1); + + if (existing) { + return c.json( + { + success: false, + error: "This map already exists", + existingId: existing.id, + }, + 409 + ); + } + + // Parse PK3 to find BSP + const pk3Contents = await parsePk3(buffer); + + if (pk3Contents.bspFiles.length === 0) { + return c.json( + { success: false, error: "No BSP file found in PK3" }, + 400 + ); + } + + // Parse BSP to extract metadata + const bspFile = pk3Contents.bspFiles[0]; + let bspMetadata; + try { + bspMetadata = parseBsp(bspFile.data); + } catch (err) { + console.error("BSP parsing error:", err); + bspMetadata = { + mapName: null, + features: [], + }; + } + + // Determine the map name for file naming + const mapNameForFile = bspMetadata.mapName || bspFile.name.replace(".bsp", ""); + + // Save file to storage + const filePath = await saveMapFile(buffer, checksum, file.name, mapNameForFile); + + // Insert into database + const newMap: NewMap = { + filename: file.name, + filePath, + fileSize: buffer.byteLength, + checksum, + bspName: bspFile.name, + mapName: bspMetadata.mapName, + features: bspMetadata.features, + displayName: mapNameForFile, + isPublished: false, + }; + + let inserted; + try { + [inserted] = await db.insert(maps).values(newMap).returning(); + } catch (insertError: any) { + // Handle race condition: another request inserted the same checksum + if (insertError?.message?.includes("UNIQUE constraint")) { + // Clean up the saved file since we won't use it + await deleteMapFile(filePath); + return c.json( + { success: false, error: "This map already exists" }, + 409 + ); + } + throw insertError; + } + + return c.json( + { + success: true, + data: inserted, + parsed: { + bspName: bspFile.name, + mapName: bspMetadata.mapName, + features: bspMetadata.features, + }, + }, + 201 + ); + } catch (error) { + console.error("Error uploading map:", error); + return c.json({ success: false, error: "Failed to upload map" }, 500); + } +}); + +// POST /api/maps/:id/image - Upload custom image (protected) +app.post("/:id/image", requireAuth, async (c) => { + const id = parseInt(c.req.param("id")); + + if (isNaN(id)) { + return c.json({ success: false, error: "Invalid map ID" }, 400); + } + + try { + const [existing] = await db + .select() + .from(maps) + .where(eq(maps.id, id)) + .limit(1); + + if (!existing) { + return c.json({ success: false, error: "Map not found" }, 404); + } + + const formData = await c.req.formData(); + const file = formData.get("image") as File | null; + + if (!file) { + return c.json({ success: false, error: "No image provided" }, 400); + } + + if (file.size > MAX_IMAGE_SIZE) { + return c.json({ success: false, error: `Image exceeds ${process.env.MAX_IMAGE_SIZE_MB || "10"} MB limit` }, 400); + } + + // Check file type + const allowedTypes = ["image/jpeg", "image/png", "image/webp"]; + if (!allowedTypes.includes(file.type)) { + return c.json( + { success: false, error: "Image must be JPEG, PNG, or WebP" }, + 400 + ); + } + + // Process image - resize and crop to fixed 16:9 dimensions for consistency + const imageBuffer = await file.arrayBuffer(); + const resizedImage = await sharp(Buffer.from(imageBuffer)) + .resize(IMAGE_WIDTH, IMAGE_HEIGHT, { + fit: "cover", // Crop to fill entire area, no letterboxing + position: "center", + }) + .jpeg({ quality: 85 }) + .toBuffer(); + + // Delete old levelshot if exists + if (existing.levelshotPath) { + await deleteLevelshot(existing.levelshotPath); + } + + // Save new image with map name for easier recognition + const mapNameForFile = existing.displayName || existing.mapName || existing.bspName || "map"; + const levelshotPath = await saveLevelshot( + resizedImage, + existing.checksum, + mapNameForFile + ); + + // Update database + const [updated] = await db + .update(maps) + .set({ + levelshotPath, + updatedAt: new Date().toISOString(), + }) + .where(eq(maps.id, id)) + .returning(); + + return c.json({ success: true, data: updated }); + } catch (error) { + console.error("Error uploading image:", error); + return c.json({ success: false, error: "Failed to upload image" }, 500); + } +}); + +// DELETE /api/maps/:id/image - Delete map image (protected) +app.delete("/:id/image", requireAuth, async (c) => { + const id = parseInt(c.req.param("id")); + + if (isNaN(id)) { + return c.json({ success: false, error: "Invalid map ID" }, 400); + } + + try { + const [existing] = await db + .select() + .from(maps) + .where(eq(maps.id, id)) + .limit(1); + + if (!existing) { + return c.json({ success: false, error: "Map not found" }, 404); + } + + if (!existing.levelshotPath) { + return c.json({ success: false, error: "Map has no image" }, 400); + } + + // Delete the levelshot file + await deleteLevelshot(existing.levelshotPath); + + // Update database + const [updated] = await db + .update(maps) + .set({ + levelshotPath: null, + updatedAt: new Date().toISOString(), + }) + .where(eq(maps.id, id)) + .returning(); + + return c.json({ success: true, data: updated }); + } catch (error) { + console.error("Error deleting image:", error); + return c.json({ success: false, error: "Failed to delete image" }, 500); + } +}); + +// PUT /api/maps/:id - Update map metadata (protected) +app.put("/:id", requireAuth, async (c) => { + const id = parseInt(c.req.param("id")); + + if (isNaN(id)) { + return c.json({ success: false, error: "Invalid map ID" }, 400); + } + + try { + const body = await c.req.json(); + const { displayName, author, releaseYear, difficulty, mapTypes, features, tags } = body; + + // Validate inputs + const errors: string[] = []; + + // Validate displayName (max 30 chars) + const nameError = validateString(displayName, "Display name", MAX_NAME_LENGTH); + if (nameError) errors.push(nameError); + + // Validate author (max 50 chars to allow multiple authors) + const authorError = validateString(author, "Author", MAX_AUTHOR_LENGTH); + if (authorError) errors.push(authorError); + + // Validate year + const yearError = validateYear(releaseYear); + if (yearError) errors.push(yearError); + + // Validate difficulty (must be from allowed list if provided) + if (difficulty && !AVAILABLE_DIFFICULTIES.includes(difficulty)) { + errors.push(`Invalid difficulty. Must be one of: ${AVAILABLE_DIFFICULTIES.join(", ")}`); + } + + // Validate mapTypes (must be from allowed list if provided) + if (mapTypes && Array.isArray(mapTypes)) { + const invalidTypes = mapTypes.filter((t: string) => !AVAILABLE_MAP_TYPES.includes(t)); + if (invalidTypes.length > 0) { + errors.push(`Invalid map types: ${invalidTypes.join(", ")}`); + } + } + + // Validate features (custom features allowed, but sanitize them) + if (features && Array.isArray(features)) { + for (const feature of features) { + if (typeof feature !== "string" || feature.length > 50) { + errors.push("Each feature must be a string of 50 characters or less"); + break; + } + if (!/^[a-zA-Z0-9_]+$/.test(feature)) { + errors.push("Features can only contain letters, numbers, and underscores"); + break; + } + } + } + + if (errors.length > 0) { + return c.json({ success: false, error: errors.join("; ") }, 400); + } + + const [existing] = await db + .select() + .from(maps) + .where(eq(maps.id, id)) + .limit(1); + + if (!existing) { + return c.json({ success: false, error: "Map not found" }, 404); + } + + // Sanitize inputs before saving + const [updated] = await db + .update(maps) + .set({ + displayName: sanitizeString(displayName, MAX_NAME_LENGTH), + author: sanitizeString(author, MAX_AUTHOR_LENGTH), + releaseYear, + difficulty, + mapTypes, + features, + tags, + updatedAt: new Date().toISOString(), + }) + .where(eq(maps.id, id)) + .returning(); + + return c.json({ success: true, data: updated }); + } catch (error) { + console.error("Error updating map:", error); + return c.json({ success: false, error: "Failed to update map" }, 500); + } +}); + +// POST /api/maps/:id/publish - Toggle publish status (protected) +app.post("/:id/publish", requireAuth, async (c) => { + const id = parseInt(c.req.param("id")); + + if (isNaN(id)) { + return c.json({ success: false, error: "Invalid map ID" }, 400); + } + + try { + // Atomic toggle: NOT(is_published) in a single UPDATE + const [updated] = await db + .update(maps) + .set({ + isPublished: sql`NOT ${maps.isPublished}`, + updatedAt: new Date().toISOString(), + }) + .where(eq(maps.id, id)) + .returning(); + + if (!updated) { + return c.json({ success: false, error: "Map not found" }, 404); + } + + return c.json({ + success: true, + data: updated, + message: updated.isPublished ? "Map published" : "Map unpublished", + }); + } catch (error) { + console.error("Error toggling publish:", error); + return c.json({ success: false, error: "Failed to toggle publish" }, 500); + } +}); + +// DELETE /api/maps/:id - Delete map (protected) +app.delete("/:id", requireAuth, async (c) => { + const id = parseInt(c.req.param("id")); + + if (isNaN(id)) { + return c.json({ success: false, error: "Invalid map ID" }, 400); + } + + try { + const [existing] = await db + .select() + .from(maps) + .where(eq(maps.id, id)) + .limit(1); + + if (!existing) { + return c.json({ success: false, error: "Map not found" }, 404); + } + + // Delete file from storage + await deleteMapFile(existing.filePath); + + // Delete levelshot if exists + if (existing.levelshotPath) { + await deleteLevelshot(existing.levelshotPath); + } + + // Delete from database + await db.delete(maps).where(eq(maps.id, id)); + + return c.json({ success: true, message: "Map deleted" }); + } catch (error) { + console.error("Error deleting map:", error); + return c.json({ success: false, error: "Failed to delete map" }, 500); + } +}); + +export default app; diff --git a/maps-api/src/services/bsp-parser.ts b/maps-api/src/services/bsp-parser.ts new file mode 100644 index 0000000..58449dc --- /dev/null +++ b/maps-api/src/services/bsp-parser.ts @@ -0,0 +1,176 @@ +/** + * Custom BSP parser for Quake 3 / Enemy Territory maps. + * Only extracts the entities lump to get map metadata. + * + * BSP Format Reference: http://www.mralligator.com/q3/ + */ + +interface BspEntity { + classname: string; + [key: string]: string; +} + +// ETJump special features that can be detected from entities +export type MapFeature = "portalgun" | "pushers" | "save_zones" | "timerun"; + +export interface BspMetadata { + mapName: string | null; + features: MapFeature[]; +} + +// BSP header constants +const BSP_MAGIC = "IBSP"; +const LUMP_ENTITIES = 0; + +// Feature detection rules +// - "all": ALL listed entities must be present +// - "any": ANY of the listed entities must be present +interface FeatureRule { + mode: "all" | "any"; + entities: string[]; +} + +const FEATURE_RULES: Record = { + // Timerun requires BOTH start and stop timers + timerun: { + mode: "all", + entities: ["target_starttimer", "target_stoptimer"], + }, + // Portalgun just needs the weapon + portalgun: { + mode: "any", + entities: ["weapon_portalgun"], + }, + // Save zones feature = has restricted save areas (nosave zones) + save_zones: { + mode: "any", + entities: ["target_nosave", "trigger_nosave"], + }, + // Pushers = jump pads + pushers: { + mode: "any", + entities: ["target_push", "trigger_push"], + }, +}; + +// Minimum BSP size: 4 (magic) + 4 (version) + 17*8 (lump directory) = 144 bytes +const BSP_HEADER_SIZE = 8 + 17 * 8; + +export function parseBsp(bspBuffer: ArrayBuffer): BspMetadata { + if (bspBuffer.byteLength < BSP_HEADER_SIZE) { + throw new Error(`Invalid BSP file: too small (${bspBuffer.byteLength} bytes)`); + } + + const view = new DataView(bspBuffer); + + // Verify magic number "IBSP" + const magic = String.fromCharCode( + view.getUint8(0), + view.getUint8(1), + view.getUint8(2), + view.getUint8(3) + ); + + if (magic !== BSP_MAGIC) { + throw new Error(`Invalid BSP file: expected magic "${BSP_MAGIC}", got "${magic}"`); + } + + // Version is at offset 4 (should be 0x2e for Q3/ET) + const version = view.getInt32(4, true); + if (version !== 0x2e && version !== 0x2f) { + console.warn(`Unexpected BSP version: 0x${version.toString(16)}`); + } + + // Lump directory starts at offset 8 + // Each lump entry is 8 bytes: 4 bytes offset + 4 bytes length + // Entities lump is first (index 0) + const entitiesOffset = view.getInt32(8 + LUMP_ENTITIES * 8, true); + const entitiesLength = view.getInt32(8 + LUMP_ENTITIES * 8 + 4, true); + + // Bounds check: ensure offset and length are within the buffer + if ( + entitiesOffset < 0 || + entitiesLength < 0 || + entitiesOffset + entitiesLength > bspBuffer.byteLength + ) { + throw new Error( + `Invalid BSP file: entities lump out of bounds (offset=${entitiesOffset}, length=${entitiesLength}, fileSize=${bspBuffer.byteLength})` + ); + } + + // Extract entities string + const decoder = new TextDecoder("utf-8"); + const entitiesData = new Uint8Array(bspBuffer, entitiesOffset, entitiesLength); + const entitiesString = decoder.decode(entitiesData).replace(/\0+$/, ""); // Remove null terminators + + // Parse entities + const entities = parseEntities(entitiesString); + + // Extract metadata + const worldspawn = entities.find((e) => e.classname === "worldspawn"); + const mapName = worldspawn?.message || null; + + // Detect ETJump features + const features = detectFeatures(entities); + + return { + mapName, + features, + }; +} + +function parseEntities(entitiesString: string): BspEntity[] { + const entities: BspEntity[] = []; + let current: Record | null = null; + + const lines = entitiesString.split("\n"); + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed === "{") { + current = {}; + } else if (trimmed === "}") { + if (current && current.classname) { + entities.push(current as BspEntity); + } + current = null; + } else if (current && trimmed.startsWith('"')) { + // Parse key-value pair: "key" "value" + const match = trimmed.match(/"([^"]+)"\s+"([^"]*)"/); + if (match) { + current[match[1]] = match[2]; + } + } + } + + return entities; +} + +function detectFeatures(entities: BspEntity[]): MapFeature[] { + const detectedFeatures: MapFeature[] = []; + const entityClassnames = new Set(entities.map((e) => e.classname.toLowerCase())); + + for (const [feature, rule] of Object.entries(FEATURE_RULES)) { + let hasFeature: boolean; + + if (rule.mode === "all") { + // ALL entities must be present + hasFeature = rule.entities.every((cn) => + entityClassnames.has(cn.toLowerCase()) + ); + } else { + // ANY entity must be present + hasFeature = rule.entities.some((cn) => + entityClassnames.has(cn.toLowerCase()) + ); + } + + if (hasFeature) { + detectedFeatures.push(feature as MapFeature); + } + } + + // Sort alphabetically for consistency + return detectedFeatures.sort(); +} diff --git a/maps-api/src/services/pk3-parser.ts b/maps-api/src/services/pk3-parser.ts new file mode 100644 index 0000000..f357224 --- /dev/null +++ b/maps-api/src/services/pk3-parser.ts @@ -0,0 +1,47 @@ +import JSZip from "jszip"; + +// Max decompressed BSP size (256 MB) to prevent zip bombs +const MAX_BSP_DECOMPRESSED_SIZE = 256 * 1024 * 1024; + +export interface Pk3Contents { + bspFiles: Array<{ + name: string; + path: string; + data: ArrayBuffer; + }>; +} + +export async function parsePk3(pk3Buffer: ArrayBuffer): Promise { + const zip = await JSZip.loadAsync(pk3Buffer); + const bspFiles: Pk3Contents["bspFiles"] = []; + + // Find BSP files + for (const [path, file] of Object.entries(zip.files)) { + if (file.dir) continue; + + if (path.toLowerCase().endsWith(".bsp")) { + // Check decompressed size before extracting (JSZip exposes it via _data) + const uncompressedSize = (file as any)._data?.uncompressedSize; + if (uncompressedSize && uncompressedSize > MAX_BSP_DECOMPRESSED_SIZE) { + throw new Error( + `BSP file "${path}" exceeds max decompressed size (${Math.round(uncompressedSize / 1024 / 1024)} MB)` + ); + } + + const data = await file.async("arraybuffer"); + + // Also check actual size after decompression in case metadata was missing + if (data.byteLength > MAX_BSP_DECOMPRESSED_SIZE) { + throw new Error( + `BSP file "${path}" exceeds max decompressed size (${Math.round(data.byteLength / 1024 / 1024)} MB)` + ); + } + + // Extract just the filename from the path + const name = path.split("/").pop() || path; + bspFiles.push({ name, path, data }); + } + } + + return { bspFiles }; +} diff --git a/maps-api/src/services/storage.ts b/maps-api/src/services/storage.ts new file mode 100644 index 0000000..9423246 --- /dev/null +++ b/maps-api/src/services/storage.ts @@ -0,0 +1,93 @@ +import { mkdir, writeFile, readFile, unlink } from "fs/promises"; +import { existsSync } from "fs"; +import { join } from "path"; +import { createHash } from "crypto"; + +const STORAGE_PATH = process.env.STORAGE_PATH || "../storage/maps"; +const LEVELSHOTS_PATH = join(STORAGE_PATH, "levelshots"); + +export async function ensureStorageDir(): Promise { + if (!existsSync(STORAGE_PATH)) { + await mkdir(STORAGE_PATH, { recursive: true }); + } + if (!existsSync(LEVELSHOTS_PATH)) { + await mkdir(LEVELSHOTS_PATH, { recursive: true }); + } +} + +export function computeChecksum(buffer: ArrayBuffer): string { + return createHash("sha256").update(Buffer.from(buffer)).digest("hex"); +} + +// Sanitize name for filesystem: keep only alphanumeric, dash, underscore +function sanitizeForFilename(name: string): string { + return name + .toLowerCase() + .replace(/\.pk3$/i, "") // Remove .pk3 extension if present + .replace(/\.bsp$/i, "") // Remove .bsp extension if present + .replace(/[^a-z0-9_-]/g, "_") // Replace invalid chars with underscore + .replace(/_+/g, "_") // Collapse multiple underscores + .replace(/^_|_$/g, "") // Trim leading/trailing underscores + .slice(0, 50); // Limit length +} + +export async function saveMapFile( + buffer: ArrayBuffer, + checksum: string, + originalFilename: string, + mapName?: string +): Promise { + await ensureStorageDir(); + + // Use mapname_checksum.pk3 format for easier recognition + const safeName = sanitizeForFilename(mapName || originalFilename); + const shortChecksum = checksum.slice(0, 12); // First 12 chars is enough for uniqueness + const filename = `${safeName}_${shortChecksum}.pk3`; + const filePath = join(STORAGE_PATH, filename); + + await writeFile(filePath, Buffer.from(buffer)); + + return filePath; +} + +export async function saveLevelshot( + buffer: ArrayBuffer | Buffer, + checksum: string, + mapName?: string +): Promise { + await ensureStorageDir(); + + // Use mapname_checksum.jpg format for easier recognition + const safeName = mapName ? sanitizeForFilename(mapName) : "levelshot"; + const shortChecksum = checksum.slice(0, 12); + const filename = `${safeName}_${shortChecksum}.jpg`; + const filePath = join(LEVELSHOTS_PATH, filename); + + await writeFile(filePath, Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer)); + + return filePath; +} + +export async function getMapFile(filePath: string): Promise { + return readFile(filePath); +} + +export async function getLevelshot(filePath: string): Promise { + if (!existsSync(filePath)) { + return null; + } + return readFile(filePath); +} + +export async function deleteMapFile(filePath: string): Promise { + if (existsSync(filePath)) { + await unlink(filePath); + } +} + +export async function deleteLevelshot(filePath: string): Promise { + if (existsSync(filePath)) { + await unlink(filePath); + } +} + diff --git a/maps-api/tsconfig.json b/maps-api/tsconfig.json new file mode 100644 index 0000000..2997de8 --- /dev/null +++ b/maps-api/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": ["bun-types"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/maps-web/index.html b/maps-web/index.html new file mode 100644 index 0000000..d96ff81 --- /dev/null +++ b/maps-web/index.html @@ -0,0 +1,13 @@ + + + + + + + ETJump Maps + + +
+ + + diff --git a/maps-web/package-lock.json b/maps-web/package-lock.json new file mode 100644 index 0000000..d877b1f --- /dev/null +++ b/maps-web/package-lock.json @@ -0,0 +1,2714 @@ +{ + "name": "etjump-maps-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "etjump-maps-web", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-router-dom": "^6.23.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.3", + "typescript": "^5.4.0", + "vite": "^5.2.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@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" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", + "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001766", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", + "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.283", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz", + "integrity": "sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/maps-web/package.json b/maps-web/package.json new file mode 100644 index 0000000..b6087d8 --- /dev/null +++ b/maps-web/package.json @@ -0,0 +1,25 @@ +{ + "name": "etjump-maps-web", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-router-dom": "^6.23.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.3", + "typescript": "^5.4.0", + "vite": "^5.2.0" + } +} diff --git a/maps-web/postcss.config.js b/maps-web/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/maps-web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/maps-web/public/etjump-logo.svg b/maps-web/public/etjump-logo.svg new file mode 100644 index 0000000..cfb7cca --- /dev/null +++ b/maps-web/public/etjump-logo.svg @@ -0,0 +1 @@ +etjump \ No newline at end of file diff --git a/maps-web/src/App.tsx b/maps-web/src/App.tsx new file mode 100644 index 0000000..219e5f7 --- /dev/null +++ b/maps-web/src/App.tsx @@ -0,0 +1,17 @@ +import { Routes, Route } from "react-router-dom"; +import Layout from "./components/Layout"; +import Home from "./pages/Home"; +import MapDetail from "./pages/MapDetail"; +import Upload from "./pages/Upload"; + +export default function App() { + return ( + + }> + } /> + } /> + } /> + + + ); +} diff --git a/maps-web/src/api/maps.ts b/maps-web/src/api/maps.ts new file mode 100644 index 0000000..f242a00 --- /dev/null +++ b/maps-web/src/api/maps.ts @@ -0,0 +1,209 @@ +const API_BASE = "/api/maps"; + +export interface MapData { + id: number; + filename: string; + filePath: string; + fileSize: number; + checksum: string; + bspName: string | null; + mapName: string | null; + features: string[]; + levelshotPath: string | null; + displayName: string | null; + author: string | null; + releaseYear: number | null; + difficulty: string | null; + mapTypes: string[]; + tags: string[]; + downloadCount: number; + isPublished: boolean; + createdAt: string; + updatedAt: string; +} + +export interface MapsResponse { + success: boolean; + data: MapData[]; + meta: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +} + +export interface MapResponse { + success: boolean; + data: MapData; +} + +export interface UploadResponse { + success: boolean; + data: MapData; + parsed: { + bspName: string; + mapName: string | null; + features: string[]; + }; +} + +async function safeFetch(url: string, options?: RequestInit): Promise { + let res: Response; + try { + res = await fetch(url, options); + } catch { + throw new Error("Network error: could not reach the server"); + } + + if (!res.ok) { + // Try to parse error JSON from server + try { + const errorBody = await res.json(); + if (errorBody.error) { + throw new Error(errorBody.error); + } + } catch (e) { + if (e instanceof Error && e.message !== "Network error: could not reach the server") { + throw e; + } + } + throw new Error(`Server error (${res.status})`); + } + + try { + return await res.json(); + } catch { + throw new Error("Invalid response from server"); + } +} + +export async function getMaps(params?: { + page?: number; + limit?: number; + search?: string; + difficulty?: string; + types?: string[]; + year?: number; +}): Promise { + const searchParams = new URLSearchParams(); + if (params?.page) searchParams.set("page", params.page.toString()); + if (params?.limit) searchParams.set("limit", params.limit.toString()); + if (params?.search) searchParams.set("search", params.search); + if (params?.difficulty) searchParams.set("difficulty", params.difficulty); + if (params?.types && params.types.length > 0) { + searchParams.set("types", params.types.join(",")); + } + if (params?.year) searchParams.set("year", params.year.toString()); + + const url = `${API_BASE}?${searchParams.toString()}`; + return safeFetch(url); +} + +export async function validateKey(uploadKey: string): Promise<{ success: boolean }> { + return safeFetch(`${API_BASE}/validate-key`, { + method: "POST", + headers: { + "X-Upload-Key": uploadKey, + }, + }); +} + +export async function getMap(id: number): Promise { + return safeFetch(`${API_BASE}/${id}`); +} + +export async function uploadMap( + file: File, + uploadKey: string +): Promise { + const formData = new FormData(); + formData.append("file", file); + + return safeFetch(API_BASE, { + method: "POST", + headers: { + "X-Upload-Key": uploadKey, + }, + body: formData, + }); +} + +export async function updateMap( + id: number, + data: { + displayName?: string; + author?: string; + releaseYear?: number; + difficulty?: string; + mapTypes?: string[]; + features?: string[]; + tags?: string[]; + }, + uploadKey: string +): Promise { + return safeFetch(`${API_BASE}/${id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + "X-Upload-Key": uploadKey, + }, + body: JSON.stringify(data), + }); +} + +export async function uploadImage( + id: number, + image: File, + uploadKey: string +): Promise { + const formData = new FormData(); + formData.append("image", image); + + return safeFetch(`${API_BASE}/${id}/image`, { + method: "POST", + headers: { + "X-Upload-Key": uploadKey, + }, + body: formData, + }); +} + +export async function deleteImage( + id: number, + uploadKey: string +): Promise { + return safeFetch(`${API_BASE}/${id}/image`, { + method: "DELETE", + headers: { + "X-Upload-Key": uploadKey, + }, + }); +} + +export async function deleteMap( + id: number, + uploadKey: string +): Promise<{ success: boolean; message?: string }> { + return safeFetch(`${API_BASE}/${id}`, { + method: "DELETE", + headers: { + "X-Upload-Key": uploadKey, + }, + }); +} + +export function getDownloadUrl(id: number): string { + return `${API_BASE}/${id}/download`; +} + +export function getLevelshotUrl(id: number, cacheBuster?: string): string { + const base = `${API_BASE}/${id}/levelshot`; + return cacheBuster ? `${base}?v=${cacheBuster}` : base; +} + +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/maps-web/src/components/Layout.tsx b/maps-web/src/components/Layout.tsx new file mode 100644 index 0000000..bee8e4e --- /dev/null +++ b/maps-web/src/components/Layout.tsx @@ -0,0 +1,25 @@ +import { Link, Outlet } from "react-router-dom"; + +export default function Layout() { + return ( +
+
+
+ + ETJump + +
+
+ +
+ +
+ +
+
+ ETJump Maps Database +
+
+
+ ); +} diff --git a/maps-web/src/components/MapTable.tsx b/maps-web/src/components/MapTable.tsx new file mode 100644 index 0000000..10b00cb --- /dev/null +++ b/maps-web/src/components/MapTable.tsx @@ -0,0 +1,776 @@ +import { useState, useRef } from "react"; +import { Link } from "react-router-dom"; +import { + MapData, + formatFileSize, + getLevelshotUrl, + getDownloadUrl, + deleteMap, + updateMap, + uploadImage, + deleteImage, +} from "../api/maps"; +import { useAuth } from "../context/AuthContext"; + +interface MapTableProps { + maps: MapData[]; + loading: boolean; + onMapDeleted?: () => void; + onMapUpdated?: () => void; +} + +const DIFFICULTIES = ["Beginner", "Easy", "Medium", "Hard", "Insane"]; +const FEATURES = ["portalgun", "pushers", "save_zones", "timerun"]; // Alphabetical +const MAP_TYPES = ["customs", "gamma", "originals"]; // Alphabetical + +const DIFFICULTY_COLORS: Record = { + Beginner: "bg-green-900 text-green-300", + Easy: "bg-green-800 text-green-200", + Medium: "bg-yellow-900 text-yellow-300", + Hard: "bg-red-900/80 text-red-300", + Insane: "bg-purple-900 text-purple-300", +}; + +export default function MapTable({ + maps, + loading, + onMapDeleted, + onMapUpdated, +}: MapTableProps) { + const { secretKey, isAuthenticated } = useAuth(); + const [expandedId, setExpandedId] = useState(null); + const [deletingId, setDeletingId] = useState(null); + const [error, setError] = useState(null); + + // Edit state + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(""); + const [editAuthors, setEditAuthors] = useState([]); + const [editAuthorInput, setEditAuthorInput] = useState(""); + const [editYear, setEditYear] = useState(""); + const [editDifficulty, setEditDifficulty] = useState(""); + const [editMapTypes, setEditMapTypes] = useState([]); + const [editFeatures, setEditFeatures] = useState([]); + const [editCustomFeature, setEditCustomFeature] = useState(""); + const [savingId, setSavingId] = useState(null); + + // Image edit state + const [imageFile, setImageFile] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + const [uploadingImage, setUploadingImage] = useState(false); + const [deletingImage, setDeletingImage] = useState(false); + const imageInputRef = useRef(null); + + if (loading) { + return ( +
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+
+ ); + } + + if (maps.length === 0) { + return ( +
+

No maps found

+
+ ); + } + + const toggleExpand = (id: number) => { + if (expandedId === id) { + setExpandedId(null); + } else { + setExpandedId(id); + setEditingId(null); + resetImageState(); + } + setError(null); + setDeletingId(null); + }; + + const parseAuthors = (authorStr: string | null): string[] => { + if (!authorStr) return []; + return authorStr.split(",").map((a) => a.trim()).filter(Boolean); + }; + + const resetImageState = () => { + setImageFile(null); + setImagePreview(null); + if (imageInputRef.current) { + imageInputRef.current.value = ""; + } + }; + + const startEdit = (map: MapData) => { + setEditingId(map.id); + setEditName(map.displayName || ""); + setEditAuthors(parseAuthors(map.author)); + setEditAuthorInput(""); + setEditYear(map.releaseYear || ""); + setEditDifficulty(map.difficulty || ""); + setEditMapTypes(map.mapTypes || []); + setEditFeatures(map.features || []); + setEditCustomFeature(""); + resetImageState(); + setError(null); + }; + + const cancelEdit = () => { + setEditingId(null); + resetImageState(); + setError(null); + }; + + const toggleFeature = (feature: string) => { + setEditFeatures((prev) => + prev.includes(feature) + ? prev.filter((f) => f !== feature) + : [...prev, feature] + ); + }; + + const toggleMapType = (type: string) => { + setEditMapTypes((prev) => + prev.includes(type) + ? prev.filter((t) => t !== type) + : [...prev, type] + ); + }; + + const addAuthor = () => { + const trimmed = editAuthorInput.trim(); + if (trimmed && !editAuthors.includes(trimmed)) { + setEditAuthors([...editAuthors, trimmed]); + setEditAuthorInput(""); + } + }; + + const removeAuthor = (author: string) => { + setEditAuthors(editAuthors.filter((a) => a !== author)); + }; + + const addCustomFeature = () => { + const trimmed = editCustomFeature.trim().toLowerCase().replace(/\s+/g, "_"); + if (trimmed && !editFeatures.includes(trimmed)) { + setEditFeatures([...editFeatures, trimmed]); + setEditCustomFeature(""); + } + }; + + const handleImageSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) { + setError("Image must be JPEG, PNG, or WebP"); + return; + } + setImageFile(file); + setImagePreview(URL.createObjectURL(file)); + setError(null); + } + }; + + const handleImageUpload = async (mapId: number) => { + if (!imageFile) return; + + setUploadingImage(true); + setError(null); + + try { + const response = await uploadImage(mapId, imageFile, secretKey); + if (response.success) { + resetImageState(); + onMapUpdated?.(); + } else { + setError((response as any).error || "Failed to upload image"); + } + } catch (err) { + setError("Failed to upload image"); + } finally { + setUploadingImage(false); + } + }; + + const handleImageDelete = async (mapId: number) => { + if (!confirm("Delete this image?")) return; + + setDeletingImage(true); + setError(null); + + try { + const response = await deleteImage(mapId, secretKey); + if (response.success) { + onMapUpdated?.(); + } else { + setError((response as any).error || "Failed to delete image"); + } + } catch (err) { + setError("Failed to delete image"); + } finally { + setDeletingImage(false); + } + }; + + const handleSave = async (mapId: number) => { + if (!isAuthenticated) { + setError("Please set the secret key above first"); + return; + } + + setSavingId(mapId); + setError(null); + + try { + const response = await updateMap( + mapId, + { + displayName: editName, + author: editAuthors.join(", "), + releaseYear: editYear ? Number(editYear) : undefined, + difficulty: editDifficulty || undefined, + mapTypes: editMapTypes, + features: editFeatures, + }, + secretKey + ); + + if (response.success) { + setEditingId(null); + resetImageState(); + onMapUpdated?.(); + } else { + setError((response as any).error || "Failed to save"); + } + } catch (err) { + setError("Failed to save changes"); + } finally { + setSavingId(null); + } + }; + + const handleDelete = async (mapId: number) => { + if (!isAuthenticated) { + setError("Please set the secret key above first"); + return; + } + + if (!confirm("Are you sure you want to delete this map?")) { + return; + } + + setDeletingId(mapId); + setError(null); + + try { + const response = await deleteMap(mapId, secretKey); + if (response.success) { + setExpandedId(null); + onMapDeleted?.(); + } else { + setError(response.message || "Failed to delete"); + } + } catch (err) { + setError("Failed to delete map"); + } finally { + setDeletingId(null); + } + }; + + const formatAuthors = (authorStr: string | null) => { + if (!authorStr) return "-"; + const authors = parseAuthors(authorStr); + if (authors.length <= 2) { + return authors.join(", "); + } + return `${authors[0]} +${authors.length - 1}`; + }; + + const formatMapTypes = (types: string[] | undefined) => { + if (!types || types.length === 0) return null; + return types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(", "); + }; + + return ( +
+ {/* Table Header */} +
+
+
Name
+
Author
+
Year
+
Type
+
Difficulty
+
Action
+
+ + {/* Table Rows */} + {maps.map((map) => ( +
+ {/* Main Row */} +
toggleExpand(map.id)} + > + {/* Expand Arrow - Fixed width column */} +
+ + + +
+ {/* Name */} +
+ + {map.displayName || map.mapName || map.bspName || map.filename} + +
+ {/* Author */} +
+ {formatAuthors(map.author)} +
+ {/* Year */} +
+ {map.releaseYear || "-"} +
+ {/* Type */} +
+ {map.mapTypes && map.mapTypes.length > 0 ? ( + + {formatMapTypes(map.mapTypes)} + + ) : ( + - + )} +
+ {/* Difficulty */} +
+ {map.difficulty ? ( + + {map.difficulty} + + ) : ( + - + )} +
+ {/* Action */} + +
+ + {/* Expanded Content */} + {expandedId === map.id && ( +
+
+ {/* Levelshot */} +
+ {editingId === map.id ? ( + /* Image Edit Mode */ +
+ + {imagePreview ? ( + Preview + ) : map.levelshotPath ? ( + {map.displayName + ) : ( +
+ No image +
+ )} +
+ + {imageFile && ( + + )} + {map.levelshotPath && !imageFile && ( + + )} +
+
+ ) : ( + /* Image View Mode */ + map.levelshotPath ? ( + {map.displayName { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + ) : ( +
+ No preview +
+ ) + )} +
+ + {/* Details / Edit Form */} +
+ {editingId === map.id ? ( + /* Edit Form */ +
+
+
+ + setEditName(e.target.value)} + onClick={(e) => e.stopPropagation()} + className="w-full mt-1 text-sm bg-neutral-800 border border-neutral-600 rounded px-2 py-1 text-white" + /> +
+
+ + + setEditYear(e.target.value ? parseInt(e.target.value) : "") + } + onClick={(e) => e.stopPropagation()} + className="w-full mt-1 text-sm bg-neutral-800 border border-neutral-600 rounded px-2 py-1 text-white" + /> +
+
+ + +
+
+ + {/* Map Types - Multi-select checkboxes */} +
+ +
+ {MAP_TYPES.map((type) => ( + + ))} +
+
+ + {/* Authors - tag input */} +
+ +
+ {editAuthors.map((author) => ( + + {author} + + + ))} +
+
+ setEditAuthorInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addAuthor())} + placeholder="Add author..." + className="flex-1 text-sm bg-neutral-800 border border-neutral-600 rounded px-2 py-1 text-white placeholder-neutral-500" + /> + +
+
+ + {/* Features - All removable */} +
+ +
+ {editFeatures.map((feature) => ( + + {feature.replace(/_/g, " ")} + + + ))} +
+ {/* Add known features */} +
+ {FEATURES.filter(f => !editFeatures.includes(f)).map((feature) => ( + + ))} +
+
+ setEditCustomFeature(e.target.value)} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addCustomFeature())} + placeholder="Add custom feature..." + className="flex-1 text-sm bg-neutral-800 border border-neutral-600 rounded px-2 py-1 text-white placeholder-neutral-500" + /> + +
+
+ + {/* Save/Cancel buttons */} +
+ + + {!isAuthenticated && ( + Set key above first + )} +
+ + {error && ( +

{error}

+ )} +
+ ) : ( + /* View Mode */ + <> + {/* Authors */} + {map.author && parseAuthors(map.author).length > 0 && ( +
+ Authors: +
+ {parseAuthors(map.author).map((author) => ( + + {author} + + ))} +
+
+ )} + + {/* Features */} + {map.features && map.features.length > 0 && ( +
+ Features: +
+ {map.features.map((feature) => ( + + {feature.replace(/_/g, " ")} + + ))} +
+
+ )} + + {/* Size */} +
+ Size: + + {formatFileSize(map.fileSize)} + +
+ + {/* Actions */} +
+ + View Details + + + {isAuthenticated && ( +
+ + +
+ )} +
+ + {error && ( +

{error}

+ )} + + )} +
+
+
+ )} +
+ ))} +
+ ); +} diff --git a/maps-web/src/components/SecretKeyInput.tsx b/maps-web/src/components/SecretKeyInput.tsx new file mode 100644 index 0000000..12df8ad --- /dev/null +++ b/maps-web/src/components/SecretKeyInput.tsx @@ -0,0 +1,152 @@ +import { useState, useRef, useEffect } from "react"; +import { useAuth } from "../context/AuthContext"; +import { validateKey } from "../api/maps"; + +export default function SecretKeyInput() { + const { secretKey, setSecretKey, isAuthenticated, setIsValidated } = useAuth(); + const [showInput, setShowInput] = useState(false); + const [tempKey, setTempKey] = useState(secretKey); + const [validating, setValidating] = useState(false); + const [error, setError] = useState(null); + const inputRef = useRef(null); + const containerRef = useRef(null); + + // Focus input when shown + useEffect(() => { + if (showInput && inputRef.current) { + inputRef.current.focus(); + } + }, [showInput]); + + // Close on click outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setShowInput(false); + setError(null); + } + }; + if (showInput) { + document.addEventListener("mousedown", handleClickOutside); + } + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [showInput]); + + const handleSave = async () => { + if (!tempKey.trim()) { + setError("Please enter a key"); + return; + } + + setValidating(true); + setError(null); + + try { + const result = await validateKey(tempKey); + if (result.success) { + setSecretKey(tempKey); + setIsValidated(true); + setShowInput(false); + } else { + setError("Invalid key"); + setIsValidated(false); + } + } catch { + setError("Failed to validate key"); + setIsValidated(false); + } finally { + setValidating(false); + } + }; + + const handleClear = () => { + setSecretKey(""); + setTempKey(""); + setIsValidated(false); + setShowInput(false); + setError(null); + }; + + return ( +
+ + + {showInput && ( +
+
+ {isAuthenticated ? "Update or clear secret key" : "Enter secret key to edit maps"} +
+ { + setTempKey(e.target.value); + setError(null); + }} + placeholder="Enter secret key..." + disabled={validating} + className="w-full text-sm bg-neutral-700 border border-neutral-600 rounded px-3 py-2 text-white placeholder-neutral-400 focus:outline-none focus:border-etjump mb-2 disabled:opacity-50" + onKeyDown={(e) => e.key === "Enter" && !validating && handleSave()} + /> + {error && ( +
{error}
+ )} +
+ + {isAuthenticated && ( + + )} + +
+
+ )} +
+ ); +} diff --git a/maps-web/src/context/AuthContext.tsx b/maps-web/src/context/AuthContext.tsx new file mode 100644 index 0000000..eb18889 --- /dev/null +++ b/maps-web/src/context/AuthContext.tsx @@ -0,0 +1,61 @@ +import { createContext, useContext, useState, ReactNode } from "react"; + +interface AuthContextType { + secretKey: string; + setSecretKey: (key: string) => void; + isAuthenticated: boolean; + setIsValidated: (valid: boolean) => void; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [secretKey, setSecretKeyState] = useState(() => { + return sessionStorage.getItem("etjump-secret-key") || ""; + }); + const [isValidated, setIsValidated] = useState(() => { + // Check if we have a validated key from this session + return sessionStorage.getItem("etjump-key-validated") === "true"; + }); + + const setSecretKey = (key: string) => { + setSecretKeyState(key); + if (key) { + sessionStorage.setItem("etjump-secret-key", key); + } else { + sessionStorage.removeItem("etjump-secret-key"); + sessionStorage.removeItem("etjump-key-validated"); + setIsValidated(false); + } + }; + + const handleSetIsValidated = (valid: boolean) => { + setIsValidated(valid); + if (valid) { + sessionStorage.setItem("etjump-key-validated", "true"); + } else { + sessionStorage.removeItem("etjump-key-validated"); + } + }; + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/maps-web/src/main.tsx b/maps-web/src/main.tsx new file mode 100644 index 0000000..164de48 --- /dev/null +++ b/maps-web/src/main.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { AuthProvider } from "./context/AuthContext"; +import App from "./App"; +import "./styles/globals.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + + +); diff --git a/maps-web/src/pages/Home.tsx b/maps-web/src/pages/Home.tsx new file mode 100644 index 0000000..ab1b3bf --- /dev/null +++ b/maps-web/src/pages/Home.tsx @@ -0,0 +1,309 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { Link } from "react-router-dom"; +import { getMaps, MapData } from "../api/maps"; +import MapTable from "../components/MapTable"; +import SecretKeyInput from "../components/SecretKeyInput"; +import { useAuth } from "../context/AuthContext"; + +const DIFFICULTIES = ["Beginner", "Easy", "Medium", "Hard", "Insane"]; +const MAP_TYPES = ["customs", "gamma", "originals"]; // Alphabetical +const PAGE_SIZES = [10, 25, 50, 100]; + +// Generate year options from 2000 to current year +const currentYear = new Date().getFullYear(); +const YEARS = Array.from({ length: currentYear - 1999 }, (_, i) => currentYear - i); + +export default function Home() { + const { isAuthenticated } = useAuth(); + const [maps, setMaps] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + const [difficulty, setDifficulty] = useState(""); + const [selectedTypes, setSelectedTypes] = useState([]); + const [year, setYear] = useState(""); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [totalPages, setTotalPages] = useState(1); + const [total, setTotal] = useState(0); + + // Type dropdown state + const [typeDropdownOpen, setTypeDropdownOpen] = useState(false); + const typeDropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (typeDropdownRef.current && !typeDropdownRef.current.contains(event.target as Node)) { + setTypeDropdownOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const fetchMaps = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await getMaps({ + page, + limit: pageSize, + search: search || undefined, + difficulty: difficulty || undefined, + types: selectedTypes.length > 0 ? selectedTypes : undefined, + year: year ? Number(year) : undefined, + }); + + if (response.success) { + setMaps(response.data); + setTotalPages(response.meta.totalPages); + setTotal(response.meta.total); + } else { + setError(response.error || "Failed to load maps"); + } + } catch (err) { + console.error("Failed to fetch maps:", err); + setError(err instanceof Error ? err.message : "Failed to load maps"); + } finally { + setLoading(false); + } + }, [page, pageSize, search, difficulty, selectedTypes, year]); + + useEffect(() => { + fetchMaps(); + }, [fetchMaps]); + + const handleTypeToggle = (type: string) => { + setSelectedTypes((prev) => + prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type] + ); + setPage(1); + }; + + const handlePageSizeChange = (newSize: number) => { + setPageSize(newSize); + setPage(1); + }; + + const hasActiveFilters = search || difficulty || selectedTypes.length > 0 || year; + + const resetFilters = () => { + setSearch(""); + setDifficulty(""); + setSelectedTypes([]); + setYear(""); + setPage(1); + }; + + const getTypeLabel = () => { + if (selectedTypes.length === 0) return "All types"; + if (selectedTypes.length === 1) return selectedTypes[0].charAt(0).toUpperCase() + selectedTypes[0].slice(1); + return `${selectedTypes.length} types`; + }; + + return ( +
+
+
+

ETJump Maps

+

+ Browse and download community maps for ETJump +

+
+ {isAuthenticated && ( + + Upload Map + + )} +
+ + + + {/* Filters */} +
+ {/* Search */} + { + setSearch(e.target.value); + setPage(1); + }} + placeholder="Search by name or author..." + className="flex-1 min-w-[200px] bg-neutral-800 border border-neutral-700 rounded-lg px-4 py-2 text-white placeholder-neutral-500 focus:outline-none focus:border-etjump" + /> + + {/* Year filter */} + + + {/* Type filter (dropdown with checkboxes) */} +
+ + {typeDropdownOpen && ( +
+ {MAP_TYPES.map((type) => ( + + ))} + {selectedTypes.length > 0 && ( + + )} +
+ )} +
+ + {/* Difficulty filter */} + + + {/* Reset filters button */} + {hasActiveFilters && ( + + )} +
+ + {/* Results count */} + {!loading && ( +

+ {total === 0 + ? "No maps found" + : `Showing ${maps.length} of ${total} maps`} +

+ )} + + {error && ( +
+ {error} +
+ )} + + + + {/* Pagination and Page Size */} +
+ {/* Pagination controls */} + {totalPages > 1 && ( +
+ + + + + Page {page} of {totalPages} + + + + +
+ )} + + {/* Page size selector */} +
+ Show: + + per page +
+
+
+ ); +} diff --git a/maps-web/src/pages/MapDetail.tsx b/maps-web/src/pages/MapDetail.tsx new file mode 100644 index 0000000..9a95445 --- /dev/null +++ b/maps-web/src/pages/MapDetail.tsx @@ -0,0 +1,233 @@ +import { useState, useEffect } from "react"; +import { useParams, Link } from "react-router-dom"; +import { + getMap, + getDownloadUrl, + getLevelshotUrl, + formatFileSize, + MapData, +} from "../api/maps"; + +const DIFFICULTY_COLORS: Record = { + Beginner: "bg-green-900 text-green-300", + Easy: "bg-green-800 text-green-200", + Medium: "bg-yellow-900 text-yellow-300", + Hard: "bg-red-900/80 text-red-300", + Insane: "bg-purple-900 text-purple-300", +}; + +export default function MapDetail() { + const { id } = useParams<{ id: string }>(); + const [map, setMap] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [imageError, setImageError] = useState(false); + + useEffect(() => { + const fetchMap = async () => { + if (!id) return; + + setLoading(true); + try { + const response = await getMap(parseInt(id)); + + if (response.success) { + setMap(response.data); + } else { + setError("Map not found"); + } + } catch (err) { + setError("Failed to load map"); + } finally { + setLoading(false); + } + }; + + fetchMap(); + }, [id]); + + const parseAuthors = (authorStr: string | null): string[] => { + if (!authorStr) return []; + return authorStr.split(",").map((a) => a.trim()).filter(Boolean); + }; + + if (loading) { + return ( +
+
+
+
+
+
+
+ ); + } + + if (error || !map) { + return ( +
+

+ {error || "Map not found"} +

+ + Back to maps + +
+ ); + } + + const authors = parseAuthors(map.author); + + return ( +
+ + ← Back to maps + + +
+ {/* Levelshot */} + {map.levelshotPath && !imageError ? ( +
+ {map.displayName setImageError(true)} + /> +
+ ) : ( +
+ No preview available +
+ )} + +
+ {/* Header */} +
+
+

+ {map.displayName || map.mapName || map.filename} +

+
+ {authors.length > 0 && ( +
+ by +
+ {authors.map((author, i) => ( + + {author} + {i < authors.length - 1 && ","} + + ))} +
+
+ )} + {map.releaseYear && ( + ({map.releaseYear}) + )} +
+
+ + + + + + Download + +
+ + {/* Features */} + {map.features && map.features.length > 0 && ( +
+

+ Features +

+
+ {map.features.map((feature) => ( + + {feature.replace(/_/g, " ")} + + ))} +
+
+ )} + + {/* Stats */} +
+
+

File Size

+

+ {formatFileSize(map.fileSize)} +

+
+ + {map.difficulty && ( +
+

Difficulty

+ + {map.difficulty} + +
+ )} + + {map.mapTypes && map.mapTypes.length > 0 && ( +
+

Type

+
+ {map.mapTypes.map((type) => ( + + {type.charAt(0).toUpperCase() + type.slice(1)} + + ))} +
+
+ )} + + {map.releaseYear && ( +
+

Release Year

+

{map.releaseYear}

+
+ )} +
+ + {/* File info */} +
+
+ {map.filename} + | + {map.checksum.slice(0, 16)}... +
+
+
+
+
+ ); +} diff --git a/maps-web/src/pages/Upload.tsx b/maps-web/src/pages/Upload.tsx new file mode 100644 index 0000000..d7e6618 --- /dev/null +++ b/maps-web/src/pages/Upload.tsx @@ -0,0 +1,687 @@ +import { useState, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import { + uploadMap, + updateMap, + uploadImage, + formatFileSize, +} from "../api/maps"; +import { useAuth } from "../context/AuthContext"; +import SecretKeyInput from "../components/SecretKeyInput"; + +const DIFFICULTIES = ["Beginner", "Easy", "Medium", "Hard", "Insane"]; +const FEATURES = ["portalgun", "pushers", "save_zones", "timerun"]; // Alphabetical +const MAP_TYPES = ["customs", "gamma", "originals"]; // Alphabetical + +interface PendingFile { + file: File; + id: string; +} + +export default function Upload() { + const navigate = useNavigate(); + const { secretKey, isAuthenticated } = useAuth(); + const fileInputRef = useRef(null); + const imageInputRef = useRef(null); + + // Bulk upload state - files stored in memory, not uploaded yet + const [pendingFiles, setPendingFiles] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [completedCount, setCompletedCount] = useState(0); + + // Upload/save state + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [dragOver, setDragOver] = useState(false); + + // Form fields (user fills these before upload) + const [displayName, setDisplayName] = useState(""); + const [authors, setAuthors] = useState([]); + const [authorInput, setAuthorInput] = useState(""); + const [releaseYear, setReleaseYear] = useState(""); + const [difficulty, setDifficulty] = useState(""); + const [mapTypes, setMapTypes] = useState([]); + const [selectedFeatures, setSelectedFeatures] = useState([]); + const [customFeature, setCustomFeature] = useState(""); + + // Image upload (optional, uploaded after map is saved) + const [imageFile, setImageFile] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + + const handleFilesSelect = (files: FileList | File[]) => { + const fileArray = Array.from(files); + const pk3Files = fileArray.filter(f => f.name.toLowerCase().endsWith(".pk3")); + + if (pk3Files.length === 0) { + setError("Please select .pk3 files"); + return; + } + + const newPending: PendingFile[] = pk3Files.map(file => ({ + file, + id: `${file.name}-${Date.now()}-${Math.random()}`, + })); + + setPendingFiles(newPending); + setCurrentIndex(0); + setCompletedCount(0); + resetFormFields(); + setError(null); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + if (e.dataTransfer.files.length > 0) { + handleFilesSelect(e.dataTransfer.files); + } + }; + + const handleFeatureToggle = (feature: string) => { + setSelectedFeatures((prev) => + prev.includes(feature) + ? prev.filter((f) => f !== feature) + : [...prev, feature] + ); + }; + + const addAuthor = () => { + const trimmed = authorInput.trim(); + if (trimmed && !authors.includes(trimmed)) { + setAuthors([...authors, trimmed]); + setAuthorInput(""); + } + }; + + const removeAuthor = (author: string) => { + setAuthors(authors.filter((a) => a !== author)); + }; + + const addCustomFeature = () => { + const trimmed = customFeature.trim().toLowerCase().replace(/\s+/g, "_"); + if (trimmed && !selectedFeatures.includes(trimmed)) { + setSelectedFeatures([...selectedFeatures, trimmed]); + setCustomFeature(""); + } + }; + + const handleImageSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) { + setError("Image must be JPEG, PNG, or WebP"); + return; + } + setImageFile(file); + setImagePreview(URL.createObjectURL(file)); + setError(null); + } + }; + + const resetFormFields = () => { + setDisplayName(""); + setAuthors([]); + setAuthorInput(""); + setReleaseYear(""); + setDifficulty(""); + setMapTypes([]); + setSelectedFeatures([]); + setCustomFeature(""); + setImageFile(null); + setImagePreview(null); + }; + + // Upload current file + details + optional image all at once + const handleSaveAndNext = async () => { + const currentFile = pendingFiles[currentIndex]; + if (!currentFile) return; + + if (!isAuthenticated) { + setError("Please set the secret key above first"); + return; + } + + setSaving(true); + setError(null); + + try { + // Step 1: Upload the PK3 file + const uploadResponse = await uploadMap(currentFile.file, secretKey); + + if (!uploadResponse.success) { + setError((uploadResponse as any).error || "Upload failed"); + setSaving(false); + return; + } + + const uploadedMap = uploadResponse.data; + + // Step 2: Update with user-provided details + const updateResponse = await updateMap( + uploadedMap.id, + { + displayName: displayName || undefined, + author: authors.length > 0 ? authors.join(", ") : undefined, + releaseYear: releaseYear ? Number(releaseYear) : undefined, + difficulty: difficulty || undefined, + mapTypes: mapTypes, + features: selectedFeatures.length > 0 ? selectedFeatures : uploadedMap.features, + }, + secretKey + ); + + if (!updateResponse.success) { + setError((updateResponse as any).error || "Failed to save details"); + setSaving(false); + return; + } + + // Step 3: Upload image if provided + if (imageFile) { + const imageResponse = await uploadImage(uploadedMap.id, imageFile, secretKey); + if (!imageResponse.success) { + // Image upload failed but map was saved - continue anyway + console.warn("Image upload failed:", (imageResponse as any).error); + } + } + + // Success - move to next file or finish + const newCompletedCount = completedCount + 1; + setCompletedCount(newCompletedCount); + + if (currentIndex < pendingFiles.length - 1) { + // More files to process + resetFormFields(); + setCurrentIndex(currentIndex + 1); + } else { + // All done + navigate("/"); + } + } catch (err) { + setError("Failed to upload map. Please try again."); + } finally { + setSaving(false); + } + }; + + const handleSkip = () => { + // Skip current file without uploading + if (currentIndex < pendingFiles.length - 1) { + resetFormFields(); + setCurrentIndex(currentIndex + 1); + } else { + // All done (some may have been skipped) + navigate("/"); + } + }; + + const handleCancel = () => { + // Cancel all remaining files - they're just in memory, not uploaded + navigate("/"); + }; + + const handleReset = () => { + setPendingFiles([]); + setCurrentIndex(0); + setCompletedCount(0); + resetFormFields(); + setError(null); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const currentFile = pendingFiles[currentIndex]; + const totalFiles = pendingFiles.length; + const remainingFiles = totalFiles - currentIndex; + + // Show form to fill details for current file (file is NOT uploaded yet) + if (currentFile) { + return ( +
+ {/* Progress indicator for bulk upload */} + {totalFiles > 1 && ( +
+
+ + Map {currentIndex + 1} of {totalFiles} + + + {completedCount} saved, {remainingFiles} remaining + +
+
+
+
+
+ {pendingFiles.map((pf, idx) => ( + + {pf.file.name.length > 20 + ? pf.file.name.slice(0, 17) + "..." + : pf.file.name} + + ))} +
+
+ )} + +
+

+ {currentFile.file.name} +

+

+ Fill in the details below. The file will be uploaded when you click Save. +

+

+ Size: {formatFileSize(currentFile.file.size)} +

+
+ +
+ {/* Image Upload Section */} +
+ +
+ {/* Preview Image */} +
+ {imagePreview ? ( + Preview + ) : ( +
+ No image selected +
+ )} +
+ + {/* Upload Controls */} +
+ + + {imageFile && ( +
+

{imageFile.name}

+ +
+ )} +

+ Image will be resized to 640x360 +

+
+
+
+ + {/* Form fields */} +
+ {/* Name */} +
+ + setDisplayName(e.target.value)} + placeholder="Map name (optional, will use filename if empty)" + className="w-full bg-neutral-700 border border-neutral-600 rounded-lg px-4 py-2 text-white placeholder-neutral-400 focus:outline-none focus:border-etjump" + /> +
+ + {/* Authors - tag input */} +
+ +
+ {authors.map((author) => ( + + {author} + + + ))} +
+
+ setAuthorInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addAuthor())} + placeholder="Add author..." + className="flex-1 bg-neutral-700 border border-neutral-600 rounded-lg px-4 py-2 text-white placeholder-neutral-400 focus:outline-none focus:border-etjump" + /> + +
+
+ + {/* Year and Difficulty on same row */} +
+
+ + + setReleaseYear(e.target.value ? parseInt(e.target.value) : "") + } + placeholder="e.g., 2024" + min="2000" + max={new Date().getFullYear()} + className="w-full bg-neutral-700 border border-neutral-600 rounded-lg px-4 py-2 text-white placeholder-neutral-400 focus:outline-none focus:border-etjump" + /> +
+ +
+ + +
+
+ + {/* Types - Multi-select */} +
+ +
+ {MAP_TYPES.map((type) => ( + + ))} +
+
+ + {/* Features */} +
+ +
+ {FEATURES.map((feature) => ( + + ))} + {/* Custom features */} + {selectedFeatures + .filter((f) => !FEATURES.includes(f)) + .map((feature) => ( + + {feature.replace(/_/g, " ")} + + + ))} +
+
+ setCustomFeature(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addCustomFeature())} + placeholder="Add custom feature..." + className="flex-1 bg-neutral-700 border border-neutral-600 rounded-lg px-4 py-2 text-white placeholder-neutral-400 focus:outline-none focus:border-etjump" + /> + +
+

+ Features will also be auto-detected from the BSP file when uploaded. +

+
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Actions */} +
+ + + {totalFiles > 1 && ( + + )} + + {totalFiles > 1 && currentIndex < totalFiles - 1 ? ( + + ) : totalFiles <= 1 ? ( + + ) : null} +
+ + {!isAuthenticated && ( +

+ Please enter the secret key at the top of the page to upload maps. +

+ )} +
+
+ ); + } + + // Initial file selection screen + return ( +
+
+

Upload Maps

+

+ Upload one or more .pk3 map files. Select multiple files to bulk upload + and fill in details for each one. +

+
+ + + +
+ {/* File drop zone */} +
+ +
{ + e.preventDefault(); + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + > + { + if (e.target.files && e.target.files.length > 0) { + handleFilesSelect(e.target.files); + } + }} + className="hidden" + /> + +
+ + + +

+ Drag and drop .pk3 files here, or click to select +

+

+ You can select multiple files for bulk upload +

+
+
+
+ + {error && ( +
+ {error} +
+ )} + + {!isAuthenticated && ( +

+ Please enter the secret key above to upload maps. +

+ )} +
+
+ ); +} diff --git a/maps-web/src/styles/globals.css b/maps-web/src/styles/globals.css new file mode 100644 index 0000000..8bac841 --- /dev/null +++ b/maps-web/src/styles/globals.css @@ -0,0 +1,11 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + @apply min-h-screen bg-neutral-900 text-neutral-100; +} + +a { + @apply text-etjump hover:text-etjump-400 transition-colors; +} diff --git a/maps-web/src/vite-env.d.ts b/maps-web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/maps-web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/maps-web/tailwind.config.js b/maps-web/tailwind.config.js new file mode 100644 index 0000000..a5e6984 --- /dev/null +++ b/maps-web/tailwind.config.js @@ -0,0 +1,25 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + theme: { + extend: { + colors: { + // ETJump brand color + etjump: { + DEFAULT: "#77E848", + 50: "#E5FBDB", + 100: "#D6F9C8", + 200: "#B8F4A1", + 300: "#9AEF7A", + 400: "#7CEA53", + 500: "#77E848", + 600: "#52D41E", + 700: "#3FA317", + 800: "#2C7210", + 900: "#194109", + }, + }, + }, + }, + plugins: [], +}; diff --git a/maps-web/tsconfig.json b/maps-web/tsconfig.json new file mode 100644 index 0000000..3934b8f --- /dev/null +++ b/maps-web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/maps-web/tsconfig.node.json b/maps-web/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/maps-web/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/maps-web/vite.config.ts b/maps-web/vite.config.ts new file mode 100644 index 0000000..1dd2a10 --- /dev/null +++ b/maps-web/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": { + target: "http://localhost:3001", + changeOrigin: true, + }, + }, + }, +});