feat: add admin panel with Discord OAuth and dashboard
Some checks failed
Deploy to Production / test (push) Failing after 37s

Adds a React admin panel (panel/) with Discord OAuth2 login,
live dashboard via WebSocket, and settings/management pages.
Includes Docker build support, Vite proxy config for dev,
game_settings migration, and open-redirect protection on auth callback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-02-13 20:27:14 +01:00
parent 121c242168
commit 2381f073ba
30 changed files with 3626 additions and 11 deletions

View File

@@ -20,6 +20,13 @@ DISCORD_BOT_TOKEN=your-discord-bot-token
DISCORD_CLIENT_ID=your-discord-client-id DISCORD_CLIENT_ID=your-discord-client-id
DISCORD_GUILD_ID=your-discord-guild-id DISCORD_GUILD_ID=your-discord-guild-id
# Admin Panel (Discord OAuth)
# Get client secret from: https://discord.com/developers/applications → OAuth2
DISCORD_CLIENT_SECRET=your-discord-client-secret
SESSION_SECRET=change-me-to-a-random-string
ADMIN_USER_IDS=123456789012345678
PANEL_BASE_URL=http://localhost:3000
# Server (for remote access scripts) # Server (for remote access scripts)
# Use a non-root user (see shared/scripts/setup-server.sh) # Use a non-root user (see shared/scripts/setup-server.sh)
VPS_USER=deploy VPS_USER=deploy

View File

@@ -16,6 +16,7 @@ FROM base AS deps
# Copy only package files first (better layer caching) # Copy only package files first (better layer caching)
COPY package.json bun.lock ./ COPY package.json bun.lock ./
COPY panel/package.json panel/
# Install dependencies # Install dependencies
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
@@ -45,6 +46,9 @@ COPY --from=deps /app/node_modules ./node_modules
# Copy source code # Copy source code
COPY . . COPY . .
# Build admin panel
RUN cd panel && bun run build
# ============================================ # ============================================
# Production stage - minimal runtime image # Production stage - minimal runtime image
# ============================================ # ============================================
@@ -56,6 +60,7 @@ COPY --from=builder --chown=bun:bun /app/node_modules ./node_modules
COPY --from=builder --chown=bun:bun /app/web/src ./web/src COPY --from=builder --chown=bun:bun /app/web/src ./web/src
COPY --from=builder --chown=bun:bun /app/bot ./bot COPY --from=builder --chown=bun:bun /app/bot ./bot
COPY --from=builder --chown=bun:bun /app/shared ./shared COPY --from=builder --chown=bun:bun /app/shared ./shared
COPY --from=builder --chown=bun:bun /app/panel/dist ./panel/dist
COPY --from=builder --chown=bun:bun /app/package.json . COPY --from=builder --chown=bun:bun /app/package.json .
COPY --from=builder --chown=bun:bun /app/drizzle.config.ts . COPY --from=builder --chown=bun:bun /app/drizzle.config.ts .
COPY --from=builder --chown=bun:bun /app/tsconfig.json . COPY --from=builder --chown=bun:bun /app/tsconfig.json .

295
bun.lock
View File

@@ -20,8 +20,67 @@
"typescript": "^5.9.3", "typescript": "^5.9.3",
}, },
}, },
"panel": {
"name": "panel",
"version": "0.1.0",
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.1",
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.10",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"autoprefixer": "^10.4.21",
"daisyui": "^5.0.43",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.10",
"typescript": "^5.9.3",
"vite": "^6.3.5",
},
},
}, },
"packages": { "packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "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" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "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" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "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" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "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" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@discordjs/builders": ["@discordjs/builders@1.13.0", "", { "dependencies": { "@discordjs/formatters": "^0.6.1", "@discordjs/util": "^1.1.1", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.31", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q=="], "@discordjs/builders": ["@discordjs/builders@1.13.0", "", { "dependencies": { "@discordjs/formatters": "^0.6.1", "@discordjs/util": "^1.1.1", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.31", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q=="],
"@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="], "@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
@@ -92,6 +151,16 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.89", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.89", "@napi-rs/canvas-darwin-arm64": "0.1.89", "@napi-rs/canvas-darwin-x64": "0.1.89", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.89", "@napi-rs/canvas-linux-arm64-gnu": "0.1.89", "@napi-rs/canvas-linux-arm64-musl": "0.1.89", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-musl": "0.1.89", "@napi-rs/canvas-win32-arm64-msvc": "0.1.89", "@napi-rs/canvas-win32-x64-msvc": "0.1.89" } }, "sha512-7GjmkMirJHejeALCqUnZY3QwID7bbumOiLrqq2LKgxrdjdmxWQBTc6rcASa2u8wuWrH7qo4/4n/VNrOwCoKlKg=="], "@napi-rs/canvas": ["@napi-rs/canvas@0.1.89", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.89", "@napi-rs/canvas-darwin-arm64": "0.1.89", "@napi-rs/canvas-darwin-x64": "0.1.89", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.89", "@napi-rs/canvas-linux-arm64-gnu": "0.1.89", "@napi-rs/canvas-linux-arm64-musl": "0.1.89", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-musl": "0.1.89", "@napi-rs/canvas-win32-arm64-msvc": "0.1.89", "@napi-rs/canvas-win32-x64-msvc": "0.1.89" } }, "sha512-7GjmkMirJHejeALCqUnZY3QwID7bbumOiLrqq2LKgxrdjdmxWQBTc6rcASa2u8wuWrH7qo4/4n/VNrOwCoKlKg=="],
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.89", "", { "os": "android", "cpu": "arm64" }, "sha512-CXxQTXsjtQqKGENS8Ejv9pZOFJhOPIl2goenS+aU8dY4DygvkyagDhy/I07D1YLqrDtPvLEX5zZHt8qUdnuIpQ=="], "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.89", "", { "os": "android", "cpu": "arm64" }, "sha512-CXxQTXsjtQqKGENS8Ejv9pZOFJhOPIl2goenS+aU8dY4DygvkyagDhy/I07D1YLqrDtPvLEX5zZHt8qUdnuIpQ=="],
@@ -116,26 +185,142 @@
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="], "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="], "@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="], "@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="], "@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="], "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
"autoprefixer": ["autoprefixer@10.4.24", "", { "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" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
"browserslist": ["browserslist@4.28.1", "", { "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" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "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=="], "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"daisyui": ["daisyui@5.5.18", "", {}, "sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"discord-api-types": ["discord-api-types@0.38.34", "", {}, "sha512-muq7xKGznj5MSFCzuIm/2TO7DpttuomUTemVM82fRqgnMl70YRzEyY24jlbiV6R9tzOTq6A6UnZ0bsfZeKD38Q=="], "discord-api-types": ["discord-api-types@0.38.34", "", {}, "sha512-muq7xKGznj5MSFCzuIm/2TO7DpttuomUTemVM82fRqgnMl70YRzEyY24jlbiV6R9tzOTq6A6UnZ0bsfZeKD38Q=="],
"discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="], "discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="],
@@ -146,30 +331,122 @@
"drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="], "drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="],
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"magic-bytes.js": ["magic-bytes.js@1.12.1", "", {}, "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA=="], "magic-bytes.js": ["magic-bytes.js@1.12.1", "", {}, "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"panel": ["panel@workspace:panel"],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="], "postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-router": ["react-router@7.13.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw=="],
"react-router-dom": ["react-router-dom@7.13.0", "", { "dependencies": { "react-router": "7.13.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "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" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"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=="], "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=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="], "ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -180,8 +457,14 @@
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
@@ -190,6 +473,18 @@
"@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-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=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@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-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-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],

View File

@@ -47,6 +47,10 @@ services:
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID} - DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID} - DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME} - DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
- SESSION_SECRET=${SESSION_SECRET}
- ADMIN_USER_IDS=${ADMIN_USER_IDS}
- PANEL_BASE_URL=${PANEL_BASE_URL:-http://localhost:3000}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View File

@@ -4,6 +4,7 @@
"module": "bot/index.ts", "module": "bot/index.ts",
"type": "module", "type": "module",
"private": true, "private": true,
"workspaces": ["panel"],
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"drizzle-kit": "^0.31.8" "drizzle-kit": "^0.31.8"
@@ -30,6 +31,8 @@
"test": "bash shared/scripts/test-sequential.sh", "test": "bash shared/scripts/test-sequential.sh",
"test:ci": "bash shared/scripts/test-sequential.sh --integration", "test:ci": "bash shared/scripts/test-sequential.sh --integration",
"test:simulate-ci": "bash shared/scripts/simulate-ci.sh", "test:simulate-ci": "bash shared/scripts/simulate-ci.sh",
"panel:dev": "cd panel && bun run dev",
"panel:build": "cd panel && bun run build",
"deploy": "bash shared/scripts/deploy.sh", "deploy": "bash shared/scripts/deploy.sh",
"deploy:remote": "bash shared/scripts/deploy-remote.sh", "deploy:remote": "bash shared/scripts/deploy-remote.sh",
"setup-server": "bash shared/scripts/setup-server.sh", "setup-server": "bash shared/scripts/setup-server.sh",

12
panel/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Aurora Admin Panel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

28
panel/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "panel",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.1"
},
"devDependencies": {
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"autoprefixer": "^10.4.21",
"daisyui": "^5.0.43",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.10",
"@tailwindcss/vite": "^4.1.10",
"typescript": "^5.9.3",
"vite": "^6.3.5"
}
}

53
panel/src/App.tsx Normal file
View File

@@ -0,0 +1,53 @@
import { Routes, Route } from "react-router-dom";
import { useAuth } from "./lib/useAuth";
import Layout from "./components/Layout";
import Dashboard from "./pages/Dashboard";
import Items from "./pages/Items";
import Quests from "./pages/Quests";
import Classes from "./pages/Classes";
import Users from "./pages/Users";
import Settings from "./pages/Settings";
import Lootdrops from "./pages/Lootdrops";
export default function App() {
const { loading, user, logout } = useAuth();
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-base-200">
<span className="loading loading-spinner loading-lg" />
</div>
);
}
if (!user) {
return (
<div className="min-h-screen flex items-center justify-center bg-base-200">
<div className="card bg-base-100 shadow-xl p-8 text-center max-w-sm">
<h1 className="text-2xl font-bold mb-2">Aurora Admin Panel</h1>
<p className="text-base-content/60 mb-6">Sign in with Discord to continue.</p>
<a href={`/auth/discord?return_to=${encodeURIComponent(window.location.origin + '/')}`} className="btn btn-primary">
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03z" />
</svg>
Sign in with Discord
</a>
</div>
</div>
);
}
return (
<Routes>
<Route element={<Layout user={user} onLogout={logout} />}>
<Route index element={<Dashboard />} />
<Route path="items" element={<Items />} />
<Route path="quests" element={<Quests />} />
<Route path="classes" element={<Classes />} />
<Route path="users" element={<Users />} />
<Route path="settings" element={<Settings />} />
<Route path="lootdrops" element={<Lootdrops />} />
</Route>
</Routes>
);
}

View File

@@ -0,0 +1,73 @@
import type { ReactNode } from "react";
export interface Column<T> {
key: string;
header: string;
render?: (row: T) => ReactNode;
className?: string;
}
interface DataTableProps<T> {
columns: Column<T>[];
data: T[];
keyField: string;
loading?: boolean;
onRowClick?: (row: T) => void;
emptyMessage?: string;
}
export default function DataTable<T extends Record<string, unknown>>({
columns,
data,
keyField,
loading,
onRowClick,
emptyMessage = "No data found",
}: DataTableProps<T>) {
if (loading) {
return (
<div className="flex justify-center p-8">
<span className="loading loading-spinner loading-lg" />
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="table table-zebra w-full">
<thead>
<tr>
{columns.map((col) => (
<th key={col.key} className={col.className}>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td colSpan={columns.length} className="text-center py-8 text-base-content/50">
{emptyMessage}
</td>
</tr>
) : (
data.map((row) => (
<tr
key={String(row[keyField])}
className={onRowClick ? "cursor-pointer hover" : ""}
onClick={() => onRowClick?.(row)}
>
{columns.map((col) => (
<td key={col.key} className={col.className}>
{col.render ? col.render(row) : String(row[col.key] ?? "")}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { NavLink, Outlet } from "react-router-dom";
import type { AuthUser } from "../lib/useAuth";
const NAV_ITEMS = [
{ to: "/", label: "Dashboard", icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1" },
{ to: "/items", label: "Items", icon: "M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" },
{ to: "/quests", label: "Quests", icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" },
{ to: "/classes", label: "Classes", icon: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" },
{ to: "/users", label: "Users", icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" },
{ to: "/settings", label: "Settings", icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z" },
{ to: "/lootdrops", label: "Lootdrops", icon: "M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7" },
];
function avatarUrl(user: AuthUser): string {
if (user.avatar) {
return `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`;
}
const index = (BigInt(user.discordId) >> 22n) % 6n;
return `https://cdn.discordapp.com/embed/avatars/${index}.png`;
}
export default function Layout({
user,
onLogout,
}: {
user: AuthUser;
onLogout: () => void;
}) {
return (
<div className="flex h-screen bg-base-200">
{/* Sidebar */}
<aside className="w-64 bg-base-300 flex flex-col">
<div className="p-4 font-bold text-xl border-b border-base-content/10">
Aurora Panel
</div>
<nav className="flex-1 p-2 space-y-1">
{NAV_ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === "/"}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
isActive
? "bg-primary text-primary-content"
: "hover:bg-base-content/10"
}`
}
>
<svg
className="w-5 h-5 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d={item.icon} />
</svg>
{item.label}
</NavLink>
))}
</nav>
<div className="p-3 border-t border-base-content/10">
<div className="flex items-center gap-3">
<img
src={avatarUrl(user)}
className="w-8 h-8 rounded-full"
alt=""
/>
<span className="text-sm font-medium flex-1 truncate">
{user.username}
</span>
<button
onClick={onLogout}
className="btn btn-ghost btn-xs"
title="Logout"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto p-6">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
actions?: ReactNode;
}
export default function Modal({ open, onClose, title, children, actions }: ModalProps) {
if (!open) return null;
return (
<dialog className="modal modal-open">
<div className="modal-box max-w-2xl">
<h3 className="font-bold text-lg mb-4">{title}</h3>
<button
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
onClick={onClose}
>
</button>
{children}
{actions && <div className="modal-action">{actions}</div>}
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={onClose}>close</button>
</form>
</dialog>
);
}

2
panel/src/index.css Normal file
View File

@@ -0,0 +1,2 @@
@import "tailwindcss";
@plugin "daisyui";

47
panel/src/lib/api.ts Normal file
View File

@@ -0,0 +1,47 @@
const BASE = "";
export interface ApiError {
error: string;
details?: string;
}
export async function api<T = unknown>(
path: string,
options: RequestInit = {}
): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
credentials: "same-origin",
});
if (res.status === 401) {
window.location.href = `/auth/discord?return_to=${encodeURIComponent(window.location.href)}`;
throw new Error("Unauthorized");
}
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
throw body as ApiError;
}
if (res.status === 204 || res.headers.get("content-length") === "0") {
return undefined as T;
}
return res.json() as Promise<T>;
}
export const get = <T = unknown>(path: string) => api<T>(path);
export const post = <T = unknown>(path: string, data?: unknown) =>
api<T>(path, { method: "POST", body: data ? JSON.stringify(data) : undefined });
export const put = <T = unknown>(path: string, data?: unknown) =>
api<T>(path, { method: "PUT", body: data ? JSON.stringify(data) : undefined });
export const del = <T = unknown>(path: string) =>
api<T>(path, { method: "DELETE" });

35
panel/src/lib/useAuth.ts Normal file
View File

@@ -0,0 +1,35 @@
import { useState, useEffect } from "react";
export interface AuthUser {
discordId: string;
username: string;
avatar: string | null;
}
interface AuthState {
loading: boolean;
user: AuthUser | null;
}
export function useAuth(): AuthState & { logout: () => Promise<void> } {
const [state, setState] = useState<AuthState>({ loading: true, user: null });
useEffect(() => {
fetch("/auth/me", { credentials: "same-origin" })
.then((r) => r.json())
.then((data: { authenticated: boolean; user?: AuthUser }) => {
setState({
loading: false,
user: data.authenticated ? data.user! : null,
});
})
.catch(() => setState({ loading: false, user: null }));
}, []);
const logout = async () => {
await fetch("/auth/logout", { method: "POST", credentials: "same-origin" });
setState({ loading: false, user: null });
};
return { ...state, logout };
}

13
panel/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);

133
panel/src/pages/Classes.tsx Normal file
View File

@@ -0,0 +1,133 @@
import { useState, useEffect, useCallback } from "react";
import { get, post, put, del } from "../lib/api";
import DataTable, { type Column } from "../components/DataTable";
import Modal from "../components/Modal";
interface GameClass {
id: string;
name: string;
balance: string;
roleId: string | null;
}
interface ClassesResponse {
classes: GameClass[];
}
export default function Classes() {
const [classes, setClasses] = useState<GameClass[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<GameClass | null>(null);
const [form, setForm] = useState<{ id?: string; name: string; balance: string; roleId: string | null }>({ name: "", balance: "0", roleId: null });
const [saving, setSaving] = useState(false);
const fetchClasses = useCallback(() => {
setLoading(true);
get<ClassesResponse>("/api/classes")
.then((data) => setClasses(data.classes))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => { fetchClasses(); }, [fetchClasses]);
const openCreate = () => {
setEditing(null);
setForm({ id: "", name: "", balance: "0", roleId: null });
setModalOpen(true);
};
const openEdit = (cls: GameClass) => {
setEditing(cls);
setForm({ name: cls.name, balance: cls.balance, roleId: cls.roleId });
setModalOpen(true);
};
const handleSave = async () => {
setSaving(true);
try {
if (editing) {
await put(`/api/classes/${editing.id}`, { name: form.name, balance: form.balance, roleId: form.roleId });
} else {
await post("/api/classes", { id: form.id, name: form.name, balance: form.balance, roleId: form.roleId });
}
setModalOpen(false);
fetchClasses();
} catch (e) {
alert(typeof e === "object" && e && "error" in e ? (e as { error: string }).error : "Failed to save");
} finally {
setSaving(false);
}
};
const handleDelete = async (cls: GameClass) => {
if (!confirm(`Delete "${cls.name}"?`)) return;
await del(`/api/classes/${cls.id}`);
fetchClasses();
};
const columns: Column<GameClass>[] = [
{ key: "id", header: "ID", className: "w-24" },
{ key: "name", header: "Name" },
{ key: "balance", header: "Balance", render: (r) => BigInt(r.balance).toLocaleString() },
{ key: "roleId", header: "Role ID", render: (r) => r.roleId ?? "—" },
{
key: "actions",
header: "",
className: "w-24",
render: (row) => (
<div className="flex gap-1">
<button className="btn btn-ghost btn-xs" onClick={(e) => { e.stopPropagation(); openEdit(row); }}>Edit</button>
<button className="btn btn-ghost btn-xs text-error" onClick={(e) => { e.stopPropagation(); handleDelete(row); }}>Del</button>
</div>
),
},
];
return (
<div>
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold">Classes</h1>
<button className="btn btn-primary btn-sm" onClick={openCreate}>+ New Class</button>
</div>
<DataTable columns={columns} data={classes as unknown as Record<string, unknown>[]} keyField="id" loading={loading} />
<Modal
open={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? `Edit: ${editing.name}` : "New Class"}
actions={
<>
<button className="btn btn-ghost" onClick={() => setModalOpen(false)}>Cancel</button>
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? <span className="loading loading-spinner loading-sm" /> : "Save"}
</button>
</>
}
>
<div className="space-y-3">
{!editing && (
<div className="form-control">
<label className="label"><span className="label-text">ID (Discord role snowflake or unique number)</span></label>
<input className="input input-bordered input-sm" value={form.id ?? ""} onChange={(e) => setForm({ ...form, id: e.target.value })} />
</div>
)}
<div className="form-control">
<label className="label"><span className="label-text">Name</span></label>
<input className="input input-bordered input-sm" maxLength={50} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Balance</span></label>
<input className="input input-bordered input-sm" value={form.balance} onChange={(e) => setForm({ ...form, balance: e.target.value })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Role ID (Discord)</span></label>
<input className="input input-bordered input-sm" placeholder="Optional" value={form.roleId ?? ""} onChange={(e) => setForm({ ...form, roleId: e.target.value || null })} />
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { useState, useEffect, useRef } from "react";
import { get } from "../lib/api";
interface Stats {
bot: { name: string; avatarUrl: string | null; status: string | null };
guilds: { count: number };
users: { total: number; active: number };
economy: { totalWealth: string; avgLevel: number; topStreak: number; totalItems: number };
commands: { total: number; active: number; disabled: number };
ping: { avg: number };
uptime: number;
}
export default function Dashboard() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
get<Stats>("/api/stats")
.then(setStats)
.catch(() => {})
.finally(() => setLoading(false));
// Connect WebSocket for live updates
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${protocol}//${location.host}/ws`);
wsRef.current = ws;
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === "STATS_UPDATE") setStats(msg.data);
} catch {}
};
return () => ws.close();
}, []);
if (loading) {
return (
<div className="flex justify-center p-12">
<span className="loading loading-spinner loading-lg" />
</div>
);
}
if (!stats) return <div className="alert alert-error">Failed to load stats</div>;
const uptimeHours = Math.floor((stats.uptime ?? 0) / 3600);
const uptimeMins = Math.floor(((stats.uptime ?? 0) % 3600) / 60);
return (
<div>
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div className="stat bg-base-100 rounded-box shadow">
<div className="stat-title">Uptime</div>
<div className="stat-value text-lg">{uptimeHours}h {uptimeMins}m</div>
<div className="stat-desc">Ping: {stats.ping?.avg ?? 0}ms</div>
</div>
<div className="stat bg-base-100 rounded-box shadow">
<div className="stat-title">Guilds</div>
<div className="stat-value text-lg">{stats.guilds?.count ?? 0}</div>
</div>
<div className="stat bg-base-100 rounded-box shadow">
<div className="stat-title">Users</div>
<div className="stat-value text-lg">{stats.users?.total ?? 0}</div>
<div className="stat-desc">{stats.users?.active ?? 0} active</div>
</div>
<div className="stat bg-base-100 rounded-box shadow">
<div className="stat-title">Economy</div>
<div className="stat-value text-lg">{Number(stats.economy?.totalWealth ?? 0).toLocaleString()}g</div>
<div className="stat-desc">{stats.economy?.totalItems ?? 0} items in circulation</div>
</div>
</div>
<div className="text-sm text-base-content/50">
Live data via WebSocket updates every 5 seconds
</div>
</div>
);
}

265
panel/src/pages/Items.tsx Normal file
View File

@@ -0,0 +1,265 @@
import { useState, useEffect, useCallback } from "react";
import { get, post, put, del } from "../lib/api";
import DataTable, { type Column } from "../components/DataTable";
import Modal from "../components/Modal";
interface Item {
id: number;
name: string;
description: string | null;
type: string;
rarity: string;
price: string | null;
iconUrl: string;
imageUrl: string;
usageData: unknown;
}
interface ItemsResponse {
items: Item[];
total: number;
}
const ITEM_TYPES = ["CONSUMABLE", "EQUIPMENT", "MATERIAL", "LOOTBOX", "COLLECTIBLE", "KEY", "TOOL"];
const ITEM_RARITIES = ["C", "R", "SR", "SSR"];
const emptyForm = () => ({
name: "",
description: "",
type: "MATERIAL",
rarity: "C",
price: "",
iconUrl: "",
imageUrl: "",
usageData: null as unknown,
});
export default function Items() {
const [items, setItems] = useState<Item[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [rarityFilter, setRarityFilter] = useState("");
const [page, setPage] = useState(0);
const limit = 25;
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Item | null>(null);
const [form, setForm] = useState(emptyForm());
const [saving, setSaving] = useState(false);
const fetchItems = useCallback(() => {
setLoading(true);
const params = new URLSearchParams({ limit: String(limit), offset: String(page * limit) });
if (search) params.set("search", search);
if (typeFilter) params.set("type", typeFilter);
if (rarityFilter) params.set("rarity", rarityFilter);
get<ItemsResponse>(`/api/items?${params}`)
.then((data) => {
setItems(data.items);
setTotal(data.total);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [search, typeFilter, rarityFilter, page]);
useEffect(() => { fetchItems(); }, [fetchItems]);
const openCreate = () => {
setEditing(null);
setForm(emptyForm());
setModalOpen(true);
};
const openEdit = (item: Item) => {
setEditing(item);
setForm({
name: item.name,
description: item.description ?? "",
type: item.type,
rarity: item.rarity,
price: item.price ?? "",
iconUrl: item.iconUrl,
imageUrl: item.imageUrl,
usageData: item.usageData,
});
setModalOpen(true);
};
const handleSave = async () => {
setSaving(true);
try {
const payload = {
name: form.name,
description: form.description || null,
type: form.type,
rarity: form.rarity,
price: form.price || null,
iconUrl: form.iconUrl,
imageUrl: form.imageUrl,
usageData: form.usageData,
};
if (editing) {
await put(`/api/items/${editing.id}`, payload);
} else {
await post("/api/items", payload);
}
setModalOpen(false);
fetchItems();
} catch (e) {
alert(typeof e === "object" && e && "error" in e ? (e as { error: string }).error : "Failed to save");
} finally {
setSaving(false);
}
};
const handleDelete = async (item: Item) => {
if (!confirm(`Delete "${item.name}"?`)) return;
await del(`/api/items/${item.id}`);
fetchItems();
};
const columns: Column<Item>[] = [
{ key: "id", header: "ID", className: "w-16" },
{
key: "iconUrl",
header: "",
className: "w-12",
render: (row) =>
row.iconUrl ? (
<img src={row.iconUrl} className="w-8 h-8 rounded object-cover" alt="" />
) : (
<div className="w-8 h-8 bg-base-300 rounded" />
),
},
{ key: "name", header: "Name" },
{
key: "type",
header: "Type",
render: (row) => <span className="badge badge-sm badge-outline">{row.type}</span>,
},
{
key: "rarity",
header: "Rarity",
render: (row) => {
const colors: Record<string, string> = { C: "badge-ghost", R: "badge-info", SR: "badge-warning", SSR: "badge-error" };
return <span className={`badge badge-sm ${colors[row.rarity] ?? ""}`}>{row.rarity}</span>;
},
},
{ key: "price", header: "Price", render: (row) => row.price ? `${BigInt(row.price).toLocaleString()}` : "—" },
{
key: "actions",
header: "",
className: "w-24",
render: (row) => (
<div className="flex gap-1">
<button className="btn btn-ghost btn-xs" onClick={(e) => { e.stopPropagation(); openEdit(row); }}>Edit</button>
<button className="btn btn-ghost btn-xs text-error" onClick={(e) => { e.stopPropagation(); handleDelete(row); }}>Del</button>
</div>
),
},
];
const totalPages = Math.ceil(total / limit);
return (
<div>
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold">Items</h1>
<button className="btn btn-primary btn-sm" onClick={openCreate}>+ New Item</button>
</div>
<div className="flex gap-2 mb-4 flex-wrap">
<input
type="text"
placeholder="Search..."
className="input input-bordered input-sm w-48"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
/>
<select className="select select-bordered select-sm" value={typeFilter} onChange={(e) => { setTypeFilter(e.target.value); setPage(0); }}>
<option value="">All Types</option>
{ITEM_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<select className="select select-bordered select-sm" value={rarityFilter} onChange={(e) => { setRarityFilter(e.target.value); setPage(0); }}>
<option value="">All Rarities</option>
{ITEM_RARITIES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<DataTable columns={columns} data={items as unknown as Record<string, unknown>[]} keyField="id" loading={loading} />
{totalPages > 1 && (
<div className="flex justify-center mt-4">
<div className="join">
<button className="join-item btn btn-sm" disabled={page === 0} onClick={() => setPage(page - 1)}>«</button>
<button className="join-item btn btn-sm">Page {page + 1} / {totalPages}</button>
<button className="join-item btn btn-sm" disabled={page >= totalPages - 1} onClick={() => setPage(page + 1)}>»</button>
</div>
</div>
)}
<Modal
open={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? `Edit: ${editing.name}` : "New Item"}
actions={
<>
<button className="btn btn-ghost" onClick={() => setModalOpen(false)}>Cancel</button>
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? <span className="loading loading-spinner loading-sm" /> : "Save"}
</button>
</>
}
>
<div className="grid grid-cols-2 gap-3">
<div className="form-control col-span-2">
<label className="label"><span className="label-text">Name</span></label>
<input className="input input-bordered input-sm" maxLength={100} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div className="form-control col-span-2">
<label className="label"><span className="label-text">Description</span></label>
<textarea className="textarea textarea-bordered textarea-sm" maxLength={500} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Type</span></label>
<select className="select select-bordered select-sm" value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
{ITEM_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div className="form-control">
<label className="label"><span className="label-text">Rarity</span></label>
<select className="select select-bordered select-sm" value={form.rarity} onChange={(e) => setForm({ ...form, rarity: e.target.value })}>
{ITEM_RARITIES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div className="form-control col-span-2">
<label className="label"><span className="label-text">Price</span></label>
<input className="input input-bordered input-sm" placeholder="Leave empty for no price" value={form.price} onChange={(e) => setForm({ ...form, price: e.target.value })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Icon URL</span></label>
<input className="input input-bordered input-sm" value={form.iconUrl} onChange={(e) => setForm({ ...form, iconUrl: e.target.value })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Image URL</span></label>
<input className="input input-bordered input-sm" value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
</div>
<div className="form-control col-span-2">
<label className="label"><span className="label-text">Usage Data (JSON)</span></label>
<textarea
className="textarea textarea-bordered textarea-sm font-mono text-xs"
rows={4}
value={form.usageData ? JSON.stringify(form.usageData, null, 2) : "{}"}
onChange={(e) => {
try { setForm({ ...form, usageData: e.target.value ? JSON.parse(e.target.value) : null }); } catch {}
}}
/>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,144 @@
import { useState, useEffect, useCallback } from "react";
import { get, post, del } from "../lib/api";
import DataTable, { type Column } from "../components/DataTable";
import Modal from "../components/Modal";
interface Lootdrop {
messageId: string;
channelId: string;
rewardAmount: number;
currency: string;
claimedBy: string | null;
createdAt: string;
expiresAt: string | null;
}
interface LootdropsResponse {
lootdrops: Lootdrop[];
}
export default function Lootdrops() {
const [drops, setDrops] = useState<Lootdrop[]>([]);
const [loading, setLoading] = useState(true);
const [spawnOpen, setSpawnOpen] = useState(false);
const [spawnForm, setSpawnForm] = useState({ channelId: "", amount: "", currency: "" });
const [spawning, setSpawning] = useState(false);
const fetchDrops = useCallback(() => {
setLoading(true);
get<LootdropsResponse>("/api/lootdrops")
.then((data) => setDrops(data.lootdrops))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => { fetchDrops(); }, [fetchDrops]);
const handleSpawn = async () => {
if (!spawnForm.channelId) return;
setSpawning(true);
try {
const payload: Record<string, unknown> = { channelId: spawnForm.channelId };
if (spawnForm.amount) payload.amount = Number(spawnForm.amount);
if (spawnForm.currency) payload.currency = spawnForm.currency;
await post("/api/lootdrops", payload);
setSpawnOpen(false);
setSpawnForm({ channelId: "", amount: "", currency: "" });
fetchDrops();
} catch (e) {
alert(typeof e === "object" && e && "error" in e ? (e as { error: string }).error : "Failed to spawn");
} finally {
setSpawning(false);
}
};
const handleCancel = async (drop: Lootdrop) => {
if (!confirm("Cancel this lootdrop?")) return;
await del(`/api/lootdrops/${drop.messageId}`);
fetchDrops();
};
const columns: Column<Lootdrop>[] = [
{ key: "messageId", header: "Message ID" },
{ key: "channelId", header: "Channel" },
{ key: "rewardAmount", header: "Reward", render: (r) => `${r.rewardAmount} ${r.currency}` },
{
key: "claimedBy",
header: "Status",
render: (r) => r.claimedBy
? <span className="badge badge-sm badge-ghost">Claimed by {r.claimedBy}</span>
: <span className="badge badge-sm badge-success">Active</span>,
},
{ key: "createdAt", header: "Created", render: (r) => new Date(r.createdAt).toLocaleString() },
{
key: "expiresAt",
header: "Expires",
render: (r) => r.expiresAt ? new Date(r.expiresAt).toLocaleString() : "—",
},
{
key: "actions",
header: "",
className: "w-20",
render: (row) =>
!row.claimedBy ? (
<button className="btn btn-ghost btn-xs text-error" onClick={(e) => { e.stopPropagation(); handleCancel(row); }}>Cancel</button>
) : null,
},
];
return (
<div>
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold">Lootdrops</h1>
<button className="btn btn-primary btn-sm" onClick={() => setSpawnOpen(true)}>Spawn Lootdrop</button>
</div>
<DataTable columns={columns} data={drops as unknown as Record<string, unknown>[]} keyField="messageId" loading={loading} />
<Modal
open={spawnOpen}
onClose={() => setSpawnOpen(false)}
title="Spawn Lootdrop"
actions={
<>
<button className="btn btn-ghost" onClick={() => setSpawnOpen(false)}>Cancel</button>
<button className="btn btn-primary" onClick={handleSpawn} disabled={spawning}>
{spawning ? <span className="loading loading-spinner loading-sm" /> : "Spawn"}
</button>
</>
}
>
<div className="space-y-3">
<div className="form-control">
<label className="label"><span className="label-text">Channel ID</span></label>
<input
className="input input-bordered input-sm"
placeholder="Discord channel ID"
value={spawnForm.channelId}
onChange={(e) => setSpawnForm({ ...spawnForm, channelId: e.target.value })}
/>
</div>
<div className="form-control">
<label className="label"><span className="label-text">Amount (optional)</span></label>
<input
type="number"
className="input input-bordered input-sm"
placeholder="Random if empty"
value={spawnForm.amount}
onChange={(e) => setSpawnForm({ ...spawnForm, amount: e.target.value })}
/>
</div>
<div className="form-control">
<label className="label"><span className="label-text">Currency (optional)</span></label>
<input
className="input input-bordered input-sm"
placeholder="Default from settings"
value={spawnForm.currency}
onChange={(e) => setSpawnForm({ ...spawnForm, currency: e.target.value })}
/>
</div>
</div>
</Modal>
</div>
);
}

170
panel/src/pages/Quests.tsx Normal file
View File

@@ -0,0 +1,170 @@
import { useState, useEffect, useCallback } from "react";
import { get, post, put, del } from "../lib/api";
import DataTable, { type Column } from "../components/DataTable";
import Modal from "../components/Modal";
interface Quest {
id: number;
name: string;
description: string | null;
triggerEvent: string;
requirements: { target: number };
rewards: { xp: number; balance: number };
}
interface QuestsResponse {
success: boolean;
data: Quest[];
}
export default function Quests() {
const [quests, setQuests] = useState<Quest[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Quest | null>(null);
const [form, setForm] = useState({
name: "",
description: "",
triggerEvent: "",
target: 1,
xpReward: 0,
balanceReward: 0,
});
const [saving, setSaving] = useState(false);
const fetchQuests = useCallback(() => {
setLoading(true);
get<QuestsResponse>("/api/quests")
.then((data) => setQuests(data.data))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => { fetchQuests(); }, [fetchQuests]);
const openCreate = () => {
setEditing(null);
setForm({ name: "", description: "", triggerEvent: "", target: 1, xpReward: 0, balanceReward: 0 });
setModalOpen(true);
};
const openEdit = (quest: Quest) => {
setEditing(quest);
setForm({
name: quest.name,
description: quest.description ?? "",
triggerEvent: quest.triggerEvent,
target: quest.requirements.target,
xpReward: quest.rewards.xp,
balanceReward: quest.rewards.balance,
});
setModalOpen(true);
};
const handleSave = async () => {
setSaving(true);
try {
const payload = {
name: form.name,
description: form.description || undefined,
triggerEvent: form.triggerEvent,
target: form.target,
xpReward: form.xpReward,
balanceReward: form.balanceReward,
};
if (editing) {
await put(`/api/quests/${editing.id}`, payload);
} else {
await post("/api/quests", payload);
}
setModalOpen(false);
fetchQuests();
} catch (e) {
alert(typeof e === "object" && e && "error" in e ? (e as { error: string }).error : "Failed to save");
} finally {
setSaving(false);
}
};
const handleDelete = async (quest: Quest) => {
if (!confirm(`Delete "${quest.name}"?`)) return;
await del(`/api/quests/${quest.id}`);
fetchQuests();
};
const columns: Column<Quest>[] = [
{ key: "id", header: "ID", className: "w-16" },
{ key: "name", header: "Name" },
{ key: "triggerEvent", header: "Trigger", render: (r) => <span className="badge badge-sm badge-outline">{r.triggerEvent}</span> },
{ key: "target", header: "Target", render: (r) => String(r.requirements.target) },
{ key: "xpReward", header: "XP Reward", render: (r) => String(r.rewards.xp) },
{ key: "balanceReward", header: "Gold Reward", render: (r) => String(r.rewards.balance) },
{
key: "actions",
header: "",
className: "w-24",
render: (row) => (
<div className="flex gap-1">
<button className="btn btn-ghost btn-xs" onClick={(e) => { e.stopPropagation(); openEdit(row); }}>Edit</button>
<button className="btn btn-ghost btn-xs text-error" onClick={(e) => { e.stopPropagation(); handleDelete(row); }}>Del</button>
</div>
),
},
];
return (
<div>
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold">Quests</h1>
<button className="btn btn-primary btn-sm" onClick={openCreate}>+ New Quest</button>
</div>
<DataTable columns={columns} data={quests as unknown as Record<string, unknown>[]} keyField="id" loading={loading} />
<Modal
open={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? `Edit: ${editing.name}` : "New Quest"}
actions={
<>
<button className="btn btn-ghost" onClick={() => setModalOpen(false)}>Cancel</button>
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? <span className="loading loading-spinner loading-sm" /> : "Save"}
</button>
</>
}
>
<div className="space-y-3">
<div className="form-control">
<label className="label"><span className="label-text">Name</span></label>
<input className="input input-bordered input-sm" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Description</span></label>
<textarea className="textarea textarea-bordered textarea-sm" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="form-control">
<label className="label"><span className="label-text">Trigger Event</span></label>
<input className="input input-bordered input-sm" value={form.triggerEvent} onChange={(e) => setForm({ ...form, triggerEvent: e.target.value })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Target</span></label>
<input type="number" className="input input-bordered input-sm" min={1} value={form.target} onChange={(e) => setForm({ ...form, target: Number(e.target.value) })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="form-control">
<label className="label"><span className="label-text">XP Reward</span></label>
<input type="number" className="input input-bordered input-sm" min={0} value={form.xpReward} onChange={(e) => setForm({ ...form, xpReward: Number(e.target.value) })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Balance Reward</span></label>
<input type="number" className="input input-bordered input-sm" min={0} value={form.balanceReward} onChange={(e) => setForm({ ...form, balanceReward: Number(e.target.value) })} />
</div>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,94 @@
import { useState, useEffect } from "react";
import { get, post } from "../lib/api";
export default function Settings() {
const [settings, setSettings] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [raw, setRaw] = useState("");
const [parseError, setParseError] = useState("");
useEffect(() => {
get<Record<string, unknown>>("/api/settings")
.then((data) => {
setSettings(data);
setRaw(JSON.stringify(data, null, 2));
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleRawChange = (value: string) => {
setRaw(value);
try {
JSON.parse(value);
setParseError("");
} catch (e) {
setParseError(e instanceof Error ? e.message : "Invalid JSON");
}
};
const handleSave = async () => {
if (parseError) return;
setSaving(true);
try {
const parsed = JSON.parse(raw);
const updated = await post<Record<string, unknown>>("/api/settings", parsed);
setSettings(updated);
setRaw(JSON.stringify(updated, null, 2));
} catch (e) {
alert(typeof e === "object" && e && "error" in e ? (e as { error: string }).error : "Failed to save");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex justify-center p-12">
<span className="loading loading-spinner loading-lg" />
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold">Settings</h1>
<button
className="btn btn-primary btn-sm"
onClick={handleSave}
disabled={saving || !!parseError}
>
{saving ? <span className="loading loading-spinner loading-sm" /> : "Save"}
</button>
</div>
<div className="text-sm text-base-content/60 mb-2">
Edit game configuration directly. Changes are merged with existing settings.
</div>
{parseError && (
<div className="alert alert-error mb-3 py-2 text-sm">{parseError}</div>
)}
<textarea
className="textarea textarea-bordered w-full font-mono text-sm"
rows={30}
value={raw}
onChange={(e) => handleRawChange(e.target.value)}
/>
{settings && (
<div className="mt-4">
<h3 className="text-sm font-semibold mb-2">Quick Reference Top-level keys:</h3>
<div className="flex flex-wrap gap-1">
{Object.keys(settings).map((key) => (
<span key={key} className="badge badge-sm badge-outline">{key}</span>
))}
</div>
</div>
)}
</div>
);
}

265
panel/src/pages/Users.tsx Normal file
View File

@@ -0,0 +1,265 @@
import { useState, useEffect, useCallback } from "react";
import { get, put, post, del } from "../lib/api";
import DataTable, { type Column } from "../components/DataTable";
import Modal from "../components/Modal";
interface User {
id: string;
classId: string | null;
username: string;
isActive: boolean;
balance: string;
xp: string;
level: number;
dailyStreak: number;
settings: unknown;
createdAt: string;
updatedAt: string;
}
interface UsersResponse {
users: User[];
total: number;
}
interface InventoryEntry {
userId: string;
itemId: number;
quantity: string;
item: { id: number; name: string; rarity: string; type: string };
}
interface InventoryResponse {
inventory: InventoryEntry[];
}
export default function Users() {
const [users, setUsers] = useState<User[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const limit = 25;
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [inventory, setInventory] = useState<InventoryEntry[]>([]);
const [invLoading, setInvLoading] = useState(false);
const [editForm, setEditForm] = useState<{ balance: string; level: string; xp: string; dailyStreak: string; isActive: boolean }>({ balance: "0", level: "1", xp: "0", dailyStreak: "0", isActive: true });
const [saving, setSaving] = useState(false);
const [addItemOpen, setAddItemOpen] = useState(false);
const [addItemId, setAddItemId] = useState("");
const [addItemQty, setAddItemQty] = useState("1");
const fetchUsers = useCallback(() => {
setLoading(true);
const params = new URLSearchParams({ limit: String(limit), offset: String(page * limit) });
if (search) params.set("search", search);
get<UsersResponse>(`/api/users?${params}`)
.then((data) => { setUsers(data.users); setTotal(data.total); })
.catch(() => {})
.finally(() => setLoading(false));
}, [search, page]);
useEffect(() => { fetchUsers(); }, [fetchUsers]);
const openUser = (user: User) => {
setSelectedUser(user);
setEditForm({
balance: user.balance,
level: String(user.level),
xp: user.xp,
dailyStreak: String(user.dailyStreak),
isActive: user.isActive,
});
setInvLoading(true);
get<InventoryResponse>(`/api/users/${user.id}/inventory`)
.then((data) => setInventory(data.inventory))
.catch(() => setInventory([]))
.finally(() => setInvLoading(false));
};
const handleSaveUser = async () => {
if (!selectedUser) return;
setSaving(true);
try {
await put(`/api/users/${selectedUser.id}`, {
balance: editForm.balance,
level: Number(editForm.level),
xp: editForm.xp,
dailyStreak: Number(editForm.dailyStreak),
isActive: editForm.isActive,
});
fetchUsers();
setSelectedUser(null);
} catch (e) {
alert(typeof e === "object" && e && "error" in e ? (e as { error: string }).error : "Failed");
} finally {
setSaving(false);
}
};
const handleAddItem = async () => {
if (!selectedUser || !addItemId) return;
try {
await post(`/api/users/${selectedUser.id}/inventory`, { itemId: Number(addItemId), quantity: addItemQty });
const data = await get<InventoryResponse>(`/api/users/${selectedUser.id}/inventory`);
setInventory(data.inventory);
setAddItemOpen(false);
setAddItemId("");
setAddItemQty("1");
} catch (e) {
alert(typeof e === "object" && e && "error" in e ? (e as { error: string }).error : "Failed");
}
};
const handleRemoveItem = async (itemId: number) => {
if (!selectedUser) return;
await del(`/api/users/${selectedUser.id}/inventory/${itemId}`);
const data = await get<InventoryResponse>(`/api/users/${selectedUser.id}/inventory`);
setInventory(data.inventory);
};
const columns: Column<User>[] = [
{ key: "id", header: "ID" },
{ key: "username", header: "Username" },
{ key: "level", header: "Lv" },
{ key: "xp", header: "XP", render: (r) => BigInt(r.xp).toLocaleString() },
{ key: "balance", header: "Balance", render: (r) => BigInt(r.balance).toLocaleString() },
{ key: "dailyStreak", header: "Streak" },
{
key: "isActive",
header: "Active",
render: (r) => <span className={`badge badge-sm ${r.isActive ? "badge-success" : "badge-ghost"}`}>{r.isActive ? "Yes" : "No"}</span>,
},
];
const totalPages = Math.ceil(total / limit);
return (
<div>
<h1 className="text-2xl font-bold mb-4">Users</h1>
<div className="mb-4">
<input
type="text"
placeholder="Search by username or ID..."
className="input input-bordered input-sm w-72"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
/>
</div>
<DataTable columns={columns} data={users as unknown as Record<string, unknown>[]} keyField="id" loading={loading} onRowClick={(r) => openUser(r as unknown as User)} />
{totalPages > 1 && (
<div className="flex justify-center mt-4">
<div className="join">
<button className="join-item btn btn-sm" disabled={page === 0} onClick={() => setPage(page - 1)}>«</button>
<button className="join-item btn btn-sm">Page {page + 1} / {totalPages}</button>
<button className="join-item btn btn-sm" disabled={page >= totalPages - 1} onClick={() => setPage(page + 1)}>»</button>
</div>
</div>
)}
<Modal
open={!!selectedUser}
onClose={() => setSelectedUser(null)}
title={selectedUser ? `User: ${selectedUser.username}` : ""}
actions={
<>
<button className="btn btn-ghost" onClick={() => setSelectedUser(null)}>Close</button>
<button className="btn btn-primary" onClick={handleSaveUser} disabled={saving}>
{saving ? <span className="loading loading-spinner loading-sm" /> : "Save Changes"}
</button>
</>
}
>
{selectedUser && (
<div className="space-y-4">
<div className="text-sm text-base-content/60">
ID: {selectedUser.id} | Class: {selectedUser.classId ?? "None"} | Joined: {new Date(selectedUser.createdAt).toLocaleDateString()}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="form-control">
<label className="label"><span className="label-text">Balance</span></label>
<input className="input input-bordered input-sm" value={editForm.balance} onChange={(e) => setEditForm({ ...editForm, balance: e.target.value })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Level</span></label>
<input type="number" className="input input-bordered input-sm" min={0} value={editForm.level} onChange={(e) => setEditForm({ ...editForm, level: e.target.value })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">XP</span></label>
<input className="input input-bordered input-sm" value={editForm.xp} onChange={(e) => setEditForm({ ...editForm, xp: e.target.value })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Daily Streak</span></label>
<input type="number" className="input input-bordered input-sm" min={0} value={editForm.dailyStreak} onChange={(e) => setEditForm({ ...editForm, dailyStreak: e.target.value })} />
</div>
</div>
<div className="form-control">
<label className="label cursor-pointer justify-start gap-2">
<input type="checkbox" className="toggle toggle-sm toggle-success" checked={editForm.isActive} onChange={(e) => setEditForm({ ...editForm, isActive: e.target.checked })} />
<span className="label-text">Active</span>
</label>
</div>
<div className="divider">Inventory</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">Items ({inventory.length})</span>
<button className="btn btn-sm btn-outline" onClick={() => setAddItemOpen(true)}>+ Add Item</button>
</div>
{invLoading ? (
<span className="loading loading-spinner loading-sm" />
) : inventory.length === 0 ? (
<div className="text-sm text-base-content/50">No items</div>
) : (
<div className="overflow-x-auto max-h-48">
<table className="table table-xs">
<thead><tr><th>Item</th><th>Qty</th><th></th></tr></thead>
<tbody>
{inventory.map((inv) => (
<tr key={inv.itemId}>
<td>{inv.item?.name ?? `#${inv.itemId}`}</td>
<td>{BigInt(inv.quantity).toLocaleString()}</td>
<td><button className="btn btn-ghost btn-xs text-error" onClick={() => handleRemoveItem(inv.itemId)}>Remove</button></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</Modal>
<Modal
open={addItemOpen}
onClose={() => setAddItemOpen(false)}
title="Add Item to Inventory"
actions={
<>
<button className="btn btn-ghost" onClick={() => setAddItemOpen(false)}>Cancel</button>
<button className="btn btn-primary" onClick={handleAddItem}>Add</button>
</>
}
>
<div className="grid grid-cols-2 gap-3">
<div className="form-control">
<label className="label"><span className="label-text">Item ID</span></label>
<input type="number" className="input input-bordered input-sm" value={addItemId} onChange={(e) => setAddItemId(e.target.value)} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Quantity</span></label>
<input className="input input-bordered input-sm" value={addItemQty} onChange={(e) => setAddItemQty(e.target.value)} />
</div>
</div>
</Modal>
</div>
);
}

19
panel/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true
},
"include": ["src"]
}

21
panel/vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
"/api": "http://localhost:3000",
"/auth": "http://localhost:3000",
"/ws": {
target: "ws://localhost:3000",
ws: true,
},
},
},
build: {
outDir: "dist",
},
});

View File

@@ -0,0 +1,13 @@
CREATE TABLE "game_settings" (
"id" text PRIMARY KEY DEFAULT 'default' NOT NULL,
"leveling" jsonb NOT NULL,
"economy" jsonb NOT NULL,
"inventory" jsonb NOT NULL,
"lootdrop" jsonb NOT NULL,
"trivia" jsonb NOT NULL,
"moderation" jsonb NOT NULL,
"commands" jsonb DEFAULT '{}'::jsonb,
"system" jsonb DEFAULT '{}'::jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,13 @@
"when": 1770904612078, "when": 1770904612078,
"tag": "0004_bored_kat_farrell", "tag": "0004_bored_kat_farrell",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1771010684586,
"tag": "0005_wealthy_golden_guardian",
"breakpoints": true
} }
] ]
} }

View File

@@ -0,0 +1,233 @@
/**
* @fileoverview Discord OAuth2 authentication routes for the admin panel.
* Handles login flow, callback, logout, and session management.
*/
import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse } from "./utils";
import { logger } from "@shared/lib/logger";
// In-memory session store: token → { discordId, username, avatar, expiresAt }
export interface Session {
discordId: string;
username: string;
avatar: string | null;
expiresAt: number;
}
const sessions = new Map<string, Session>();
const redirects = new Map<string, string>(); // redirect token -> return_to URL
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
function getEnv(key: string): string {
const val = process.env[key];
if (!val) throw new Error(`Missing env: ${key}`);
return val;
}
function getAdminIds(): string[] {
const raw = process.env.ADMIN_USER_IDS ?? "";
return raw.split(",").map(s => s.trim()).filter(Boolean);
}
function generateToken(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
}
function getBaseUrl(): string {
return process.env.PANEL_BASE_URL ?? `http://localhost:3000`;
}
function parseCookies(header: string | null): Record<string, string> {
if (!header) return {};
const cookies: Record<string, string> = {};
for (const pair of header.split(";")) {
const [key, ...rest] = pair.trim().split("=");
if (key) cookies[key] = rest.join("=");
}
return cookies;
}
/** Get session from request cookie */
export function getSession(req: Request): Session | null {
const cookies = parseCookies(req.headers.get("cookie"));
const token = cookies["aurora_session"];
if (!token) return null;
const session = sessions.get(token);
if (!session) return null;
if (Date.now() > session.expiresAt) {
sessions.delete(token);
return null;
}
return session;
}
/** Check if request is authenticated as admin */
export function isAuthenticated(req: Request): boolean {
return getSession(req) !== null;
}
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method } = ctx;
// GET /auth/discord — redirect to Discord OAuth
if (pathname === "/auth/discord" && method === "GET") {
try {
const clientId = getEnv("DISCORD_CLIENT_ID");
const baseUrl = getBaseUrl();
const redirectUri = encodeURIComponent(`${baseUrl}/auth/callback`);
const scope = "identify+email";
// Store return_to URL if provided
const returnTo = ctx.url.searchParams.get("return_to") || "/";
const redirectToken = generateToken();
redirects.set(redirectToken, returnTo);
const url = `https://discord.com/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}`;
// Set a temporary cookie with the redirect token
return new Response(null, {
status: 302,
headers: {
Location: url,
"Set-Cookie": `aurora_redirect=${redirectToken}; Path=/; Max-Age=600; SameSite=Lax`,
},
});
} catch (e) {
logger.error("auth", "Failed to initiate OAuth", e);
return errorResponse("OAuth not configured", 500);
}
}
// GET /auth/callback — handle Discord OAuth callback
if (pathname === "/auth/callback" && method === "GET") {
const code = ctx.url.searchParams.get("code");
if (!code) return errorResponse("Missing code parameter", 400);
try {
const clientId = getEnv("DISCORD_CLIENT_ID");
const clientSecret = getEnv("DISCORD_CLIENT_SECRET");
const baseUrl = getBaseUrl();
const redirectUri = `${baseUrl}/auth/callback`;
// Exchange code for token
const tokenRes = await fetch("https://discord.com/api/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
}),
});
if (!tokenRes.ok) {
logger.error("auth", `Token exchange failed: ${tokenRes.status}`);
return errorResponse("OAuth token exchange failed", 401);
}
const tokenData = await tokenRes.json() as { access_token: string };
// Fetch user info
const userRes = await fetch("https://discord.com/api/users/@me", {
headers: { Authorization: `Bearer ${tokenData.access_token}` },
});
if (!userRes.ok) {
return errorResponse("Failed to fetch Discord user", 401);
}
const user = await userRes.json() as { id: string; username: string; avatar: string | null };
// Check allowlist
const adminIds = getAdminIds();
if (adminIds.length > 0 && !adminIds.includes(user.id)) {
logger.warn("auth", `Unauthorized login attempt by ${user.username} (${user.id})`);
return new Response(
`<html><body><h1>Access Denied</h1><p>Your Discord account is not authorized.</p></body></html>`,
{ status: 403, headers: { "Content-Type": "text/html" } }
);
}
// Create session
const token = generateToken();
sessions.set(token, {
discordId: user.id,
username: user.username,
avatar: user.avatar,
expiresAt: Date.now() + SESSION_MAX_AGE,
});
logger.info("auth", `Admin login: ${user.username} (${user.id})`);
// Get return_to URL from redirect token cookie
const cookies = parseCookies(ctx.req.headers.get("cookie"));
const redirectToken = cookies["aurora_redirect"];
let returnTo = redirectToken && redirects.get(redirectToken) ? redirects.get(redirectToken)! : "/";
if (redirectToken) redirects.delete(redirectToken);
// Only allow redirects to localhost or relative paths (prevent open redirect)
try {
const parsed = new URL(returnTo, baseUrl);
if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") {
returnTo = "/";
}
} catch {
returnTo = "/";
}
// Redirect to panel with session cookie
return new Response(null, {
status: 302,
headers: {
Location: returnTo,
"Set-Cookie": `aurora_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE / 1000}`,
},
});
} catch (e) {
logger.error("auth", "OAuth callback error", e);
return errorResponse("Authentication failed", 500);
}
}
// POST /auth/logout — clear session
if (pathname === "/auth/logout" && method === "POST") {
const cookies = parseCookies(ctx.req.headers.get("cookie"));
const token = cookies["aurora_session"];
if (token) sessions.delete(token);
return new Response(null, {
status: 200,
headers: {
"Set-Cookie": "aurora_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
"Content-Type": "application/json",
},
});
}
// GET /auth/me — return current session info
if (pathname === "/auth/me" && method === "GET") {
const session = getSession(ctx.req);
if (!session) return jsonResponse({ authenticated: false }, 401);
return jsonResponse({
authenticated: true,
user: {
discordId: session.discordId,
username: session.username,
avatar: session.avatar,
},
});
}
return null;
}
export const authRoutes: RouteModule = {
name: "auth",
handler,
};

View File

@@ -4,6 +4,7 @@
*/ */
import type { RouteContext, RouteModule } from "./types"; import type { RouteContext, RouteModule } from "./types";
import { authRoutes, isAuthenticated } from "./auth.routes";
import { healthRoutes } from "./health.routes"; import { healthRoutes } from "./health.routes";
import { statsRoutes } from "./stats.routes"; import { statsRoutes } from "./stats.routes";
import { actionsRoutes } from "./actions.routes"; import { actionsRoutes } from "./actions.routes";
@@ -17,13 +18,16 @@ import { moderationRoutes } from "./moderation.routes";
import { transactionsRoutes } from "./transactions.routes"; import { transactionsRoutes } from "./transactions.routes";
import { lootdropsRoutes } from "./lootdrops.routes"; import { lootdropsRoutes } from "./lootdrops.routes";
import { assetsRoutes } from "./assets.routes"; import { assetsRoutes } from "./assets.routes";
import { errorResponse } from "./utils";
/** /** Routes that do NOT require authentication */
* All registered route modules in order of precedence. const publicRoutes: RouteModule[] = [
* Routes are checked in order; the first matching route wins. authRoutes,
*/
const routeModules: RouteModule[] = [
healthRoutes, healthRoutes,
];
/** Routes that require an authenticated admin session */
const protectedRoutes: RouteModule[] = [
statsRoutes, statsRoutes,
actionsRoutes, actionsRoutes,
questsRoutes, questsRoutes,
@@ -58,14 +62,25 @@ export async function handleRequest(req: Request, url: URL): Promise<Response |
pathname: url.pathname, pathname: url.pathname,
}; };
// Try each route module in order // Try public routes first (auth, health)
for (const module of routeModules) { for (const module of publicRoutes) {
const response = await module.handler(ctx); const response = await module.handler(ctx);
if (response !== null) { if (response !== null) return response;
return response; }
// For API routes, enforce authentication
if (ctx.pathname.startsWith("/api/")) {
if (!isAuthenticated(req)) {
return errorResponse("Unauthorized", 401);
} }
} }
// Try protected routes
for (const module of protectedRoutes) {
const response = await module.handler(ctx);
if (response !== null) return response;
}
return null; return null;
} }
@@ -74,5 +89,5 @@ export async function handleRequest(req: Request, url: URL): Promise<Response |
* Useful for debugging and documentation. * Useful for debugging and documentation.
*/ */
export function getRegisteredRoutes(): string[] { export function getRegisteredRoutes(): string[] {
return routeModules.map(m => m.name); return [...publicRoutes, ...protectedRoutes].map(m => m.name);
} }

View File

@@ -7,10 +7,11 @@
* Each route module handles its own validation, business logic, and responses. * Each route module handles its own validation, business logic, and responses.
*/ */
import { serve } from "bun"; import { serve, file } from "bun";
import { logger } from "@shared/lib/logger"; import { logger } from "@shared/lib/logger";
import { handleRequest } from "./routes"; import { handleRequest } from "./routes";
import { getFullDashboardStats } from "./routes/stats.helper"; import { getFullDashboardStats } from "./routes/stats.helper";
import { join } from "path";
export interface WebServerConfig { export interface WebServerConfig {
port?: number; port?: number;
@@ -38,6 +39,54 @@ export interface WebServerInstance {
* // To stop the server: * // To stop the server:
* await server.stop(); * await server.stop();
*/ */
const MIME_TYPES: Record<string, string> = {
".html": "text/html",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
};
/**
* Serve static files from the panel dist directory.
* Falls back to index.html for SPA routing.
*/
async function servePanelStatic(pathname: string, distDir: string): Promise<Response | null> {
// Don't serve panel for API/auth/ws/assets routes
if (pathname.startsWith("/api/") || pathname.startsWith("/auth/") || pathname === "/ws" || pathname.startsWith("/assets/")) {
return null;
}
// Try to serve the exact file
const filePath = join(distDir, pathname);
const bunFile = file(filePath);
if (await bunFile.exists()) {
const ext = pathname.substring(pathname.lastIndexOf("."));
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
return new Response(bunFile, {
headers: {
"Content-Type": contentType,
"Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=31536000, immutable",
},
});
}
// SPA fallback: serve index.html for all non-file routes
const indexFile = file(join(distDir, "index.html"));
if (await indexFile.exists()) {
return new Response(indexFile, {
headers: { "Content-Type": "text/html", "Cache-Control": "no-cache" },
});
}
return null;
}
export async function createWebServer(config: WebServerConfig = {}): Promise<WebServerInstance> { export async function createWebServer(config: WebServerConfig = {}): Promise<WebServerInstance> {
const { port = 3000, hostname = "localhost" } = config; const { port = 3000, hostname = "localhost" } = config;
@@ -72,6 +121,11 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
const response = await handleRequest(req, url); const response = await handleRequest(req, url);
if (response) return response; if (response) return response;
// Serve panel static files (production)
const panelDistDir = join(import.meta.dir, "../../panel/dist");
const staticResponse = await servePanelStatic(url.pathname, panelDistDir);
if (staticResponse) return staticResponse;
// No matching route found // No matching route found
return new Response("Not Found", { status: 404 }); return new Response("Not Found", { status: 404 });
}, },