forked from syntaxbullet/aurorabot
feat: add admin panel with Discord OAuth and dashboard
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:
@@ -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
|
||||||
|
|||||||
@@ -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
295
bun.lock
@@ -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=="],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
12
panel/index.html
Normal 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
28
panel/package.json
Normal 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
53
panel/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
panel/src/components/DataTable.tsx
Normal file
73
panel/src/components/DataTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
panel/src/components/Layout.tsx
Normal file
92
panel/src/components/Layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
panel/src/components/Modal.tsx
Normal file
32
panel/src/components/Modal.tsx
Normal 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
2
panel/src/index.css
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "daisyui";
|
||||||
47
panel/src/lib/api.ts
Normal file
47
panel/src/lib/api.ts
Normal 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
35
panel/src/lib/useAuth.ts
Normal 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
13
panel/src/main.tsx
Normal 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
133
panel/src/pages/Classes.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
panel/src/pages/Dashboard.tsx
Normal file
84
panel/src/pages/Dashboard.tsx
Normal 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
265
panel/src/pages/Items.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
panel/src/pages/Lootdrops.tsx
Normal file
144
panel/src/pages/Lootdrops.tsx
Normal 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
170
panel/src/pages/Quests.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
panel/src/pages/Settings.tsx
Normal file
94
panel/src/pages/Settings.tsx
Normal 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
265
panel/src/pages/Users.tsx
Normal 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
19
panel/tsconfig.json
Normal 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
21
panel/vite.config.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
13
shared/db/migrations/0005_wealthy_golden_guardian.sql
Normal file
13
shared/db/migrations/0005_wealthy_golden_guardian.sql
Normal 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
|
||||||
|
);
|
||||||
1397
shared/db/migrations/meta/0005_snapshot.json
Normal file
1397
shared/db/migrations/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
233
web/src/routes/auth.routes.ts
Normal file
233
web/src/routes/auth.routes.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user