29 Commits

Author SHA1 Message Date
syntaxbullet
10c84a8478 fix: fix Y - UV coordinate issue flipping the preview in chroma key.
All checks were successful
Deploy to Production / test (push) Successful in 38s
2026-02-21 13:14:58 +01:00
syntaxbullet
9eba64621a feat: add ability to edit items.
All checks were successful
Deploy to Production / test (push) Successful in 37s
2026-02-19 15:53:13 +01:00
syntaxbullet
7cc2f61db6 feat: add item creation tools
All checks were successful
Deploy to Production / test (push) Successful in 37s
2026-02-19 14:40:22 +01:00
syntaxbullet
f5fecb59cb Merge branch 'main' of https://git.ayau.me/syntaxbullet/discord-rpg-concept
All checks were successful
Deploy to Production / test (push) Successful in 38s
2026-02-16 17:22:22 +01:00
syntaxbullet
65f5663c97 feat: implement basic items page, with a placeholder for item creation tool. 2026-02-16 17:22:18 +01:00
de83307adc chore: add newline to readme.md
All checks were successful
Deploy to Production / test (push) Successful in 35s
2026-02-15 14:28:46 +00:00
syntaxbullet
15e01906a3 fix: additional mocks of authentication logic, fix: made path traversal test work with fetch().
All checks were successful
Deploy to Production / test (push) Successful in 34s
2026-02-15 15:26:46 +01:00
syntaxbullet
fed27c0227 fix: mock authentication logic in server test to ensure tests for protected routes pass.
Some checks failed
Deploy to Production / test (push) Failing after 29s
2026-02-15 15:20:50 +01:00
syntaxbullet
9751e62e30 chore: add citrine task file
Some checks failed
Deploy to Production / test (push) Failing after 31s
2026-02-15 15:18:00 +01:00
syntaxbullet
87d5aa259c feat: add users management page with search, editing, and inventory control
Some checks failed
Deploy to Production / test (push) Failing after 30s
Implements comprehensive user management interface for admin panel:
- Search and filter users by username, class, and active status
- Sort by username, level, balance, or XP with pagination
- View and edit user details (balance, XP, level, class, daily streak, active status)
- Manage user inventories (add/remove items with quantities)
- Debounced search input (300ms delay)
- Responsive design (mobile full-screen, desktop slide-in panel)
- Draft state management with unsaved changes tracking
- Keyboard shortcuts (Escape to close detail panel)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 13:15:37 +01:00
syntaxbullet
f0bfaecb0b feat: add settings page with guild config, game settings, and command toggles
Some checks failed
Deploy to Production / test (push) Failing after 31s
Implements the full admin settings page covering all game settings
(leveling, economy, inventory, lootdrops, trivia, moderation, commands)
and guild settings (roles, channels, welcome message, moderation,
feature overrides). Includes role/channel pickers, trivia category
multi-select, and a feature override flag editor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 12:45:23 +01:00
syntaxbullet
9471b6fdab feat: add admin dashboard with sidebar navigation and stats overview
Some checks failed
Deploy to Production / test (push) Failing after 30s
Replace placeholder panel with a full dashboard landing page showing
bot stats, leaderboards, and recent events from /api/stats. Add
sidebar navigation with placeholder pages for Users, Items, Classes,
Quests, Lootdrops, Moderation, Transactions, and Settings. Update
theme to match Aurora design guidelines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 12:23:13 +01:00
syntaxbullet
04e5851387 refactor: rename web/ to api/ to better reflect its purpose
Some checks failed
Deploy to Production / test (push) Failing after 30s
The web/ folder contains the REST API, WebSocket server, and OAuth
routes — not a web frontend. Renaming to api/ clarifies this distinction
since the actual web frontend lives in panel/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 11:37:40 +01:00
1a59c9e796 chore: update prod docker-compose with volume mount for item graphics
Some checks failed
Deploy to Production / test (push) Failing after 37s
2026-02-13 20:16:26 +00:00
syntaxbullet
251616fe15 fix: rename panel asset dir to avoid conflict with bot /assets route
Some checks failed
Deploy to Production / test (push) Failing after 32s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:05:06 +01:00
syntaxbullet
fbb2e0f010 fix: install panel deps in Docker builder stage before build
Some checks failed
Deploy to Production / test (push) Failing after 31s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:53:59 +01:00
syntaxbullet
dc10ad5c37 fix: resolve vite path in Docker build and add OAuth env to prod compose
Some checks failed
Deploy to Production / test (push) Failing after 35s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:53:01 +01:00
syntaxbullet
2381f073ba feat: add admin panel with Discord OAuth and dashboard
Some checks failed
Deploy to Production / test (push) Failing after 37s
Adds a React admin panel (panel/) with Discord OAuth2 login,
live dashboard via WebSocket, and settings/management pages.
Includes Docker build support, Vite proxy config for dev,
game_settings migration, and open-redirect protection on auth callback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:27:14 +01:00
syntaxbullet
121c242168 fix: handle permission denied on backup directory
All checks were successful
Deploy to Production / test (push) Successful in 35s
The backups directory may have been created by Docker/root, making it
unwritable by the deploy user. The script now detects this and attempts
to fix permissions automatically (chmod, then sudo chown as fallback).

Also added shared/db/backups to .gitignore.
2026-02-13 14:48:06 +01:00
syntaxbullet
942875e8d0 fix: replace 'source .env' with safe env loader in all scripts
All checks were successful
Deploy to Production / test (push) Successful in 34s
The raw 'source .env' pattern breaks when values contain special bash
characters like ) in passwords or database URLs. This caused deploy:remote
to fail with 'syntax error near unexpected token )'.

Changes:
- Created shared/scripts/lib/load-env.sh: reads .env line-by-line with
  export instead of source, safely handling special characters
- Updated db-backup.sh, db-restore.sh, deploy-remote.sh, remote.sh to
  use the shared loader
- Reordered deploy-remote.sh: git pull now runs first (step 1) so the
  remote always has the latest scripts before running backup (step 2)
2026-02-13 14:46:30 +01:00
syntaxbullet
878e3306eb chore: add missing script aliases and reorganize package.json scripts
All checks were successful
Deploy to Production / test (push) Successful in 33s
Added missing aliases:
- deploy: production deployment script
- deploy:remote: remote VPS deployment
- setup-server: server hardening/provisioning
- test:simulate-ci: local CI simulation with ephemeral Postgres

Reorganized scripts into logical groups:
- Dev (dev, logs, remote)
- Database (db:generate, db:migrate, db:push, db:studio, db:backup, db:restore, migrations)
- Testing (test, test:ci, test:simulate-ci)
- Deployment (deploy, deploy:remote, setup-server)
- Docker (docker:cleanup)

Renamed generate → db:generate, migrate → db:migrate for consistency.
Kept old names as backward-compatible aliases (referenced in AGENTS.md, README.md, docs).
2026-02-13 14:41:32 +01:00
syntaxbullet
aca5538d57 chore: improve DX scripts, fix test suite, and harden tooling
All checks were successful
Deploy to Production / test (push) Successful in 32s
Scripts:
- remote.sh: remove unused open_browser() function
- deploy-remote.sh: add DB backup before deploy, --skip-backup flag, step numbering
- db-backup.sh: fix macOS compat (xargs -r is GNU-only), use portable approach
- db-restore.sh: add safety backup before restore, SQL file validation, file size display
- logs.sh: default to no-follow with --tail=100, order-independent arg parsing
- docker-cleanup.sh: add Docker health check, colored output
- test-sequential.sh: exclude *.integration.test.ts by default, add --integration flag
- simulate-ci.sh: pass --integration flag (has real DB)

Tests:
- db.test.ts: fix mock path from ./DrizzleClient to @shared/db/DrizzleClient
- server.settings.test.ts: rewrite mocks for gameSettingsService (old config/saveConfig removed)
- server.test.ts: add missing config.lootdrop and BotClient mocks, complete DrizzleClient chain
- indexes.test.ts: rename to indexes.integration.test.ts (requires live DB)

Config:
- package.json: test script uses sequential runner, add test:ci and db:restore aliases
- deploy.yml: use --integration flag in CI (has Postgres service)
2026-02-13 14:39:02 +01:00
syntaxbullet
f822d90dd3 refactor: merge dockerfiles
Some checks failed
Deploy to Production / test (push) Failing after 33s
2026-02-13 14:28:43 +01:00
syntaxbullet
141c3098f8 feat: standardize command error handling (Sprint 4)
- Create withCommandErrorHandling utility in bot/lib/commandUtils.ts
- Migrate economy commands: daily, exam, pay, trivia
- Migrate inventory command: use
- Migrate admin/moderation commands: warn, case, cases, clearwarning,
  warnings, note, notes, create_color, listing, webhook, refresh,
  terminal, featureflags, settings, prune
- Add 9 unit tests for the utility
- Update AGENTS.md with new recommended error handling pattern
2026-02-13 14:23:37 +01:00
syntaxbullet
0c67a8754f refactor: Implement Zod schema validation for inventory effect payloads and enhance item route DTO type safety.
Some checks failed
Deploy to Production / test (push) Failing after 38s
2026-02-13 14:12:46 +01:00
syntaxbullet
bf20c61190 chore: exclude tickets from being commited.
Some checks failed
Deploy to Production / test (push) Failing after 34s
2026-02-13 13:53:45 +01:00
syntaxbullet
099601ce6d refactor: convert ModerationService and PruneService from classes to singleton objects
- Convert ModerationService class to moderationService singleton
- Convert PruneService class to pruneService singleton
- Update all command files to use new singleton imports
- Update web routes to use new singleton imports
- Update tests for singleton pattern
- Remove getNextCaseId from tests (now private module function)
2026-02-13 13:33:58 +01:00
syntaxbullet
55d2376ca1 refactor: convert LootdropService from class to singleton object pattern
- Move instance properties to module-level state (channelActivity, channelCooldowns)
- Convert constructor cleanup interval to module-level initialization
- Export state variables for testing
- Update tests to use direct state access instead of (service as any)
- Maintains same behavior while following project service pattern

Closes #4
2026-02-13 13:28:46 +01:00
syntaxbullet
6eb4a32a12 refactor: consolidate config types and remove file-based config
Tickets: #2, #3

- Remove duplicate type definitions from shared/lib/config.ts
- Import types from schema files (game-settings.ts, guild-settings.ts)
- Add GuildConfig interface to guild-settings.ts schema
- Rename ModerationConfig to ModerationCaseConfig in moderation.service.ts
- Delete shared/config/config.json and shared/scripts/migrate-config-to-db.ts
- Update settings API to use gameSettingsService exclusively
- Return DB format (strings) from API instead of runtime BigInts
- Fix moderation service tests to pass config as parameter

Breaking Changes:
- Removes legacy file-based configuration system
- API now returns database format with string values for BigInt fields
2026-02-13 13:24:02 +01:00
119 changed files with 13904 additions and 1838 deletions

7
.citrine Normal file
View File

@@ -0,0 +1,7 @@
### Frontend
[8bb0] [>] implement items page
[de51] [ ] implement classes page
[d108] [ ] implement quests page
[8bbe] [ ] implement lootdrops page
[094e] [ ] implement moderation page
[220d] [ ] implement transactions page

View File

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

View File

@@ -95,6 +95,6 @@ jobs:
ADMIN_TOKEN="admin_token_123" ADMIN_TOKEN="admin_token_123"
LOG_LEVEL="error" LOG_LEVEL="error"
EOF EOF
bash shared/scripts/test-sequential.sh bash shared/scripts/test-sequential.sh --integration
env: env:
NODE_ENV: test NODE_ENV: test

4
.gitignore vendored
View File

@@ -3,6 +3,7 @@ node_modules
docker-compose.override.yml docker-compose.override.yml
shared/db-logs shared/db-logs
shared/db/data shared/db/data
shared/db/backups
shared/db/loga shared/db/loga
.cursor .cursor
# dependencies (bun install) # dependencies (bun install)
@@ -46,5 +47,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
src/db/data src/db/data
src/db/log src/db/log
scratchpad/ scratchpad/
tickets/
bot/assets/graphics/items bot/assets/graphics/items
tickets/
.citrine.local

View File

@@ -141,22 +141,36 @@ throw new UserError("You don't have enough coins!");
throw new SystemError("Database connection failed"); throw new SystemError("Database connection failed");
``` ```
### Standard Error Pattern ### Recommended: `withCommandErrorHandling`
Use the `withCommandErrorHandling` utility from `@lib/commandUtils` to standardize
error handling across all commands. It handles `deferReply`, `UserError` display,
and unexpected error logging automatically.
```typescript ```typescript
try { import { withCommandErrorHandling } from "@lib/commandUtils";
const result = await service.method();
await interaction.editReply({ embeds: [createSuccessEmbed(result)] }); export const myCommand = createCommand({
} catch (error) { data: new SlashCommandBuilder()
if (error instanceof UserError) { .setName("mycommand")
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); .setDescription("Does something"),
} else { execute: async (interaction) => {
console.error("Unexpected error:", error); await withCommandErrorHandling(
await interaction.editReply({ interaction,
embeds: [createErrorEmbed("An unexpected error occurred.")], async () => {
}); const result = await service.method();
} await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
} },
{ ephemeral: true } // optional: makes the deferred reply ephemeral
);
},
});
```
Options:
- `ephemeral` — whether `deferReply` should be ephemeral
- `successMessage` — a simple string to send on success
- `onSuccess` — a callback invoked with the operation result
``` ```
## Database Patterns ## Database Patterns
@@ -240,3 +254,4 @@ describe("serviceName", () => {
| Environment | `shared/lib/env.ts` | | Environment | `shared/lib/env.ts` |
| Embed helpers | `bot/lib/embeds.ts` | | Embed helpers | `bot/lib/embeds.ts` |
| Command utils | `shared/lib/utils.ts` | | Command utils | `shared/lib/utils.ts` |
| Error handler | `bot/lib/commandUtils.ts` |

187
CLAUDE.md Normal file
View File

@@ -0,0 +1,187 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Development
bun --watch bot/index.ts # Run bot + API with hot reload
docker compose up # Start all services (bot, API, database)
docker compose up app # Start just the app (bot + API)
docker compose up db # Start just the database
# Testing
bun test # Run all tests
bun test path/to/file.test.ts # Run a single test file
bun test shared/modules/economy # Run tests in a directory
bun test --watch # Watch mode
# Database
bun run db:push:local # Push schema changes (local)
bun run db:studio # Open Drizzle Studio (localhost:4983)
bun run generate # Generate Drizzle migrations (Docker)
bun run migrate # Apply migrations (Docker)
# Admin Panel
bun run panel:dev # Start Vite dev server for dashboard
bun run panel:build # Build React dashboard for production
```
## Architecture
Aurora is a Discord RPG bot + REST API running as a **single Bun process**. The bot and API share the same database client and services.
```
bot/ # Discord bot
├── commands/ # Slash commands by category (admin, economy, inventory, etc.)
├── events/ # Discord event handlers
├── lib/ # BotClient, handlers, loaders, embed helpers, commandUtils
├── modules/ # Feature modules (views, interactions per domain)
└── graphics/ # Canvas-based image generation (@napi-rs/canvas)
shared/ # Shared between bot and API
├── db/ # Drizzle ORM client + schema (users, economy, inventory, quests, etc.)
├── lib/ # env, config, errors, logger, types, utils
└── modules/ # Domain services (economy, user, inventory, quest, moderation, etc.)
api/ # REST API (Bun HTTP server)
└── src/routes/ # Route handlers for each domain
panel/ # React admin dashboard (Vite + Tailwind + Radix UI)
```
**Key architectural details:**
- Bot and API both import from `shared/` — do not duplicate logic.
- Services in `shared/modules/` are singleton objects, not classes.
- The database uses PostgreSQL 16+ via Drizzle ORM with `bigint` mode for Discord IDs and currency.
- Feature modules follow a strict file suffix convention (see below).
## Import Conventions
Use path aliases (defined in `tsconfig.json`). Order: external packages → aliases → relative.
```typescript
import { SlashCommandBuilder } from "discord.js"; // external
import { economyService } from "@shared/modules/economy/economy.service"; // alias
import { users } from "@db/schema"; // alias
import { createErrorEmbed } from "@lib/embeds"; // alias
import { localHelper } from "./helper"; // relative
```
**Aliases:**
- `@/*``bot/`
- `@shared/*``shared/`
- `@db/*``shared/db/`
- `@lib/*``bot/lib/`
- `@modules/*``bot/modules/`
- `@commands/*``bot/commands/`
## Code Patterns
### Module File Suffixes
- `*.view.ts` — Creates Discord embeds/components
- `*.interaction.ts` — Handles button/select/modal interactions
- `*.service.ts` — Business logic (lives in `shared/modules/`)
- `*.types.ts` — Module-specific TypeScript types
- `*.test.ts` — Tests (co-located with source)
### Command Definition
```typescript
export const commandName = createCommand({
data: new SlashCommandBuilder().setName("name").setDescription("desc"),
execute: async (interaction) => {
await withCommandErrorHandling(interaction, async () => {
const result = await service.method();
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
}, { ephemeral: true });
},
});
```
`withCommandErrorHandling` (from `@lib/commandUtils`) handles `deferReply`, `UserError` display, and unexpected error logging automatically.
### Service Pattern
```typescript
export const serviceName = {
methodName: async (params: ParamType): Promise<ReturnType> => {
return await withTransaction(async (tx) => {
// database operations
});
},
};
```
### Error Handling
```typescript
import { UserError, SystemError } from "@shared/lib/errors";
throw new UserError("You don't have enough coins!"); // shown to user
throw new SystemError("DB connection failed"); // logged, generic message shown
```
### Database Transactions
```typescript
import { withTransaction } from "@/lib/db";
return await withTransaction(async (tx) => {
const user = await tx.query.users.findFirst({ where: eq(users.id, id) });
await tx.update(users).set({ coins: newBalance }).where(eq(users.id, id));
return user;
}, existingTx); // pass existing tx for nested transactions
```
### Testing
Mock modules **before** imports. Use `bun:test`.
```typescript
import { describe, it, expect, mock, beforeEach } from "bun:test";
mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: { query: mockQuery },
}));
describe("serviceName", () => {
beforeEach(() => mockFn.mockClear());
it("should handle expected case", async () => {
mockFn.mockResolvedValue(testData);
const result = await service.method(input);
expect(result).toEqual(expected);
});
});
```
## Naming Conventions
| Element | Convention | Example |
| ---------------- | ---------------------- | -------------------------------- |
| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` |
| Classes | PascalCase | `CommandHandler`, `UserError` |
| Functions | camelCase | `createCommand`, `handleShopInteraction` |
| Constants | UPPER_SNAKE_CASE | `EVENTS`, `BRANDING` |
| Enums | PascalCase | `TimerType`, `TransactionType` |
| Services | camelCase singleton | `economyService`, `userService` |
| Types/Interfaces | PascalCase | `Command`, `Event`, `GameConfigType` |
| DB tables | snake_case | `users`, `moderation_cases` |
| Custom IDs | snake_case with prefix | `shop_buy_`, `trade_accept_` |
| API routes | kebab-case | `/api/guild-settings` |
## Key Files
| Purpose | File |
| ----------------- | -------------------------- |
| Bot entry point | `bot/index.ts` |
| Discord client | `bot/lib/BotClient.ts` |
| DB schema index | `shared/db/schema.ts` |
| Error classes | `shared/lib/errors.ts` |
| Environment vars | `shared/lib/env.ts` |
| Config loader | `shared/lib/config.ts` |
| Embed helpers | `bot/lib/embeds.ts` |
| Command utils | `bot/lib/commandUtils.ts` |
| API server | `api/src/server.ts` |

View File

@@ -16,6 +16,7 @@ FROM base AS deps
# Copy only package files first (better layer caching) # Copy only package files first (better layer caching)
COPY package.json bun.lock ./ COPY package.json bun.lock ./
COPY panel/package.json panel/
# Install dependencies # Install dependencies
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
@@ -33,3 +34,44 @@ EXPOSE 3000
# Default command # Default command
CMD ["bun", "run", "dev"] CMD ["bun", "run", "dev"]
# ============================================
# Builder stage - copies source for production
# ============================================
FROM base AS builder
# Copy source code first, then deps on top (so node_modules aren't overwritten)
COPY . .
COPY --from=deps /app/node_modules ./node_modules
# Install panel deps and build
RUN cd panel && bun install --frozen-lockfile && bun run build
# ============================================
# Production stage - minimal runtime image
# ============================================
FROM oven/bun:latest AS production
WORKDIR /app
# Copy only what's needed for production
COPY --from=builder --chown=bun:bun /app/node_modules ./node_modules
COPY --from=builder --chown=bun:bun /app/api/src ./api/src
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/panel/dist ./panel/dist
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/tsconfig.json .
# Switch to non-root user
USER bun
# Expose web dashboard port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
# Run in production mode
CMD ["bun", "run", "bot/index.ts"]

View File

@@ -1,48 +0,0 @@
# =============================================================================
# Stage 1: Dependencies & Build
# =============================================================================
FROM oven/bun:latest AS builder
WORKDIR /app
# Install system dependencies needed for build
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
# Install root project dependencies
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
# Copy source code
COPY . .
# =============================================================================
# Stage 2: Production Runtime
# =============================================================================
FROM oven/bun:latest AS production
WORKDIR /app
# Create non-root user for security (bun user already exists with 1000:1000)
# No need to create user/group
# Copy only what's needed for production
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/bot ./bot
COPY --from=builder --chown=bun:bun /app/shared ./shared
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/tsconfig.json .
# Switch to non-root user
USER bun
# Expose web dashboard port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
# Run in production mode
CMD ["bun", "run", "bot/index.ts"]

View File

@@ -7,6 +7,7 @@
![Discord.js](https://img.shields.io/badge/Discord.js-14.x-5865F2) ![Discord.js](https://img.shields.io/badge/Discord.js-14.x-5865F2)
![Drizzle ORM](https://img.shields.io/badge/Drizzle_ORM-0.30+-C5F74F) ![Drizzle ORM](https://img.shields.io/badge/Drizzle_ORM-0.30+-C5F74F)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-336791) ![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-336791)
Aurora is a powerful Discord bot designed to facilitate RPG-like elements within a Discord server. It features a robust economy, class system, inventory management, quests, and more, all built on top of a high-performance stack using Bun and Drizzle ORM. Aurora is a powerful Discord bot designed to facilitate RPG-like elements within a Discord server. It features a robust economy, class system, inventory management, quests, and more, all built on top of a high-performance stack using Bun and Drizzle ORM.
**New in v1.0:** Aurora now includes a fully integrated **REST API** for accessing bot data, statistics, and configuration, running alongside the bot in a single process. **New in v1.0:** Aurora now includes a fully integrated **REST API** for accessing bot data, statistics, and configuration, running alongside the bot in a single process.

View File

View File

@@ -66,7 +66,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
return new Response(file, { return new Response(file, {
headers: { headers: {
"Content-Type": contentType, "Content-Type": contentType,
"Cache-Control": "public, max-age=86400", // Cache for 24 hours "Cache-Control": "no-cache",
} }
}); });
} }

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
import { join, resolve, dirname } from "path"; import { join, resolve, dirname } from "path";
import type { RouteContext, RouteModule } from "./types"; import type { RouteContext, RouteModule } from "./types";
import type { CreateItemDTO, UpdateItemDTO } from "@shared/modules/items/items.service";
import { import {
jsonResponse, jsonResponse,
errorResponse, errorResponse,
@@ -121,7 +122,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
return withErrorHandling(async () => { return withErrorHandling(async () => {
const contentType = req.headers.get("content-type") || ""; const contentType = req.headers.get("content-type") || "";
let itemData: any; let itemData: CreateItemDTO | null = null;
let imageFile: File | null = null; let imageFile: File | null = null;
if (contentType.includes("multipart/form-data")) { if (contentType.includes("multipart/form-data")) {
@@ -130,12 +131,16 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
imageFile = formData.get("image") as File | null; imageFile = formData.get("image") as File | null;
if (typeof jsonData === "string") { if (typeof jsonData === "string") {
itemData = JSON.parse(jsonData); itemData = JSON.parse(jsonData) as CreateItemDTO;
} else { } else {
return errorResponse("Missing item data", 400); return errorResponse("Missing item data", 400);
} }
} else { } else {
itemData = await req.json(); itemData = await req.json() as CreateItemDTO;
}
if (!itemData) {
return errorResponse("Missing item data", 400);
} }
// Validate required fields // Validate required fields
@@ -183,7 +188,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
const filePath = join(assetsDir, fileName); const filePath = join(assetsDir, fileName);
await Bun.write(filePath, buffer); await Bun.write(filePath, buffer);
const assetUrl = `/assets/items/${fileName}`; const assetUrl = `/assets/items/${fileName}?v=${Date.now()}`;
await itemsService.updateItem(item.id, { await itemsService.updateItem(item.id, {
iconUrl: assetUrl, iconUrl: assetUrl,
imageUrl: assetUrl, imageUrl: assetUrl,
@@ -235,7 +240,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
if (!id) return null; if (!id) return null;
return withErrorHandling(async () => { return withErrorHandling(async () => {
const data = await req.json() as Record<string, any>; const data = await req.json() as Partial<UpdateItemDTO>;
const existing = await itemsService.getItemById(id); const existing = await itemsService.getItemById(id);
if (!existing) { if (!existing) {
@@ -250,7 +255,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
} }
// Build update data // Build update data
const updateData: any = {}; const updateData: Partial<UpdateItemDTO> = {};
if (data.name !== undefined) updateData.name = data.name; if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description; if (data.description !== undefined) updateData.description = data.description;
if (data.rarity !== undefined) updateData.rarity = data.rarity; if (data.rarity !== undefined) updateData.rarity = data.rarity;
@@ -347,7 +352,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
const filePath = join(assetsDir, fileName); const filePath = join(assetsDir, fileName);
await Bun.write(filePath, buffer); await Bun.write(filePath, buffer);
const assetUrl = `/assets/items/${fileName}`; const assetUrl = `/assets/items/${fileName}?v=${Date.now()}`;
const updatedItem = await itemsService.updateItem(id, { const updatedItem = await itemsService.updateItem(id, {
iconUrl: assetUrl, iconUrl: assetUrl,
imageUrl: assetUrl, imageUrl: assetUrl,

View File

@@ -29,7 +29,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
return null; return null;
} }
const { ModerationService } = await import("@shared/modules/moderation/moderation.service"); const { moderationService } = await import("@shared/modules/moderation/moderation.service");
/** /**
* @route GET /api/moderation * @route GET /api/moderation
@@ -78,7 +78,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
filter.limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50; filter.limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
filter.offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0; filter.offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
const cases = await ModerationService.searchCases(filter); const cases = await moderationService.searchCases(filter);
return jsonResponse({ cases }); return jsonResponse({ cases });
}, "fetch moderation cases"); }, "fetch moderation cases");
} }
@@ -97,7 +97,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
const caseId = pathname.split("/").pop()!.toUpperCase(); const caseId = pathname.split("/").pop()!.toUpperCase();
return withErrorHandling(async () => { return withErrorHandling(async () => {
const moderationCase = await ModerationService.getCaseById(caseId); const moderationCase = await moderationService.getCaseById(caseId);
if (!moderationCase) { if (!moderationCase) {
return errorResponse("Case not found", 404); return errorResponse("Case not found", 404);
@@ -148,7 +148,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
); );
} }
const newCase = await ModerationService.createCase({ const newCase = await moderationService.createCase({
type: data.type, type: data.type,
userId: data.userId, userId: data.userId,
username: data.username, username: data.username,
@@ -193,7 +193,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
return errorResponse("Missing required fields: clearedBy, clearedByName", 400); return errorResponse("Missing required fields: clearedBy, clearedByName", 400);
} }
const updatedCase = await ModerationService.clearCase({ const updatedCase = await moderationService.clearCase({
caseId, caseId,
clearedBy: data.clearedBy, clearedBy: data.clearedBy,
clearedByName: data.clearedByName, clearedByName: data.clearedByName,

View File

@@ -7,13 +7,6 @@
import type { RouteContext, RouteModule } from "./types"; import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse, withErrorHandling } from "./utils"; import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
/**
* JSON replacer for BigInt serialization.
*/
function jsonReplacer(_key: string, value: unknown): unknown {
return typeof value === "bigint" ? value.toString() : value;
}
/** /**
* Settings routes handler. * Settings routes handler.
* *
@@ -32,26 +25,31 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
/** /**
* @route GET /api/settings * @route GET /api/settings
* @description Returns the current bot configuration. * @description Returns the current bot configuration from database.
* Configuration includes economy settings, leveling settings, * Configuration includes economy settings, leveling settings,
* command toggles, and other system settings. * command toggles, and other system settings.
* @response 200 - Full configuration object * @response 200 - Full configuration object (DB format with strings for BigInts)
* @response 500 - Error fetching settings * @response 500 - Error fetching settings
* *
* @example * @example
* // Response * // Response
* { * {
* "economy": { "dailyReward": 100, "streakBonus": 10 }, * "economy": { "daily": { "amount": "100", "streakBonus": "10" } },
* "leveling": { "xpPerMessage": 15, "levelUpChannel": "123456789" }, * "leveling": { "base": 100, "exponent": 1.5 },
* "commands": { "disabled": [], "channelLocks": {} } * "commands": { "disabled": [], "channelLocks": {} }
* } * }
*/ */
if (pathname === "/api/settings" && method === "GET") { if (pathname === "/api/settings" && method === "GET") {
return withErrorHandling(async () => { return withErrorHandling(async () => {
const { config } = await import("@shared/lib/config"); const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
return new Response(JSON.stringify(config, jsonReplacer), { const settings = await gameSettingsService.getSettings();
headers: { "Content-Type": "application/json" }
}); if (!settings) {
// Return defaults if no settings in DB yet
return jsonResponse(gameSettingsService.getDefaults());
}
return jsonResponse(settings);
}, "fetch settings"); }, "fetch settings");
} }
@@ -61,7 +59,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
* Only the provided fields will be updated; other settings remain unchanged. * Only the provided fields will be updated; other settings remain unchanged.
* After updating, commands are automatically reloaded. * After updating, commands are automatically reloaded.
* *
* @body Partial configuration object * @body Partial configuration object (DB format with strings for BigInts)
* @response 200 - `{ success: true }` * @response 200 - `{ success: true }`
* @response 400 - Validation error * @response 400 - Validation error
* @response 500 - Error saving settings * @response 500 - Error saving settings
@@ -69,19 +67,15 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
* @example * @example
* // Request - Only update economy daily reward * // Request - Only update economy daily reward
* POST /api/settings * POST /api/settings
* { "economy": { "dailyReward": 150 } } * { "economy": { "daily": { "amount": "150" } } }
*/ */
if (pathname === "/api/settings" && method === "POST") { if (pathname === "/api/settings" && method === "POST") {
try { try {
const partialConfig = await req.json(); const partialConfig = await req.json() as Record<string, unknown>;
const { saveConfig, config: currentConfig } = await import("@shared/lib/config"); const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
const { deepMerge } = await import("@shared/lib/utils");
// Use upsertSettings to merge partial update
// Merge partial update into current config await gameSettingsService.upsertSettings(partialConfig as Record<string, unknown>);
const mergedConfig = deepMerge(currentConfig, partialConfig);
// saveConfig throws if validation fails
saveConfig(mergedConfig);
const { systemEvents, EVENTS } = await import("@shared/lib/events"); const { systemEvents, EVENTS } = await import("@shared/lib/events");
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS); systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
@@ -145,7 +139,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}); });
return jsonResponse({ roles, channels, commands }); return jsonResponse({ guildId: env.DISCORD_GUILD_ID, roles, channels, commands });
}, "fetch settings meta"); }, "fetch settings meta");
} }

View File

@@ -132,6 +132,13 @@ mock.module("@shared/lib/utils", () => ({
typeof value === "bigint" ? value.toString() : value, typeof value === "bigint" ? value.toString() : value,
})); }));
// --- Mock Auth (bypass authentication) ---
mock.module("./routes/auth.routes", () => ({
authRoutes: { name: "auth", handler: () => null },
isAuthenticated: () => true,
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
}));
// --- Mock Logger --- // --- Mock Logger ---
mock.module("@shared/lib/logger", () => ({ mock.module("@shared/lib/logger", () => ({
logger: { logger: {
@@ -403,8 +410,11 @@ describe("Items API", () => {
}); });
test("should prevent path traversal attacks", async () => { test("should prevent path traversal attacks", async () => {
const response = await fetch(`${baseUrl}/assets/../../../etc/passwd`); // Note: fetch() and HTTP servers normalize ".." segments before the handler sees them,
// Should either return 403 (Forbidden) or 404 (Not found after sanitization) // so we can't send raw traversal paths over HTTP. Instead, test that a suspicious
// asset path (with encoded sequences) doesn't serve sensitive file content.
const response = await fetch(`${baseUrl}/assets/..%2f..%2f..%2fetc%2fpasswd`);
// Should not serve actual file content — expect 403 or 404
expect([403, 404]).toContain(response.status); expect([403, 404]).toContain(response.status);
}); });
}); });

View File

@@ -1,40 +1,57 @@
import { describe, expect, it, mock, beforeEach, afterEach, jest } from "bun:test"; import { describe, expect, it, mock, beforeEach, afterEach, jest } from "bun:test";
import { type WebServerInstance } from "./server"; import { type WebServerInstance } from "./server";
// Mock the dependencies // Mock gameSettingsService — the route now uses this instead of config/saveConfig
const mockConfig = { const mockSettings = {
leveling: { leveling: {
base: 100, base: 100,
exponent: 1.5, exponent: 1.5,
chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 } chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 }
}, },
economy: { economy: {
daily: { amount: 100n, streakBonus: 10n, weeklyBonus: 50n, cooldownMs: 86400000 }, daily: { amount: "100", streakBonus: "10", weeklyBonus: "50", cooldownMs: 86400000 },
transfers: { allowSelfTransfer: false, minAmount: 50n }, transfers: { allowSelfTransfer: false, minAmount: "1" },
exam: { multMin: 1.5, multMax: 2.5 } exam: { multMin: 1.5, multMax: 2.5 }
}, },
inventory: { maxStackSize: 99n, maxSlots: 20 }, inventory: { maxStackSize: "99", maxSlots: 20 },
lootdrop: { lootdrop: {
spawnChance: 0.1, spawnChance: 0.1,
cooldownMs: 3600000, cooldownMs: 3600000,
minMessages: 10, minMessages: 10,
activityWindowMs: 300000,
reward: { min: 100, max: 500, currency: "gold" } reward: { min: 100, max: 500, currency: "gold" }
}, },
commands: { "help": true }, commands: { "help": true },
system: {}, system: {},
moderation: { moderation: {
prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 }, prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 },
cases: { dmOnWarn: true } },
trivia: {
entryFee: "50",
rewardMultiplier: 1.5,
timeoutSeconds: 30,
cooldownMs: 60000,
categories: [],
difficulty: "random"
} }
}; };
const mockSaveConfig = jest.fn(); const mockGetSettings = jest.fn(() => Promise.resolve(mockSettings));
const mockUpsertSettings = jest.fn(() => Promise.resolve(mockSettings));
const mockGetDefaults = jest.fn(() => mockSettings);
// Mock @shared/lib/config using mock.module mock.module("@shared/modules/game-settings/game-settings.service", () => ({
mock.module("@shared/lib/config", () => ({ gameSettingsService: {
config: mockConfig, getSettings: mockGetSettings,
saveConfig: mockSaveConfig, upsertSettings: mockUpsertSettings,
GameConfigType: {} getDefaults: mockGetDefaults,
invalidateCache: jest.fn(),
}
}));
// Mock DrizzleClient (dependency potentially imported transitively)
mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: {}
})); }));
// Mock @shared/lib/utils (deepMerge is used by settings API) // Mock @shared/lib/utils (deepMerge is used by settings API)
@@ -93,6 +110,13 @@ mock.module("bun", () => {
}; };
}); });
// Mock auth (bypass authentication)
mock.module("./routes/auth.routes", () => ({
authRoutes: { name: "auth", handler: () => null },
isAuthenticated: () => true,
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
}));
// Import createWebServer after mocks // Import createWebServer after mocks
import { createWebServer } from "./server"; import { createWebServer } from "./server";
@@ -104,6 +128,8 @@ describe("Settings API", () => {
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks(); jest.clearAllMocks();
mockGetSettings.mockImplementation(() => Promise.resolve(mockSettings));
mockUpsertSettings.mockImplementation(() => Promise.resolve(mockSettings));
serverInstance = await createWebServer({ port: PORT, hostname: HOSTNAME }); serverInstance = await createWebServer({ port: PORT, hostname: HOSTNAME });
}); });
@@ -117,18 +143,14 @@ describe("Settings API", () => {
const res = await fetch(`${BASE_URL}/api/settings`); const res = await fetch(`${BASE_URL}/api/settings`);
expect(res.status).toBe(200); expect(res.status).toBe(200);
const data = await res.json(); const data = await res.json() as any;
// Check if BigInts are converted to strings // Check values come through correctly
expect(data.economy.daily.amount).toBe("100"); expect(data.economy.daily.amount).toBe("100");
expect(data.leveling.base).toBe(100); expect(data.leveling.base).toBe(100);
}); });
it("POST /api/settings should save valid configuration via merge", async () => { it("POST /api/settings should save valid configuration via merge", async () => {
// We only send a partial update, expecting the server to merge it const partialConfig = { economy: { daily: { amount: "200" } } };
// Note: For now the server implementation might still default to overwrite if we haven't updated it yet.
// But the user requested "partial vs full" fix.
// Let's assume we implement the merge logic.
const partialConfig = { studentRole: "new-role-partial" };
const res = await fetch(`${BASE_URL}/api/settings`, { const res = await fetch(`${BASE_URL}/api/settings`, {
method: "POST", method: "POST",
@@ -137,26 +159,27 @@ describe("Settings API", () => {
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
// Expect saveConfig to be called with the MERGED result // upsertSettings should be called with the partial config
expect(mockSaveConfig).toHaveBeenCalledWith(expect.objectContaining({ expect(mockUpsertSettings).toHaveBeenCalledWith(
studentRole: "new-role-partial", expect.objectContaining({
leveling: mockConfig.leveling // Should keep existing values economy: { daily: { amount: "200" } }
})); })
);
}); });
it("POST /api/settings should return 400 when save fails", async () => { it("POST /api/settings should return 400 when save fails", async () => {
mockSaveConfig.mockImplementationOnce(() => { mockUpsertSettings.mockImplementationOnce(() => {
throw new Error("Validation failed"); throw new Error("Validation failed");
}); });
const res = await fetch(`${BASE_URL}/api/settings`, { const res = await fetch(`${BASE_URL}/api/settings`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({}) // Empty might be valid partial, but mocks throw body: JSON.stringify({})
}); });
expect(res.status).toBe(400); expect(res.status).toBe(400);
const data = await res.json(); const data = await res.json() as any;
expect(data.details).toBe("Validation failed"); expect(data.details).toBe("Validation failed");
}); });
@@ -164,7 +187,7 @@ describe("Settings API", () => {
const res = await fetch(`${BASE_URL}/api/settings/meta`); const res = await fetch(`${BASE_URL}/api/settings/meta`);
expect(res.status).toBe(200); expect(res.status).toBe(200);
const data = await res.json(); const data = await res.json() as any;
expect(data.roles).toHaveLength(2); expect(data.roles).toHaveLength(2);
expect(data.roles[0]).toEqual({ id: "role1", name: "Admin", color: "#ffffff" }); expect(data.roles[0]).toEqual({ id: "role1", name: "Admin", color: "#ffffff" });
expect(data.channels[0]).toEqual({ id: "chan1", name: "general", type: 0 }); expect(data.channels[0]).toEqual({ id: "chan1", name: "general", type: 0 });

View File

@@ -1,6 +1,5 @@
import { describe, test, expect, afterAll, mock } from "bun:test"; import { describe, test, expect, afterAll, mock } from "bun:test";
import type { WebServerInstance } from "./server"; import type { WebServerInstance } from "./server";
import { createWebServer } from "./server";
interface MockBotStats { interface MockBotStats {
bot: { name: string; avatarUrl: string | null }; bot: { name: string; avatarUrl: string | null };
@@ -13,21 +12,21 @@ interface MockBotStats {
} }
// 1. Mock DrizzleClient (dependency of dashboardService) // 1. Mock DrizzleClient (dependency of dashboardService)
// Must provide full chainable builder for select().from().leftJoin().groupBy().orderBy().limit()
mock.module("@shared/db/DrizzleClient", () => { mock.module("@shared/db/DrizzleClient", () => {
const mockBuilder = { const mockBuilder: Record<string, any> = {};
where: mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }])), // Every chainable method returns mock builder; terminal calls return resolved promise
then: (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]), mockBuilder.where = mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]));
orderBy: mock(() => mockBuilder), // Chainable mockBuilder.then = (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]);
limit: mock(() => Promise.resolve([])), // Terminal mockBuilder.orderBy = mock(() => mockBuilder);
}; mockBuilder.limit = mock(() => Promise.resolve([]));
mockBuilder.leftJoin = mock(() => mockBuilder);
const mockFrom = { mockBuilder.groupBy = mock(() => mockBuilder);
from: mock(() => mockBuilder), mockBuilder.from = mock(() => mockBuilder);
};
return { return {
DrizzleClient: { DrizzleClient: {
select: mock(() => mockFrom), select: mock(() => mockBuilder),
query: { query: {
transactions: { findMany: mock(() => Promise.resolve([])) }, transactions: { findMany: mock(() => Promise.resolve([])) },
moderationCases: { findMany: mock(() => Promise.resolve([])) }, moderationCases: { findMany: mock(() => Promise.resolve([])) },
@@ -54,7 +53,38 @@ mock.module("../../bot/lib/clientStats", () => ({
})), })),
})); }));
// 3. System Events (No mock needed, use real events) // 3. Mock config (used by lootdrop.service.getLootdropState)
mock.module("@shared/lib/config", () => ({
config: {
lootdrop: {
activityWindowMs: 120000,
minMessages: 1,
spawnChance: 1,
cooldownMs: 3000,
reward: { min: 40, max: 150, currency: "Astral Units" }
}
}
}));
// 4. Mock auth (bypass authentication for testing)
mock.module("./routes/auth.routes", () => ({
authRoutes: { name: "auth", handler: () => null },
isAuthenticated: () => true,
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
}));
// 5. Mock BotClient (used by stats helper for maintenanceMode)
mock.module("../../bot/lib/BotClient", () => ({
AuroraClient: {
maintenanceMode: false,
guilds: { cache: { get: () => null } },
commands: [],
knownCommands: new Map(),
}
}));
// Import after all mocks are set up
import { createWebServer } from "./server";
describe("WebServer Security & Limits", () => { describe("WebServer Security & Limits", () => {
const port = 3001; const port = 3001;

View File

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

View File

@@ -1,7 +1,8 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service"; import { moderationService } from "@shared/modules/moderation/moderation.service";
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const moderationCase = createCommand({ export const moderationCase = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -16,39 +17,35 @@ export const moderationCase = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const caseId = interaction.options.getString("case_id", true).toUpperCase();
try { // Validate case ID format
const caseId = interaction.options.getString("case_id", true).toUpperCase(); if (!caseId.match(/^CASE-\d+$/)) {
await interaction.editReply({
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
});
return;
}
// Validate case ID format // Get the case
if (!caseId.match(/^CASE-\d+$/)) { const moderationCase = await moderationService.getCaseById(caseId);
if (!moderationCase) {
await interaction.editReply({
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
});
return;
}
// Display the case
await interaction.editReply({ await interaction.editReply({
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")] embeds: [getCaseEmbed(moderationCase)]
}); });
return; },
} { ephemeral: true }
);
// Get the case
const moderationCase = await ModerationService.getCaseById(caseId);
if (!moderationCase) {
await interaction.editReply({
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
});
return;
}
// Display the case
await interaction.editReply({
embeds: [getCaseEmbed(moderationCase)]
});
} catch (error) {
console.error("Case command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while fetching the case.")]
});
}
} }
}); });

View File

@@ -1,7 +1,8 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service"; import { moderationService } from "@shared/modules/moderation/moderation.service";
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const cases = createCommand({ export const cases = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -22,33 +23,29 @@ export const cases = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const targetUser = interaction.options.getUser("user", true);
const activeOnly = interaction.options.getBoolean("active_only") || false;
try { // Get cases for the user
const targetUser = interaction.options.getUser("user", true); const userCases = await moderationService.getUserCases(targetUser.id, activeOnly);
const activeOnly = interaction.options.getBoolean("active_only") || false;
// Get cases for the user const title = activeOnly
const userCases = await ModerationService.getUserCases(targetUser.id, activeOnly); ? `⚠️ Active Cases for ${targetUser.username}`
: `📋 All Cases for ${targetUser.username}`;
const title = activeOnly const description = userCases.length === 0
? `⚠️ Active Cases for ${targetUser.username}` ? undefined
: `📋 All Cases for ${targetUser.username}`; : `Total cases: **${userCases.length}**`;
const description = userCases.length === 0 // Display the cases
? undefined await interaction.editReply({
: `Total cases: **${userCases.length}**`; embeds: [getCasesListEmbed(userCases, title, description)]
});
// Display the cases },
await interaction.editReply({ { ephemeral: true }
embeds: [getCasesListEmbed(userCases, title, description)] );
});
} catch (error) {
console.error("Cases command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while fetching cases.")]
});
}
} }
}); });

View File

@@ -1,7 +1,8 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service"; import { moderationService } from "@shared/modules/moderation/moderation.service";
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const clearwarning = createCommand({ export const clearwarning = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -23,62 +24,58 @@ export const clearwarning = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const caseId = interaction.options.getString("case_id", true).toUpperCase();
const reason = interaction.options.getString("reason") || "Cleared by moderator";
try { // Validate case ID format
const caseId = interaction.options.getString("case_id", true).toUpperCase(); if (!caseId.match(/^CASE-\d+$/)) {
const reason = interaction.options.getString("reason") || "Cleared by moderator"; await interaction.editReply({
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
});
return;
}
// Validate case ID format // Check if case exists and is active
if (!caseId.match(/^CASE-\d+$/)) { const existingCase = await moderationService.getCaseById(caseId);
await interaction.editReply({
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")] if (!existingCase) {
await interaction.editReply({
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
});
return;
}
if (!existingCase.active) {
await interaction.editReply({
embeds: [getModerationErrorEmbed(`Case **${caseId}** is already resolved.`)]
});
return;
}
if (existingCase.type !== 'warn') {
await interaction.editReply({
embeds: [getModerationErrorEmbed(`Case **${caseId}** is not a warning. Only warnings can be cleared.`)]
});
return;
}
// Clear the warning
await moderationService.clearCase({
caseId,
clearedBy: interaction.user.id,
clearedByName: interaction.user.username,
reason
}); });
return;
}
// Check if case exists and is active // Send success message
const existingCase = await ModerationService.getCaseById(caseId);
if (!existingCase) {
await interaction.editReply({ await interaction.editReply({
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)] embeds: [getClearSuccessEmbed(caseId)]
}); });
return; },
} { ephemeral: true }
);
if (!existingCase.active) {
await interaction.editReply({
embeds: [getModerationErrorEmbed(`Case **${caseId}** is already resolved.`)]
});
return;
}
if (existingCase.type !== 'warn') {
await interaction.editReply({
embeds: [getModerationErrorEmbed(`Case **${caseId}** is not a warning. Only warnings can be cleared.`)]
});
return;
}
// Clear the warning
await ModerationService.clearCase({
caseId,
clearedBy: interaction.user.id,
clearedByName: interaction.user.username,
reason
});
// Send success message
await interaction.editReply({
embeds: [getClearSuccessEmbed(caseId)]
});
} catch (error) {
console.error("Clear warning command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while clearing the warning.")]
});
}
} }
}); });

View File

@@ -1,10 +1,11 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config"; import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service"; import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
import { DrizzleClient } from "@shared/db/DrizzleClient"; import { DrizzleClient } from "@shared/db/DrizzleClient";
import { items } from "@db/schema"; import { items } from "@db/schema";
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds"; import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const createColor = createCommand({ export const createColor = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -32,62 +33,60 @@ export const createColor = createCommand({
) )
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply(); await withCommandErrorHandling(
interaction,
async () => {
const name = interaction.options.getString("name", true);
const colorInput = interaction.options.getString("color", true);
const price = interaction.options.getNumber("price") || 500;
const imageUrl = interaction.options.getString("image") || "https://cdn.discordapp.com/attachments/1450061247365124199/1453122950822760559/Main_Chip_1.png";
const name = interaction.options.getString("name", true); // 1. Validate Color
const colorInput = interaction.options.getString("color", true); const colorRegex = /^#([0-9A-F]{3}){1,2}$/i;
const price = interaction.options.getNumber("price") || 500; if (!colorRegex.test(colorInput)) {
const imageUrl = interaction.options.getString("image") || "https://cdn.discordapp.com/attachments/1450061247365124199/1453122950822760559/Main_Chip_1.png"; await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] });
return;
}
// 1. Validate Color // 2. Create Role
const colorRegex = /^#([0-9A-F]{3}){1,2}$/i; const role = await interaction.guild?.roles.create({
if (!colorRegex.test(colorInput)) { name: name,
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] }); color: colorInput as any,
return; reason: `Created via /createcolor by ${interaction.user.tag}`
} });
try { if (!role) {
// 2. Create Role throw new Error("Failed to create role.");
const role = await interaction.guild?.roles.create({ }
name: name,
color: colorInput as any,
reason: `Created via /createcolor by ${interaction.user.tag}`
});
if (!role) { // 3. Add to guild settings
throw new Error("Failed to create role."); await guildSettingsService.addColorRole(interaction.guildId!, role.id);
} invalidateGuildConfigCache(interaction.guildId!);
// 3. Add to guild settings // 4. Create Item
await guildSettingsService.addColorRole(interaction.guildId!, role.id); await DrizzleClient.insert(items).values({
invalidateGuildConfigCache(interaction.guildId!); name: `Color Role - ${name}`,
description: `Use this item to apply the ${name} color to your name.`,
type: "CONSUMABLE",
rarity: "Common",
price: BigInt(price),
iconUrl: "",
imageUrl: imageUrl,
usageData: {
consume: false,
effects: [{ type: "COLOR_ROLE", roleId: role.id }]
} as any
});
// 4. Create Item // 5. Success
await DrizzleClient.insert(items).values({ await interaction.editReply({
name: `Color Role - ${name}`, embeds: [createSuccessEmbed(
description: `Use this item to apply the ${name} color to your name.`, `**Role:** <@&${role.id}> (${colorInput})\n**Item:** Color Role - ${name}\n**Price:** ${price} 🪙`,
type: "CONSUMABLE", "✅ Color Role & Item Created"
rarity: "Common", )]
price: BigInt(price), });
iconUrl: "", },
imageUrl: imageUrl, { ephemeral: true }
usageData: { );
consume: false,
effects: [{ type: "COLOR_ROLE", roleId: role.id }]
} as any
});
// 5. Success
await interaction.editReply({
embeds: [createSuccessEmbed(
`**Role:** <@&${role.id}> (${colorInput})\n**Item:** Color Role - ${name}\n**Price:** ${price} 🪙`,
"✅ Color Role & Item Created"
)]
});
} catch (error: any) {
console.error("Error in createcolor:", error);
await interaction.editReply({ embeds: [createErrorEmbed(`Failed to create color role: ${error.message}`)] });
}
} }
}); });

View File

@@ -2,7 +2,7 @@ import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, Colors, userMention, roleMention, ChatInputCommandInteraction } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, Colors, userMention, roleMention, ChatInputCommandInteraction } from "discord.js";
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service"; import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors"; import { withCommandErrorHandling } from "@lib/commandUtils";
export const featureflags = createCommand({ export const featureflags = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -98,57 +98,53 @@ export const featureflags = createCommand({
), ),
autocomplete: async (interaction) => { autocomplete: async (interaction) => {
const focused = interaction.options.getFocused(true); const focused = interaction.options.getFocused(true);
if (focused.name === "name") { if (focused.name === "name") {
const flags = await featureFlagsService.listFlags(); const flags = await featureFlagsService.listFlags();
const filtered = flags const filtered = flags
.filter(f => f.name.toLowerCase().includes(focused.value.toLowerCase())) .filter(f => f.name.toLowerCase().includes(focused.value.toLowerCase()))
.slice(0, 25); .slice(0, 25);
await interaction.respond( await interaction.respond(
filtered.map(f => ({ name: `${f.name} (${f.enabled ? "enabled" : "disabled"})`, value: f.name })) filtered.map(f => ({ name: `${f.name} (${f.enabled ? "enabled" : "disabled"})`, value: f.name }))
); );
} }
}, },
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply(); await withCommandErrorHandling(
interaction,
async () => {
const subcommand = interaction.options.getSubcommand();
const subcommand = interaction.options.getSubcommand(); switch (subcommand) {
case "list":
try { await handleList(interaction);
switch (subcommand) { break;
case "list": case "create":
await handleList(interaction); await handleCreate(interaction);
break; break;
case "create": case "delete":
await handleCreate(interaction); await handleDelete(interaction);
break; break;
case "delete": case "enable":
await handleDelete(interaction); await handleEnable(interaction);
break; break;
case "enable": case "disable":
await handleEnable(interaction); await handleDisable(interaction);
break; break;
case "disable": case "grant":
await handleDisable(interaction); await handleGrant(interaction);
break; break;
case "grant": case "revoke":
await handleGrant(interaction); await handleRevoke(interaction);
break; break;
case "revoke": case "access":
await handleRevoke(interaction); await handleAccess(interaction);
break; break;
case "access": }
await handleAccess(interaction); },
break; { ephemeral: true }
} );
} catch (error) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
throw error;
}
}
}, },
}); });
@@ -177,44 +173,44 @@ async function handleCreate(interaction: ChatInputCommandInteraction) {
const description = interaction.options.getString("description"); const description = interaction.options.getString("description");
const flag = await featureFlagsService.createFlag(name, description ?? undefined); const flag = await featureFlagsService.createFlag(name, description ?? undefined);
if (!flag) { if (!flag) {
await interaction.editReply({ embeds: [createErrorEmbed("Failed to create feature flag.")] }); await interaction.editReply({ embeds: [createErrorEmbed("Failed to create feature flag.")] });
return; return;
} }
await interaction.editReply({ await interaction.editReply({
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" created successfully. Use \`/featureflags enable\` to enable it.`)] embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" created successfully. Use \`/featureflags enable\` to enable it.`)]
}); });
} }
async function handleDelete(interaction: ChatInputCommandInteraction) { async function handleDelete(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString("name", true); const name = interaction.options.getString("name", true);
const flag = await featureFlagsService.deleteFlag(name); const flag = await featureFlagsService.deleteFlag(name);
await interaction.editReply({ await interaction.editReply({
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been deleted.`)] embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been deleted.`)]
}); });
} }
async function handleEnable(interaction: ChatInputCommandInteraction) { async function handleEnable(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString("name", true); const name = interaction.options.getString("name", true);
const flag = await featureFlagsService.setFlagEnabled(name, true); const flag = await featureFlagsService.setFlagEnabled(name, true);
await interaction.editReply({ await interaction.editReply({
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been enabled.`)] embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been enabled.`)]
}); });
} }
async function handleDisable(interaction: ChatInputCommandInteraction) { async function handleDisable(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString("name", true); const name = interaction.options.getString("name", true);
const flag = await featureFlagsService.setFlagEnabled(name, false); const flag = await featureFlagsService.setFlagEnabled(name, false);
await interaction.editReply({ await interaction.editReply({
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been disabled.`)] embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been disabled.`)]
}); });
} }
@@ -224,8 +220,8 @@ async function handleGrant(interaction: ChatInputCommandInteraction) {
const role = interaction.options.getRole("role"); const role = interaction.options.getRole("role");
if (!user && !role) { if (!user && !role) {
await interaction.editReply({ await interaction.editReply({
embeds: [createErrorEmbed("You must specify either a user or a role to grant access to.")] embeds: [createErrorEmbed("You must specify either a user or a role to grant access to.")]
}); });
return; return;
} }
@@ -250,29 +246,29 @@ async function handleGrant(interaction: ChatInputCommandInteraction) {
target = "Unknown"; target = "Unknown";
} }
await interaction.editReply({ await interaction.editReply({
embeds: [createSuccessEmbed(`Access to "**${name}**" granted to ${target} (ID: ${access.id})`)] embeds: [createSuccessEmbed(`Access to "**${name}**" granted to ${target} (ID: ${access.id})`)]
}); });
} }
async function handleRevoke(interaction: ChatInputCommandInteraction) { async function handleRevoke(interaction: ChatInputCommandInteraction) {
const id = interaction.options.getInteger("id", true); const id = interaction.options.getInteger("id", true);
const access = await featureFlagsService.revokeAccess(id); const access = await featureFlagsService.revokeAccess(id);
await interaction.editReply({ await interaction.editReply({
embeds: [createSuccessEmbed(`Access record #${access.id} has been revoked.`)] embeds: [createSuccessEmbed(`Access record #${access.id} has been revoked.`)]
}); });
} }
async function handleAccess(interaction: ChatInputCommandInteraction) { async function handleAccess(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString("name", true); const name = interaction.options.getString("name", true);
const accessRecords = await featureFlagsService.listAccess(name); const accessRecords = await featureFlagsService.listAccess(name);
if (accessRecords.length === 0) { if (accessRecords.length === 0) {
await interaction.editReply({ await interaction.editReply({
embeds: [createBaseEmbed("Feature Flag Access", `No access records for "**${name}**".`, Colors.Blue)] embeds: [createBaseEmbed("Feature Flag Access", `No access records for "**${name}**".`, Colors.Blue)]
}); });
return; return;
} }

View File

@@ -7,12 +7,12 @@ import {
} from "discord.js"; } from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service"; import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { createErrorEmbed } from "@lib/embeds"; import { createErrorEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors";
import { items } from "@db/schema"; import { items } from "@db/schema";
import { ilike, isNotNull, and, inArray } from "drizzle-orm"; import { ilike, isNotNull, and, inArray } from "drizzle-orm";
import { DrizzleClient } from "@shared/db/DrizzleClient"; import { DrizzleClient } from "@shared/db/DrizzleClient";
import { getShopListingMessage } from "@/modules/economy/shop.view"; import { getShopListingMessage } from "@/modules/economy/shop.view";
import { EffectType, LootType } from "@shared/lib/constants"; import { EffectType, LootType } from "@shared/lib/constants";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const listing = createCommand({ export const listing = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -31,72 +31,67 @@ export const listing = createCommand({
) )
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const itemId = interaction.options.getNumber("item", true);
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
const itemId = interaction.options.getNumber("item", true); if (!targetChannel || !targetChannel.isSendable()) {
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel; await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
return;
if (!targetChannel || !targetChannel.isSendable()) {
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
return;
}
const item = await inventoryService.getItem(itemId);
if (!item) {
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
return;
}
if (!item.price) {
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
return;
}
// Prepare context for lootboxes
const context: { referencedItems: Map<number, { name: string; rarity: string }> } = { referencedItems: new Map() };
const usageData = item.usageData as any;
const lootboxEffect = usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
if (lootboxEffect && lootboxEffect.pool) {
const itemIds = lootboxEffect.pool
.filter((drop: any) => drop.type === LootType.ITEM && drop.itemId)
.map((drop: any) => drop.itemId);
if (itemIds.length > 0) {
// Remove duplicates
const uniqueIds = [...new Set(itemIds)] as number[];
const referencedItems = await DrizzleClient.select({
id: items.id,
name: items.name,
rarity: items.rarity
}).from(items).where(inArray(items.id, uniqueIds));
for (const ref of referencedItems) {
context.referencedItems.set(ref.id, { name: ref.name, rarity: ref.rarity || 'C' });
} }
}
}
const listingMessage = getShopListingMessage({ const item = await inventoryService.getItem(itemId);
...item, if (!item) {
rarity: item.rarity || undefined, await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
formattedPrice: `${item.price} 🪙`, return;
price: item.price }
}, context);
try { if (!item.price) {
await targetChannel.send(listingMessage as any); await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` }); return;
} catch (error: any) { }
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); // Prepare context for lootboxes
} else { const context: { referencedItems: Map<number, { name: string; rarity: string }> } = { referencedItems: new Map() };
console.error("Error creating listing:", error);
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] }); const usageData = item.usageData as any;
} const lootboxEffect = usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
}
if (lootboxEffect && lootboxEffect.pool) {
const itemIds = lootboxEffect.pool
.filter((drop: any) => drop.type === LootType.ITEM && drop.itemId)
.map((drop: any) => drop.itemId);
if (itemIds.length > 0) {
// Remove duplicates
const uniqueIds = [...new Set(itemIds)] as number[];
const referencedItems = await DrizzleClient.select({
id: items.id,
name: items.name,
rarity: items.rarity
}).from(items).where(inArray(items.id, uniqueIds));
for (const ref of referencedItems) {
context.referencedItems.set(ref.id, { name: ref.name, rarity: ref.rarity || 'C' });
}
}
}
const listingMessage = getShopListingMessage({
...item,
rarity: item.rarity || undefined,
formattedPrice: `${item.price} 🪙`,
price: item.price
}, context);
await targetChannel.send(listingMessage as any);
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
},
{ ephemeral: true }
);
}, },
autocomplete: async (interaction) => { autocomplete: async (interaction) => {
const focusedValue = interaction.options.getFocused(); const focusedValue = interaction.options.getFocused();

View File

@@ -1,8 +1,9 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service"; import { moderationService } from "@shared/modules/moderation/moderation.service";
import { CaseType } from "@shared/lib/constants"; import { CaseType } from "@shared/lib/constants";
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const note = createCommand({ export const note = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -24,39 +25,35 @@ export const note = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const targetUser = interaction.options.getUser("user", true);
const noteText = interaction.options.getString("note", true);
try { // Create the note case
const targetUser = interaction.options.getUser("user", true); const moderationCase = await moderationService.createCase({
const noteText = interaction.options.getString("note", true); type: CaseType.NOTE,
userId: targetUser.id,
// Create the note case username: targetUser.username,
const moderationCase = await ModerationService.createCase({ moderatorId: interaction.user.id,
type: CaseType.NOTE, moderatorName: interaction.user.username,
userId: targetUser.id, reason: noteText,
username: targetUser.username,
moderatorId: interaction.user.id,
moderatorName: interaction.user.username,
reason: noteText,
});
if (!moderationCase) {
await interaction.editReply({
embeds: [getModerationErrorEmbed("Failed to create note.")]
}); });
return;
}
// Send success message if (!moderationCase) {
await interaction.editReply({ await interaction.editReply({
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)] embeds: [getModerationErrorEmbed("Failed to create note.")]
}); });
return;
}
} catch (error) { // Send success message
console.error("Note command error:", error); await interaction.editReply({
await interaction.editReply({ embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
embeds: [getModerationErrorEmbed("An error occurred while adding the note.")] });
}); },
} { ephemeral: true }
);
} }
}); });

View File

@@ -1,7 +1,8 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service"; import { moderationService } from "@shared/modules/moderation/moderation.service";
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const notes = createCommand({ export const notes = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -16,28 +17,24 @@ export const notes = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const targetUser = interaction.options.getUser("user", true);
try { // Get all notes for the user
const targetUser = interaction.options.getUser("user", true); const userNotes = await moderationService.getUserNotes(targetUser.id);
// Get all notes for the user // Display the notes
const userNotes = await ModerationService.getUserNotes(targetUser.id); await interaction.editReply({
embeds: [getCasesListEmbed(
// Display the notes userNotes,
await interaction.editReply({ `📝 Staff Notes for ${targetUser.username}`,
embeds: [getCasesListEmbed( userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
userNotes, )]
`📝 Staff Notes for ${targetUser.username}`, });
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**` },
)] { ephemeral: true }
}); );
} catch (error) {
console.error("Notes command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while fetching notes.")]
});
}
} }
}); });

View File

@@ -1,7 +1,7 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
import { config } from "@shared/lib/config"; import { config } from "@shared/lib/config";
import { PruneService } from "@shared/modules/moderation/prune.service"; import { pruneService } from "@shared/modules/moderation/prune.service";
import { import {
getConfirmationMessage, getConfirmationMessage,
getProgressEmbed, getProgressEmbed,
@@ -10,6 +10,7 @@ import {
getPruneWarningEmbed, getPruneWarningEmbed,
getCancelledEmbed getCancelledEmbed
} from "@/modules/moderation/prune.view"; } from "@/modules/moderation/prune.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const prune = createCommand({ export const prune = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -38,142 +39,126 @@ export const prune = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const amount = interaction.options.getInteger("amount");
const user = interaction.options.getUser("user");
const all = interaction.options.getBoolean("all") || false;
try { // Validate inputs
const amount = interaction.options.getInteger("amount"); if (!amount && !all) {
const user = interaction.options.getUser("user"); // Default to 10 messages
const all = interaction.options.getBoolean("all") || false; } else if (amount && all) {
// Validate inputs
if (!amount && !all) {
// Default to 10 messages
} else if (amount && all) {
await interaction.editReply({
embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
});
return;
}
const finalAmount = all ? 'all' : (amount || 10);
const confirmThreshold = config.moderation.prune.confirmThreshold;
// Check if confirmation is needed
const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
if (needsConfirmation) {
// Estimate message count for confirmation
let estimatedCount: number | undefined;
if (all) {
try {
estimatedCount = await PruneService.estimateMessageCount(interaction.channel!);
} catch {
estimatedCount = undefined;
}
}
const { embeds, components } = getConfirmationMessage(finalAmount, estimatedCount);
const response = await interaction.editReply({ embeds, components });
try {
const confirmation = await response.awaitMessageComponent({
filter: (i) => i.user.id === interaction.user.id,
componentType: ComponentType.Button,
time: 30000
});
if (confirmation.customId === "cancel_prune") {
await confirmation.update({
embeds: [getCancelledEmbed()],
components: []
});
return;
}
// User confirmed, proceed with deletion
await confirmation.update({
embeds: [getProgressEmbed({ current: 0, total: estimatedCount || finalAmount as number })],
components: []
});
// Execute deletion with progress callback for 'all' mode
const result = await PruneService.deleteMessages(
interaction.channel!,
{
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
userId: user?.id,
all
},
all ? async (progress) => {
await interaction.editReply({
embeds: [getProgressEmbed(progress)]
});
} : undefined
);
// Show success
await interaction.editReply({ await interaction.editReply({
embeds: [getSuccessEmbed(result)], embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
components: []
}); });
} catch (error) {
if (error instanceof Error && error.message.includes("time")) {
await interaction.editReply({
embeds: [getPruneWarningEmbed("Confirmation timed out. Please try again.")],
components: []
});
} else {
throw error;
}
}
} else {
// No confirmation needed, proceed directly
const result = await PruneService.deleteMessages(
interaction.channel!,
{
amount: finalAmount as number,
userId: user?.id,
all: false
}
);
// Check if no messages were found
if (result.deletedCount === 0) {
if (user) {
await interaction.editReply({
embeds: [getPruneWarningEmbed(`No messages found from **${user.username}** in the last ${finalAmount} messages.`)]
});
} else {
await interaction.editReply({
embeds: [getPruneWarningEmbed("No messages found to delete.")]
});
}
return; return;
} }
await interaction.editReply({ const finalAmount = all ? 'all' : (amount || 10);
embeds: [getSuccessEmbed(result)] const confirmThreshold = config.moderation.prune.confirmThreshold;
});
}
} catch (error) { // Check if confirmation is needed
console.error("Prune command error:", error); const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
let errorMessage = "An unexpected error occurred while trying to delete messages."; if (needsConfirmation) {
if (error instanceof Error) { // Estimate message count for confirmation
if (error.message.includes("permission")) { let estimatedCount: number | undefined;
errorMessage = "I don't have permission to delete messages in this channel."; if (all) {
} else if (error.message.includes("channel type")) { try {
errorMessage = "This command cannot be used in this type of channel."; estimatedCount = await pruneService.estimateMessageCount(interaction.channel!);
} catch {
estimatedCount = undefined;
}
}
const { embeds, components } = getConfirmationMessage(finalAmount, estimatedCount);
const response = await interaction.editReply({ embeds, components });
try {
const confirmation = await response.awaitMessageComponent({
filter: (i) => i.user.id === interaction.user.id,
componentType: ComponentType.Button,
time: 30000
});
if (confirmation.customId === "cancel_prune") {
await confirmation.update({
embeds: [getCancelledEmbed()],
components: []
});
return;
}
// User confirmed, proceed with deletion
await confirmation.update({
embeds: [getProgressEmbed({ current: 0, total: estimatedCount || finalAmount as number })],
components: []
});
// Execute deletion with progress callback for 'all' mode
const result = await pruneService.deleteMessages(
interaction.channel!,
{
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
userId: user?.id,
all
},
all ? async (progress) => {
await interaction.editReply({
embeds: [getProgressEmbed(progress)]
});
} : undefined
);
// Show success
await interaction.editReply({
embeds: [getSuccessEmbed(result)],
components: []
});
} catch (error) {
if (error instanceof Error && error.message.includes("time")) {
await interaction.editReply({
embeds: [getPruneWarningEmbed("Confirmation timed out. Please try again.")],
components: []
});
} else {
throw error;
}
}
} else { } else {
errorMessage = error.message; // No confirmation needed, proceed directly
} const result = await pruneService.deleteMessages(
} interaction.channel!,
{
amount: finalAmount as number,
userId: user?.id,
all: false
}
);
await interaction.editReply({ // Check if no messages were found
embeds: [getPruneErrorEmbed(errorMessage)] if (result.deletedCount === 0) {
}); if (user) {
} await interaction.editReply({
embeds: [getPruneWarningEmbed(`No messages found from **${user.username}** in the last ${finalAmount} messages.`)]
});
} else {
await interaction.editReply({
embeds: [getPruneWarningEmbed("No messages found to delete.")]
});
}
return;
}
await interaction.editReply({
embeds: [getSuccessEmbed(result)]
});
}
},
{ ephemeral: true }
);
} }
}); });

View File

@@ -1,7 +1,8 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { AuroraClient } from "@/lib/BotClient"; import { AuroraClient } from "@/lib/BotClient";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { createSuccessEmbed } from "@lib/embeds";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const refresh = createCommand({ export const refresh = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -9,25 +10,24 @@ export const refresh = createCommand({
.setDescription("Reloads all commands and config without restarting") .setDescription("Reloads all commands and config without restarting")
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const start = Date.now();
await AuroraClient.loadCommands(true);
const duration = Date.now() - start;
try { // Deploy commands
const start = Date.now(); await AuroraClient.deployCommands();
await AuroraClient.loadCommands(true);
const duration = Date.now() - start;
// Deploy commands const embed = createSuccessEmbed(
await AuroraClient.deployCommands(); `Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
"System Refreshed"
);
const embed = createSuccessEmbed( await interaction.editReply({ embeds: [embed] });
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`, },
"System Refreshed" { ephemeral: true }
); );
await interaction.editReply({ embeds: [embed] });
} catch (error) {
console.error(error);
await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while refreshing commands. Check console for details.", "Refresh Failed")] });
}
} }
}); });

View File

@@ -3,7 +3,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, Colors, ChatInputCommandInter
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service"; import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config"; import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
import { UserError } from "@shared/lib/errors"; import { withCommandErrorHandling } from "@lib/commandUtils";
export const settings = createCommand({ export const settings = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -84,33 +84,29 @@ export const settings = createCommand({
.setRequired(false))), .setRequired(false))),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ ephemeral: true }); await withCommandErrorHandling(
interaction,
async () => {
const subcommand = interaction.options.getSubcommand();
const guildId = interaction.guildId!;
const subcommand = interaction.options.getSubcommand(); switch (subcommand) {
const guildId = interaction.guildId!; case "show":
await handleShow(interaction, guildId);
try { break;
switch (subcommand) { case "set":
case "show": await handleSet(interaction, guildId);
await handleShow(interaction, guildId); break;
break; case "reset":
case "set": await handleReset(interaction, guildId);
await handleSet(interaction, guildId); break;
break; case "colors":
case "reset": await handleColors(interaction, guildId);
await handleReset(interaction, guildId); break;
break; }
case "colors": },
await handleColors(interaction, guildId); { ephemeral: true }
break; );
}
} catch (error) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
throw error;
}
}
}, },
}); });

View File

@@ -2,7 +2,8 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
import { terminalService } from "@shared/modules/terminal/terminal.service"; import { terminalService } from "@shared/modules/terminal/terminal.service";
import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds"; import { createErrorEmbed } from "@/lib/embeds";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const terminal = createCommand({ export const terminal = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -23,15 +24,14 @@ export const terminal = createCommand({
return; return;
} }
await interaction.reply({ ephemeral: true, content: "Initializing terminal..." }); await withCommandErrorHandling(
interaction,
try { async () => {
await terminalService.init(channel as TextChannel); await terminalService.init(channel as TextChannel);
await interaction.editReply({ content: "✅ Terminal initialized!" }); await interaction.editReply({ content: "✅ Terminal initialized!" });
} catch (error) { },
console.error(error); { ephemeral: true }
await interaction.editReply({ content: "❌ Failed to initialize terminal." }); );
}
} }
} }
}); });

View File

@@ -1,12 +1,12 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service"; import { moderationService } from "@shared/modules/moderation/moderation.service";
import { import {
getWarnSuccessEmbed, getWarnSuccessEmbed,
getModerationErrorEmbed, getModerationErrorEmbed,
getUserWarningEmbed
} from "@/modules/moderation/moderation.view"; } from "@/modules/moderation/moderation.view";
import { getGuildConfig } from "@shared/lib/config"; import { getGuildConfig } from "@shared/lib/config";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const warn = createCommand({ export const warn = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -28,67 +28,63 @@ export const warn = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const targetUser = interaction.options.getUser("user", true);
const reason = interaction.options.getString("reason", true);
try { // Don't allow warning bots
const targetUser = interaction.options.getUser("user", true); if (targetUser.bot) {
const reason = interaction.options.getString("reason", true); await interaction.editReply({
embeds: [getModerationErrorEmbed("You cannot warn bots.")]
});
return;
}
// Don't allow warning bots // Don't allow self-warnings
if (targetUser.bot) { if (targetUser.id === interaction.user.id) {
await interaction.editReply({
embeds: [getModerationErrorEmbed("You cannot warn yourself.")]
});
return;
}
// Fetch guild config for moderation settings
const guildConfig = await getGuildConfig(interaction.guildId!);
// Issue the warning via service
const { moderationCase, warningCount, autoTimeoutIssued } = await moderationService.issueWarning({
userId: targetUser.id,
username: targetUser.username,
moderatorId: interaction.user.id,
moderatorName: interaction.user.username,
reason,
guildName: interaction.guild?.name || undefined,
dmTarget: targetUser,
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id),
config: {
dmOnWarn: guildConfig.moderation?.cases?.dmOnWarn,
autoTimeoutThreshold: guildConfig.moderation?.cases?.autoTimeoutThreshold,
},
});
// Send success message to moderator
await interaction.editReply({ await interaction.editReply({
embeds: [getModerationErrorEmbed("You cannot warn bots.")] embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
}); });
return;
}
// Don't allow self-warnings // Follow up if auto-timeout was issued
if (targetUser.id === interaction.user.id) { if (autoTimeoutIssued) {
await interaction.editReply({ await interaction.followUp({
embeds: [getModerationErrorEmbed("You cannot warn yourself.")] embeds: [getModerationErrorEmbed(
}); `⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
return; )],
} flags: MessageFlags.Ephemeral
});
// Fetch guild config for moderation settings }
const guildConfig = await getGuildConfig(interaction.guildId!); },
{ ephemeral: true }
// Issue the warning via service );
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
userId: targetUser.id,
username: targetUser.username,
moderatorId: interaction.user.id,
moderatorName: interaction.user.username,
reason,
guildName: interaction.guild?.name || undefined,
dmTarget: targetUser,
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id),
config: {
dmOnWarn: guildConfig.moderation?.cases?.dmOnWarn,
autoTimeoutThreshold: guildConfig.moderation?.cases?.autoTimeoutThreshold,
},
});
// Send success message to moderator
await interaction.editReply({
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
});
// Follow up if auto-timeout was issued
if (autoTimeoutIssued) {
await interaction.followUp({
embeds: [getModerationErrorEmbed(
`⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
)],
flags: MessageFlags.Ephemeral
});
}
} catch (error) {
console.error("Warn command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while issuing the warning.")]
});
}
} }
}); });

View File

@@ -1,7 +1,8 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service"; import { moderationService } from "@shared/modules/moderation/moderation.service";
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getWarningsEmbed } from "@/modules/moderation/moderation.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const warnings = createCommand({ export const warnings = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -16,24 +17,20 @@ export const warnings = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const targetUser = interaction.options.getUser("user", true);
try { // Get active warnings for the user
const targetUser = interaction.options.getUser("user", true); const activeWarnings = await moderationService.getUserWarnings(targetUser.id);
// Get active warnings for the user // Display the warnings
const activeWarnings = await ModerationService.getUserWarnings(targetUser.id); await interaction.editReply({
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
// Display the warnings });
await interaction.editReply({ },
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)] { ephemeral: true }
}); );
} catch (error) {
console.error("Warnings command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while fetching warnings.")]
});
}
} }
}); });

View File

@@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { createErrorEmbed } from "@/lib/embeds"; import { createErrorEmbed } from "@/lib/embeds";
import { sendWebhookMessage } from "@/lib/webhookUtils"; import { sendWebhookMessage } from "@/lib/webhookUtils";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const webhook = createCommand({ export const webhook = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -14,43 +15,40 @@ export const webhook = createCommand({
.setRequired(true) .setRequired(true)
), ),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const payloadString = interaction.options.getString("payload", true);
let payload;
const payloadString = interaction.options.getString("payload", true); try {
let payload; payload = JSON.parse(payloadString);
} catch (error) {
await interaction.editReply({
embeds: [createErrorEmbed("The provided payload is not valid JSON.", "Invalid JSON")]
});
return;
}
try { const channel = interaction.channel;
payload = JSON.parse(payloadString);
} catch (error) {
await interaction.editReply({
embeds: [createErrorEmbed("The provided payload is not valid JSON.", "Invalid JSON")]
});
return;
}
const channel = interaction.channel; if (!channel || !('createWebhook' in channel)) {
await interaction.editReply({
embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")]
});
return;
}
if (!channel || !('createWebhook' in channel)) { await sendWebhookMessage(
await interaction.editReply({ channel,
embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")] payload,
}); interaction.client.user,
return; `Proxy message requested by ${interaction.user.tag}`
} );
try { await interaction.editReply({ content: "Message sent successfully!" });
await sendWebhookMessage( },
channel, { ephemeral: true }
payload, );
interaction.client.user,
`Proxy message requested by ${interaction.user.tag}`
);
await interaction.editReply({ content: "Message sent successfully!" });
} catch (error) {
console.error("Webhook error:", error);
await interaction.editReply({
embeds: [createErrorEmbed("Failed to send message via webhook. Ensure the bot has 'Manage Webhooks' permission and the payload is valid.", "Delivery Failed")]
});
}
} }
}); });

View File

@@ -2,35 +2,29 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js"; import { SlashCommandBuilder } from "discord.js";
import { economyService } from "@shared/modules/economy/economy.service"; import { economyService } from "@shared/modules/economy/economy.service";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors"; import { withCommandErrorHandling } from "@lib/commandUtils";
export const daily = createCommand({ export const daily = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("daily") .setName("daily")
.setDescription("Claim your daily reward"), .setDescription("Claim your daily reward"),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply(); await withCommandErrorHandling(
try { interaction,
const result = await economyService.claimDaily(interaction.user.id); async () => {
const result = await economyService.claimDaily(interaction.user.id);
const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!") const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!")
.addFields( .addFields(
{ name: "Streak", value: `🔥 ${result.streak} days`, inline: true }, { name: "Streak", value: `🔥 ${result.streak} days`, inline: true },
{ name: "Weekly Progress", value: `${"🟩".repeat(result.streak % 7 || 7)}${"⬜".repeat(7 - (result.streak % 7 || 7))} (${result.streak % 7 || 7}/7)`, inline: true }, { name: "Weekly Progress", value: `${"🟩".repeat(result.streak % 7 || 7)}${"⬜".repeat(7 - (result.streak % 7 || 7))} (${result.streak % 7 || 7}/7)`, inline: true },
{ name: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R> `, inline: true } { name: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R> `, inline: true }
) )
.setColor("Gold"); .setColor("Gold");
await interaction.editReply({ embeds: [embed] }); await interaction.editReply({ embeds: [embed] });
} catch (error: any) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
console.error("Error claiming daily:", error);
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
} }
} );
} }
}); });

View File

@@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js"; import { SlashCommandBuilder } from "discord.js";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { examService, ExamStatus } from "@shared/modules/economy/exam.service"; import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
import { withCommandErrorHandling } from "@lib/commandUtils";
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
@@ -10,66 +11,62 @@ export const exam = createCommand({
.setName("exam") .setName("exam")
.setDescription("Take your weekly exam to earn rewards based on your XP progress."), .setDescription("Take your weekly exam to earn rewards based on your XP progress."),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply(); await withCommandErrorHandling(
interaction,
async () => {
// First, try to take the exam or check status
const result = await examService.takeExam(interaction.user.id);
try { if (result.status === ExamStatus.NOT_REGISTERED) {
// First, try to take the exam or check status // Register the user
const result = await examService.takeExam(interaction.user.id); const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username);
const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000);
if (result.status === ExamStatus.NOT_REGISTERED) { await interaction.editReply({
// Register the user embeds: [createSuccessEmbed(
const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username); `You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000); `Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
"Exam Registration Successful"
)]
});
return;
}
const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
if (result.status === ExamStatus.COOLDOWN) {
await interaction.editReply({
embeds: [createErrorEmbed(
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
`Next exam available: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
)]
});
return;
}
if (result.status === ExamStatus.MISSED) {
await interaction.editReply({
embeds: [createErrorEmbed(
`You missed your exam day! Your exam day is **${DAYS[result.examDay!]}** (Server Time).\n` +
`You verify your attendance but score a **0**.\n` +
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
"Exam Failed"
)]
});
return;
}
// If it reached here with AVAILABLE, it means they passed
await interaction.editReply({ await interaction.editReply({
embeds: [createSuccessEmbed( embeds: [createSuccessEmbed(
`You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` + `**XP Gained:** ${result.xpDiff?.toString()}\n` +
`Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`, `**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
"Exam Registration Successful" `**Reward:** ${result.reward?.toString()} Currency\n\n` +
`See you next week: <t:${nextExamTimestamp}:D>`,
"Exam Passed!"
)] )]
}); });
return;
} }
);
const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
if (result.status === ExamStatus.COOLDOWN) {
await interaction.editReply({
embeds: [createErrorEmbed(
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
`Next exam available: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
)]
});
return;
}
if (result.status === ExamStatus.MISSED) {
await interaction.editReply({
embeds: [createErrorEmbed(
`You missed your exam day! Your exam day is **${DAYS[result.examDay!]}** (Server Time).\n` +
`You verify your attendance but score a **0**.\n` +
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
"Exam Failed"
)]
});
return;
}
// If it reached here with AVAILABLE, it means they passed
await interaction.editReply({
embeds: [createSuccessEmbed(
`**XP Gained:** ${result.xpDiff?.toString()}\n` +
`**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
`**Reward:** ${result.reward?.toString()} Currency\n\n` +
`See you next week: <t:${nextExamTimestamp}:D>`,
"Exam Passed!"
)]
});
} catch (error: any) {
console.error("Error in exam command:", error);
await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An unexpected error occurred.")] });
}
} }
}); });

View File

@@ -5,7 +5,7 @@ import { economyService } from "@shared/modules/economy/economy.service";
import { userService } from "@shared/modules/user/user.service"; import { userService } from "@shared/modules/user/user.service";
import { config } from "@shared/lib/config"; import { config } from "@shared/lib/config";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors"; import { withCommandErrorHandling } from "@lib/commandUtils";
export const pay = createCommand({ export const pay = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -50,20 +50,14 @@ export const pay = createCommand({
return; return;
} }
try { await withCommandErrorHandling(
await interaction.deferReply(); interaction,
await economyService.transfer(senderId, receiverId.toString(), amount); async () => {
await economyService.transfer(senderId, receiverId.toString(), amount);
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful"); const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` }); await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
} catch (error: any) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
console.error("Error sending payment:", error);
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
} }
} );
} }
}); });

View File

@@ -6,6 +6,7 @@ import { createErrorEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors"; import { UserError } from "@shared/lib/errors";
import { config } from "@shared/lib/config"; import { config } from "@shared/lib/config";
import { TriviaCategory } from "@shared/lib/constants"; import { TriviaCategory } from "@shared/lib/constants";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const trivia = createCommand({ export const trivia = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -53,64 +54,54 @@ export const trivia = createCommand({
return; return;
} }
// User can play - defer publicly for trivia question // User can play - use standardized error handling for the main operation
await interaction.deferReply(); await withCommandErrorHandling(
interaction,
async () => {
// Start trivia session (deducts entry fee)
const session = await triviaService.startTrivia(
interaction.user.id,
interaction.user.username,
categoryId ? parseInt(categoryId) : undefined
);
// Start trivia session (deducts entry fee) // Generate Components v2 message
const session = await triviaService.startTrivia( const { components, flags } = getTriviaQuestionView(session, interaction.user.username);
interaction.user.id,
interaction.user.username, // Reply with Components v2 question
categoryId ? parseInt(categoryId) : undefined await interaction.editReply({
components,
flags
});
// Set up automatic timeout cleanup
setTimeout(async () => {
const stillActive = triviaService.getSession(session.sessionId);
if (stillActive) {
// User didn't answer - clean up session with no reward
try {
await triviaService.submitAnswer(session.sessionId, interaction.user.id, false);
} catch (error) {
// Session already cleaned up, ignore
}
}
}, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period
}
); );
// Generate Components v2 message
const { components, flags } = getTriviaQuestionView(session, interaction.user.username);
// Reply with Components v2 question
await interaction.editReply({
components,
flags
});
// Set up automatic timeout cleanup
setTimeout(async () => {
const stillActive = triviaService.getSession(session.sessionId);
if (stillActive) {
// User didn't answer - clean up session with no reward
try {
await triviaService.submitAnswer(session.sessionId, interaction.user.id, false);
} catch (error) {
// Session already cleaned up, ignore
}
}
}, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period
} catch (error: any) { } catch (error: any) {
// Handle errors from the pre-defer canPlayTrivia check
if (error instanceof UserError) { if (error instanceof UserError) {
// Check if we've already deferred await interaction.reply({
if (interaction.deferred) { embeds: [createErrorEmbed(error.message)],
await interaction.editReply({ ephemeral: true
embeds: [createErrorEmbed(error.message)] });
});
} else {
await interaction.reply({
embeds: [createErrorEmbed(error.message)],
ephemeral: true
});
}
} else { } else {
console.error("Error in trivia command:", error); console.error("Error in trivia command:", error);
// Check if we've already deferred await interaction.reply({
if (interaction.deferred) { embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
await interaction.editReply({ ephemeral: true
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")] });
});
} else {
await interaction.reply({
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
ephemeral: true
});
}
} }
} }
} }

View File

@@ -4,8 +4,7 @@ import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { userService } from "@shared/modules/user/user.service"; import { userService } from "@shared/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds"; import { createErrorEmbed } from "@lib/embeds";
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view"; import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
import type { ItemUsageData } from "@shared/lib/types"; import { withCommandErrorHandling } from "@lib/commandUtils";
import { UserError } from "@shared/lib/errors";
import { getGuildConfig } from "@shared/lib/config"; import { getGuildConfig } from "@shared/lib/config";
export const use = createCommand({ export const use = createCommand({
@@ -19,57 +18,50 @@ export const use = createCommand({
.setAutocomplete(true) .setAutocomplete(true)
), ),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply(); await withCommandErrorHandling(
interaction,
async () => {
const guildConfig = await getGuildConfig(interaction.guildId!);
const colorRoles = guildConfig.colorRoles ?? [];
const guildConfig = await getGuildConfig(interaction.guildId!); const itemId = interaction.options.getNumber("item", true);
const colorRoles = guildConfig.colorRoles ?? []; const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
if (!user) {
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
return;
}
const itemId = interaction.options.getNumber("item", true); const result = await inventoryService.useItem(user.id.toString(), itemId);
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
if (!user) {
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
return;
}
try { const usageData = result.usageData;
const result = await inventoryService.useItem(user.id.toString(), itemId); if (usageData) {
for (const effect of usageData.effects) {
const usageData = result.usageData; if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') {
if (usageData) { try {
for (const effect of usageData.effects) { const member = await interaction.guild?.members.fetch(user.id.toString());
if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') { if (member) {
try { if (effect.type === 'TEMP_ROLE') {
const member = await interaction.guild?.members.fetch(user.id.toString()); await member.roles.add(effect.roleId);
if (member) { } else if (effect.type === 'COLOR_ROLE') {
if (effect.type === 'TEMP_ROLE') { // Remove existing color roles
await member.roles.add(effect.roleId); const rolesToRemove = colorRoles.filter(r => member.roles.cache.has(r));
} else if (effect.type === 'COLOR_ROLE') { if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
// Remove existing color roles await member.roles.add(effect.roleId);
const rolesToRemove = colorRoles.filter(r => member.roles.cache.has(r)); }
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
await member.roles.add(effect.roleId);
} }
} catch (e) {
console.error("Failed to assign role in /use command:", e);
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
} }
} catch (e) {
console.error("Failed to assign role in /use command:", e);
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
} }
} }
} }
const { embed, files } = getItemUseResultEmbed(result.results, result.item);
await interaction.editReply({ embeds: [embed], files });
} }
);
const { embed, files } = getItemUseResultEmbed(result.results, result.item);
await interaction.editReply({ embeds: [embed], files });
} catch (error: any) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
console.error("Error using item:", error);
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred while using the item.")] });
}
}
}, },
autocomplete: async (interaction) => { autocomplete: async (interaction) => {
const focusedValue = interaction.options.getFocused(); const focusedValue = interaction.options.getFocused();

View File

@@ -3,7 +3,7 @@ import { env } from "@shared/lib/env";
import { join } from "node:path"; import { join } from "node:path";
import { initializeConfig } from "@shared/lib/config"; import { initializeConfig } from "@shared/lib/config";
import { startWebServerFromRoot } from "../web/src/server"; import { startWebServerFromRoot } from "../api/src/server";
// Initialize config from database // Initialize config from database
await initializeConfig(); await initializeConfig();
@@ -18,7 +18,7 @@ console.log("🌐 Starting web server...");
let shuttingDown = false; let shuttingDown = false;
const webProjectPath = join(import.meta.dir, "../web"); const webProjectPath = join(import.meta.dir, "../api");
const webPort = Number(process.env.WEB_PORT) || 3000; const webPort = Number(process.env.WEB_PORT) || 3000;
const webHost = process.env.HOST || "0.0.0.0"; const webHost = process.env.HOST || "0.0.0.0";

View File

@@ -0,0 +1,147 @@
import { describe, it, expect, mock, beforeEach, spyOn } from "bun:test";
import { UserError } from "@shared/lib/errors";
// --- Mocks ---
const mockDeferReply = mock(() => Promise.resolve());
const mockEditReply = mock(() => Promise.resolve());
const mockInteraction = {
deferReply: mockDeferReply,
editReply: mockEditReply,
} as any;
const mockCreateErrorEmbed = mock((msg: string) => ({ description: msg, type: "error" }));
mock.module("./embeds", () => ({
createErrorEmbed: mockCreateErrorEmbed,
}));
// Import AFTER mocking
const { withCommandErrorHandling } = await import("./commandUtils");
// --- Tests ---
describe("withCommandErrorHandling", () => {
let consoleErrorSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
mockDeferReply.mockClear();
mockEditReply.mockClear();
mockCreateErrorEmbed.mockClear();
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => { });
});
it("should always call deferReply", async () => {
await withCommandErrorHandling(
mockInteraction,
async () => "result"
);
expect(mockDeferReply).toHaveBeenCalledTimes(1);
});
it("should pass ephemeral option to deferReply", async () => {
await withCommandErrorHandling(
mockInteraction,
async () => "result",
{ ephemeral: true }
);
expect(mockDeferReply).toHaveBeenCalledWith({ ephemeral: true });
});
it("should return the operation result on success", async () => {
const result = await withCommandErrorHandling(
mockInteraction,
async () => ({ data: "test" })
);
expect(result).toEqual({ data: "test" });
});
it("should call onSuccess with the result", async () => {
const onSuccess = mock(async (_result: string) => { });
await withCommandErrorHandling(
mockInteraction,
async () => "hello",
{ onSuccess }
);
expect(onSuccess).toHaveBeenCalledWith("hello");
});
it("should send successMessage when no onSuccess is provided", async () => {
await withCommandErrorHandling(
mockInteraction,
async () => "result",
{ successMessage: "It worked!" }
);
expect(mockEditReply).toHaveBeenCalledWith({
content: "It worked!",
});
});
it("should prefer onSuccess over successMessage", async () => {
const onSuccess = mock(async (_result: string) => { });
await withCommandErrorHandling(
mockInteraction,
async () => "result",
{ successMessage: "This should not be sent", onSuccess }
);
expect(onSuccess).toHaveBeenCalledTimes(1);
// editReply should NOT have been called with the successMessage
expect(mockEditReply).not.toHaveBeenCalledWith({
content: "This should not be sent",
});
});
it("should show error embed for UserError", async () => {
const result = await withCommandErrorHandling(
mockInteraction,
async () => {
throw new UserError("You can't do that!");
}
);
expect(result).toBeUndefined();
expect(mockCreateErrorEmbed).toHaveBeenCalledWith("You can't do that!");
expect(mockEditReply).toHaveBeenCalledTimes(1);
});
it("should show generic error and log for unexpected errors", async () => {
const unexpectedError = new Error("Database exploded");
const result = await withCommandErrorHandling(
mockInteraction,
async () => {
throw unexpectedError;
}
);
expect(result).toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Unexpected error in command:",
unexpectedError
);
expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
"An unexpected error occurred."
);
expect(mockEditReply).toHaveBeenCalledTimes(1);
});
it("should return undefined on error", async () => {
const result = await withCommandErrorHandling(
mockInteraction,
async () => {
throw new Error("fail");
}
);
expect(result).toBeUndefined();
});
});

79
bot/lib/commandUtils.ts Normal file
View File

@@ -0,0 +1,79 @@
import type { ChatInputCommandInteraction } from "discord.js";
import { UserError } from "@shared/lib/errors";
import { createErrorEmbed } from "./embeds";
/**
* Wraps a command's core logic with standardized error handling.
*
* - Calls `interaction.deferReply()` automatically
* - On success, invokes `onSuccess` callback or sends `successMessage`
* - On `UserError`, shows the error message in an error embed
* - On unexpected errors, logs to console and shows a generic error embed
*
* @example
* ```typescript
* export const myCommand = createCommand({
* execute: async (interaction) => {
* await withCommandErrorHandling(
* interaction,
* async () => {
* const result = await doSomething();
* await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
* }
* );
* }
* });
* ```
*
* @example
* ```typescript
* // With deferReply options (e.g. ephemeral)
* await withCommandErrorHandling(
* interaction,
* async () => doSomething(),
* {
* ephemeral: true,
* successMessage: "Done!",
* }
* );
* ```
*/
export async function withCommandErrorHandling<T>(
interaction: ChatInputCommandInteraction,
operation: () => Promise<T>,
options?: {
/** Message to send on success (if no onSuccess callback is provided) */
successMessage?: string;
/** Callback invoked with the operation result on success */
onSuccess?: (result: T) => Promise<void>;
/** Whether the deferred reply should be ephemeral */
ephemeral?: boolean;
}
): Promise<T | undefined> {
try {
await interaction.deferReply({ ephemeral: options?.ephemeral });
const result = await operation();
if (options?.onSuccess) {
await options.onSuccess(result);
} else if (options?.successMessage) {
await interaction.editReply({
content: options.successMessage,
});
}
return result;
} catch (error) {
if (error instanceof UserError) {
await interaction.editReply({
embeds: [createErrorEmbed(error.message)],
});
} else {
console.error("Unexpected error in command:", error);
await interaction.editReply({
embeds: [createErrorEmbed("An unexpected error occurred.")],
});
}
return undefined;
}
}

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, mock, beforeEach } from "bun:test"; import { describe, it, expect, mock, beforeEach } from "bun:test";
// Mock DrizzleClient // Mock DrizzleClient — must match the import path used in db.ts
mock.module("./DrizzleClient", () => ({ mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: { DrizzleClient: {
transaction: async (cb: any) => cb("MOCK_TX") transaction: async (cb: any) => cb("MOCK_TX")
} }

View File

@@ -1,7 +1,8 @@
import { levelingService } from "@shared/modules/leveling/leveling.service"; import { levelingService } from "@shared/modules/leveling/leveling.service";
import { economyService } from "@shared/modules/economy/economy.service"; import { economyService } from "@shared/modules/economy/economy.service";
import { userTimers } from "@db/schema"; import { userTimers } from "@db/schema";
import type { EffectHandler } from "./effect.types"; import type { EffectHandler, ValidatedEffectPayload } from "./effect.types";
import { EffectType } from "@shared/lib/constants";
import type { LootTableItem } from "@shared/lib/types"; import type { LootTableItem } from "@shared/lib/types";
import { inventoryService } from "@shared/modules/inventory/inventory.service"; import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { inventory, items } from "@db/schema"; import { inventory, items } from "@db/schema";
@@ -15,21 +16,21 @@ const getDuration = (effect: any): number => {
return effect.durationSeconds || 60; // Default to 60s if nothing provided return effect.durationSeconds || 60; // Default to 60s if nothing provided
}; };
export const handleAddXp: EffectHandler = async (userId, effect, txFn) => { export const handleAddXp: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.ADD_XP }>, txFn) => {
await levelingService.addXp(userId, BigInt(effect.amount), txFn); await levelingService.addXp(userId, BigInt(effect.amount), txFn);
return `Gained ${effect.amount} XP`; return `Gained ${effect.amount} XP`;
}; };
export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => { export const handleAddBalance: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.ADD_BALANCE }>, txFn) => {
await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn); await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
return `Gained ${effect.amount} 🪙`; return `Gained ${effect.amount} 🪙`;
}; };
export const handleReplyMessage: EffectHandler = async (_userId, effect, _txFn) => { export const handleReplyMessage: EffectHandler = async (_userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.REPLY_MESSAGE }>, _txFn) => {
return effect.message; return effect.message;
}; };
export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => { export const handleXpBoost: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.XP_BOOST }>, txFn) => {
const boostDuration = getDuration(effect); const boostDuration = getDuration(effect);
const expiresAt = new Date(Date.now() + boostDuration * 1000); const expiresAt = new Date(Date.now() + boostDuration * 1000);
await txFn.insert(userTimers).values({ await txFn.insert(userTimers).values({
@@ -45,7 +46,7 @@ export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
return `XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`; return `XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`;
}; };
export const handleTempRole: EffectHandler = async (userId, effect, txFn) => { export const handleTempRole: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.TEMP_ROLE }>, txFn) => {
const roleDuration = getDuration(effect); const roleDuration = getDuration(effect);
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000); const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
await txFn.insert(userTimers).values({ await txFn.insert(userTimers).values({
@@ -62,11 +63,11 @@ export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
return `Temporary Role granted for ${Math.floor(roleDuration / 60)}m`; return `Temporary Role granted for ${Math.floor(roleDuration / 60)}m`;
}; };
export const handleColorRole: EffectHandler = async (_userId, _effect, _txFn) => { export const handleColorRole: EffectHandler = async (_userId, _effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.COLOR_ROLE }>, _txFn) => {
return "Color Role Equipped"; return "Color Role Equipped";
}; };
export const handleLootbox: EffectHandler = async (userId, effect, txFn) => { export const handleLootbox: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.LOOTBOX }>, txFn) => {
const pool = effect.pool as LootTableItem[]; const pool = effect.pool as LootTableItem[];
if (!pool || pool.length === 0) return "The box is empty..."; if (!pool || pool.length === 0) return "The box is empty...";

View File

@@ -7,7 +7,10 @@ import {
handleColorRole, handleColorRole,
handleLootbox handleLootbox
} from "./effect.handlers"; } from "./effect.handlers";
import type { EffectHandler } from "./effect.types"; import type { EffectHandler, ValidatedEffectPayload } from "./effect.types";
import { EffectPayloadSchema } from "./effect.types";
import { UserError } from "@shared/lib/errors";
import type { Transaction } from "@shared/lib/types";
export const effectHandlers: Record<string, EffectHandler> = { export const effectHandlers: Record<string, EffectHandler> = {
'ADD_XP': handleAddXp, 'ADD_XP': handleAddXp,
@@ -18,3 +21,21 @@ export const effectHandlers: Record<string, EffectHandler> = {
'COLOR_ROLE': handleColorRole, 'COLOR_ROLE': handleColorRole,
'LOOTBOX': handleLootbox 'LOOTBOX': handleLootbox
}; };
export async function validateAndExecuteEffect(
effect: unknown,
userId: string,
tx: Transaction
) {
const result = EffectPayloadSchema.safeParse(effect);
if (!result.success) {
throw new UserError(`Invalid effect configuration: ${result.error.message}`);
}
const handler = effectHandlers[result.data.type];
if (!handler) {
throw new UserError(`Unknown effect type: ${result.data.type}`);
}
return handler(userId, result.data, tx);
}

View File

@@ -1,3 +1,71 @@
import type { Transaction } from "@shared/lib/types"; import type { Transaction } from "@shared/lib/types";
import { z } from "zod";
import { EffectType, LootType } from "@shared/lib/constants";
// Helper Schemas
const LootTableItemSchema = z.object({
type: z.nativeEnum(LootType),
weight: z.number(),
amount: z.number().optional(),
itemId: z.number().optional(),
minAmount: z.number().optional(),
maxAmount: z.number().optional(),
message: z.string().optional(),
});
const DurationSchema = z.object({
durationSeconds: z.number().optional(),
durationMinutes: z.number().optional(),
durationHours: z.number().optional(),
});
// Effect Schemas
const AddXpSchema = z.object({
type: z.literal(EffectType.ADD_XP),
amount: z.number().positive(),
});
const AddBalanceSchema = z.object({
type: z.literal(EffectType.ADD_BALANCE),
amount: z.number(),
});
const ReplyMessageSchema = z.object({
type: z.literal(EffectType.REPLY_MESSAGE),
message: z.string(),
});
const XpBoostSchema = DurationSchema.extend({
type: z.literal(EffectType.XP_BOOST),
multiplier: z.number(),
});
const TempRoleSchema = DurationSchema.extend({
type: z.literal(EffectType.TEMP_ROLE),
roleId: z.string(),
});
const ColorRoleSchema = z.object({
type: z.literal(EffectType.COLOR_ROLE),
roleId: z.string(),
});
const LootboxSchema = z.object({
type: z.literal(EffectType.LOOTBOX),
pool: z.array(LootTableItemSchema),
});
// Union Schema
export const EffectPayloadSchema = z.discriminatedUnion('type', [
AddXpSchema,
AddBalanceSchema,
ReplyMessageSchema,
XpBoostSchema,
TempRoleSchema,
ColorRoleSchema,
LootboxSchema,
]);
export type ValidatedEffectPayload = z.infer<typeof EffectPayloadSchema>;
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<any>; export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<any>;

345
bun.lock
View File

@@ -20,8 +20,71 @@
"typescript": "^5.9.3", "typescript": "^5.9.3",
}, },
}, },
"panel": {
"name": "panel",
"version": "0.1.0",
"dependencies": {
"@imgly/background-removal": "^1.7.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.564.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
},
"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",
"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 +155,18 @@
"@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=="],
"@imgly/background-removal": ["@imgly/background-removal@1.7.0", "", { "dependencies": { "lodash-es": "^4.17.21", "ndarray": "~1.0.0", "zod": "^3.23.8" }, "peerDependencies": { "onnxruntime-web": "1.21.0" } }, "sha512-/1ZryrMYg2ckIvJKoTu5Np50JfYMVffDMlVmppw/BdbN3pBTN7e6stI5/7E/LVh9DDzz6J588s7sWqul3fy5wA=="],
"@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 +191,162 @@
"@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=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@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=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"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 +357,144 @@
"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=="],
"flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="],
"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=="],
"guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="],
"iota-array": ["iota-array@1.0.0", "", {}, "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA=="],
"is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="],
"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-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="],
"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=="],
"ndarray": ["ndarray@1.0.19", "", { "dependencies": { "iota-array": "^1.0.0", "is-buffer": "^1.0.2" } }, "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"onnxruntime-common": ["onnxruntime-common@1.21.0", "", {}, "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ=="],
"onnxruntime-web": ["onnxruntime-web@1.21.0", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.21.0", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-adzOe+7uI7lKz6pQNbAsLMQd2Fq5Jhmoxd8LZjJr8m3KvbFyiYyRxRiC57/XXD+jb18voppjeGAjoZmskXG+7A=="],
"panel": ["panel@workspace:panel"],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
"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=="],
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
"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=="],
"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=="],
"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=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
"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 +505,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 +521,20 @@
"@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=="],
"@imgly/background-removal/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],

View File

@@ -34,9 +34,11 @@ services:
restart: unless-stopped restart: unless-stopped
build: build:
context: . context: .
dockerfile: Dockerfile.prod dockerfile: Dockerfile
target: production target: production
image: aurora-app:latest image: aurora-app:latest
volumes:
- ./bot/assets/graphics/items:/app/bot/assets/graphics/items
ports: ports:
- "127.0.0.1:3000:3000" - "127.0.0.1:3000:3000"
@@ -53,6 +55,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:-https://aurora.syntaxbullet.com}
depends_on: depends_on:
db: db:
@@ -76,6 +82,8 @@ services:
studio: studio:
container_name: aurora_studio container_name: aurora_studio
image: aurora-app:latest image: aurora-app:latest
volumes:
- ./bot/assets/graphics/items:/app/bot/assets/graphics/items
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
db: db:
@@ -90,6 +98,10 @@ services:
- DB_PORT=5432 - DB_PORT=5432
- DB_HOST=db - DB_HOST=db
- 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:-https://aurora.syntaxbullet.com}
networks: networks:
- internal - internal
- web - web

View File

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

View File

@@ -0,0 +1,769 @@
# Aurora Admin Panel - Design Guidelines
## Design Philosophy
The Aurora Admin Panel embodies the intersection of celestial mystique and institutional precision. It is a command center for academy administration—powerful, sophisticated, and unmistakably authoritative. Every interface element should communicate control, clarity, and prestige.
**Core Principles:**
- **Authority over Friendliness**: This is an administrative tool, not a consumer app
- **Data Clarity**: Information density balanced with elegant presentation
- **Celestial Aesthetic**: Subtle cosmic theming that doesn't compromise functionality
- **Institutional Grade**: Professional, trustworthy, built to manage complex systems
---
## Visual Foundation
### Color System
**Background Hierarchy**
```
Level 0 (Base) #0A0A0F Eclipse Void - Deepest background
Level 1 (Container) #151520 Midnight Canvas - Cards, panels, modals
Level 2 (Surface) #1E1B4B Nebula Surface - Elevated elements
Level 3 (Raised) #2D2A5F Stellar Overlay - Hover states, dropdowns
```
**Text Hierarchy**
```
Primary Text #F9FAFB Starlight White - Headings, key data
Secondary Text #E5E7EB Stardust Silver - Body text, labels
Tertiary Text #9CA3AF Cosmic Gray - Helper text, timestamps
Disabled Text #6B7280 Void Gray - Inactive elements
```
**Brand Accents**
```
Primary (Action) #8B5CF6 Aurora Purple - Primary buttons, links, active states
Secondary (Info) #3B82F6 Nebula Blue - Informational elements
Success #10B981 Emerald - Confirmations, positive indicators
Warning #F59E0B Amber - Cautions, alerts
Danger #DC2626 Crimson - Errors, destructive actions
Gold (Prestige) #FCD34D Celestial Gold - Premium features, highlights
```
**Constellation Tier Colors** (for data visualization)
```
Constellation A #FCD34D Celestial Gold
Constellation B #8B5CF6 Aurora Purple
Constellation C #3B82F6 Nebula Blue
Constellation D #6B7280 Slate Gray
```
**Semantic Colors**
```
Currency (AU) #FCD34D Gold - Astral Units indicators
Currency (CU) #8B5CF6 Purple - Constellation Units indicators
XP/Progress #3B82F6 Blue - Experience and progression
Activity #10B981 Green - Active users, live events
```
### Typography
**Font Stack**
Primary (UI Text):
```css
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
```
- Clean, highly legible, modern
- Excellent at small sizes for data-dense interfaces
- Professional without being sterile
Display (Headings):
```css
font-family: 'Space Grotesk', 'Inter', sans-serif;
```
- Geometric, slightly futuristic
- Use for page titles, section headers
- Reinforces celestial/institutional theme
Monospace (Data):
```css
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
```
- For numerical data, timestamps, IDs
- Improves scanability of tabular data
- Technical credibility
**Type Scale**
```
Display Large 48px / 3rem font-weight: 700 (Dashboard headers)
Display 36px / 2.25rem font-weight: 700 (Page titles)
Heading 1 30px / 1.875rem font-weight: 600 (Section titles)
Heading 2 24px / 1.5rem font-weight: 600 (Card headers)
Heading 3 20px / 1.25rem font-weight: 600 (Subsections)
Body Large 16px / 1rem font-weight: 400 (Emphasized body)
Body 14px / 0.875rem font-weight: 400 (Default text)
Body Small 13px / 0.8125rem font-weight: 400 (Secondary info)
Caption 12px / 0.75rem font-weight: 400 (Labels, hints)
Overline 11px / 0.6875rem font-weight: 600 (Uppercase labels)
```
**Font Weight Usage**
- **700 (Bold)**: Display text, critical metrics
- **600 (Semibold)**: Headings, emphasized data
- **500 (Medium)**: Buttons, active tabs, selected items
- **400 (Regular)**: Body text, form inputs
- **Never use weights below 400** - maintain readability
### Spacing & Layout
**Base Unit**: 4px
**Spacing Scale**
```
xs 4px 0.25rem Tight spacing, icon gaps
sm 8px 0.5rem Form element spacing
md 16px 1rem Default component spacing
lg 24px 1.5rem Section spacing
xl 32px 2rem Major section breaks
2xl 48px 3rem Page section dividers
3xl 64px 4rem Major layout divisions
```
**Container Widths**
```
Full Bleed 100% Full viewport width
Wide 1600px Wide dashboards, data tables
Standard 1280px Default content width
Narrow 960px Forms, focused content
Reading 720px Long-form text (documentation)
```
**Grid System**
- 12-column grid for flexible layouts
- 24px gutters between columns
- Responsive breakpoints: 640px, 768px, 1024px, 1280px, 1536px
### Borders & Dividers
**Border Widths**
```
Hairline 0.5px Subtle dividers
Thin 1px Default borders
Medium 2px Emphasized borders, focus states
Thick 4px Accent bars, category indicators
```
**Border Colors**
```
Default #2D2A5F 15% opacity - Standard dividers
Subtle #2D2A5F 8% opacity - Very light separation
Emphasized #8B5CF6 30% opacity - Highlighted borders
Interactive #8B5CF6 60% opacity - Hover/focus states
```
**Border Radius**
```
None 0px Data tables, strict layouts
sm 4px Buttons, badges, pills
md 8px Cards, inputs, panels
lg 12px Large cards, modals
xl 16px Feature cards, images
2xl 24px Hero elements
full 9999px Circular elements, avatars
```
---
## UI Patterns
### Cards & Containers
**Standard Card**
```
Background: #151520 (Midnight Canvas)
Border: 1px solid rgba(139, 92, 246, 0.15)
Border Radius: 8px
Padding: 24px
Shadow: 0 4px 16px rgba(0, 0, 0, 0.4)
```
**Elevated Card** (hover/focus)
```
Background: #1E1B4B (Nebula Surface)
Border: 1px solid rgba(139, 92, 246, 0.3)
Shadow: 0 8px 24px rgba(0, 0, 0, 0.6)
Transform: translateY(-2px)
Transition: all 200ms ease
```
**Stat Card** (metrics, KPIs)
```
Background: Linear gradient from #151520 to #1E1B4B
Border: 1px solid rgba(139, 92, 246, 0.2)
Accent Border: 4px left border in tier/category color
Icon: Celestial icon in accent color
Typography: Large number (Display), small label (Overline)
```
### Data Tables
**Table Structure**
```
Header Background: #1E1B4B
Header Text: #E5E7EB, 11px uppercase, 600 weight
Row Background: Alternating #0A0A0F / #151520
Row Hover: #2D2A5F with 40% opacity
Border: 1px solid rgba(139, 92, 246, 0.1) between rows
Cell Padding: 12px 16px
```
**Column Styling**
- Left-align text columns
- Right-align numerical columns
- Monospace font for numbers, IDs, timestamps
- Icon + text combinations for status indicators
**Interactive Elements**
- Sortable headers with subtle arrow icons
- Hover state on entire row
- Click/select highlight with Aurora Purple tint
- Pagination in Nebula Blue
### Forms & Inputs
**Input Fields**
```
Background: #1E1B4B
Border: 1px solid rgba(139, 92, 246, 0.2)
Border Radius: 6px
Padding: 10px 14px
Font Size: 14px
Text Color: #F9FAFB
Focus State:
Border: 2px solid #8B5CF6
Glow: 0 0 0 3px rgba(139, 92, 246, 0.2)
Error State:
Border: 1px solid #DC2626
Text: #DC2626 helper text below
Disabled State:
Background: #0A0A0F
Text: #6B7280
Cursor: not-allowed
```
**Labels**
```
Font Size: 12px
Font Weight: 600
Text Color: #E5E7EB
Margin Bottom: 6px
```
**Select Dropdowns**
```
Same base styling as inputs
Dropdown Icon: Chevron in #9CA3AF
Menu Background: #2D2A5F
Menu Border: 1px solid rgba(139, 92, 246, 0.3)
Option Hover: #3B82F6 background
Selected: #8B5CF6 with checkmark icon
```
**Checkboxes & Radio Buttons**
```
Size: 18px × 18px
Border: 2px solid rgba(139, 92, 246, 0.4)
Border Radius: 4px (checkbox) / 50% (radio)
Checked: #8B5CF6 background with white checkmark
Hover: Glow effect rgba(139, 92, 246, 0.2)
```
### Buttons
**Primary Button**
```
Background: #8B5CF6 (Aurora Purple)
Text: #FFFFFF
Padding: 10px 20px
Border Radius: 6px
Font Weight: 500
Shadow: 0 2px 8px rgba(139, 92, 246, 0.3)
Hover:
Background: #7C3AED (lighter purple)
Shadow: 0 4px 12px rgba(139, 92, 246, 0.4)
Active:
Background: #6D28D9 (darker purple)
Transform: scale(0.98)
```
**Secondary Button**
```
Background: transparent
Border: 1px solid rgba(139, 92, 246, 0.5)
Text: #8B5CF6
Padding: 10px 20px
Hover:
Background: rgba(139, 92, 246, 0.1)
Border: 1px solid #8B5CF6
```
**Destructive Button**
```
Background: #DC2626
Text: #FFFFFF
(Same structure as Primary)
```
**Ghost Button**
```
Background: transparent
Text: #E5E7EB
Padding: 8px 16px
Hover:
Background: rgba(139, 92, 246, 0.1)
Text: #8B5CF6
```
**Button Sizes**
```
Small 8px 12px 12px text
Medium 10px 20px 14px text (default)
Large 12px 24px 16px text
```
### Navigation
**Sidebar Navigation**
```
Background: #0A0A0F with subtle gradient
Width: 260px (expanded) / 64px (collapsed)
Border Right: 1px solid rgba(139, 92, 246, 0.15)
Nav Item:
Padding: 12px 16px
Border Radius: 6px
Font Size: 14px
Font Weight: 500
Icon Size: 20px
Gap: 12px between icon and text
Active State:
Background: rgba(139, 92, 246, 0.15)
Border Left: 4px solid #8B5CF6
Text: #8B5CF6
Icon: #8B5CF6
Hover State:
Background: rgba(139, 92, 246, 0.08)
Text: #F9FAFB
```
**Top Bar / Header**
```
Background: #0A0A0F with backdrop blur
Height: 64px
Border Bottom: 1px solid rgba(139, 92, 246, 0.15)
Position: Sticky
Z-index: 100
Contains:
- Logo / Academy name
- Global search
- Quick actions
- User profile dropdown
- Notification bell
```
**Breadcrumbs**
```
Font Size: 13px
Text Color: #9CA3AF
Separator: "/" or "" in #6B7280
Current Page: #F9FAFB, 600 weight
Links: #9CA3AF, hover to #8B5CF6
```
### Modals & Overlays
**Modal Structure**
```
Backdrop: rgba(0, 0, 0, 0.8) with backdrop blur
Modal Container: #151520
Border: 1px solid rgba(139, 92, 246, 0.2)
Border Radius: 12px
Shadow: 0 24px 48px rgba(0, 0, 0, 0.9)
Max Width: 600px (standard) / 900px (wide)
Padding: 32px
Header:
Border Bottom: 1px solid rgba(139, 92, 246, 0.15)
Padding: 0 0 20px 0
Font Size: 24px
Font Weight: 600
Footer:
Border Top: 1px solid rgba(139, 92, 246, 0.15)
Padding: 20px 0 0 0
Buttons: Right-aligned, 12px gap
```
**Toast Notifications**
```
Position: Top-right, 24px margin
Background: #2D2A5F
Border: 1px solid (color based on type)
Border Radius: 8px
Padding: 16px 20px
Max Width: 400px
Shadow: 0 8px 24px rgba(0, 0, 0, 0.6)
Success: #10B981 border, green icon
Warning: #F59E0B border, amber icon
Error: #DC2626 border, red icon
Info: #3B82F6 border, blue icon
Animation: Slide in from right, fade out
Duration: 4 seconds (dismissible)
```
### Data Visualization
**Charts & Graphs**
```
Background: #151520 or transparent
Grid Lines: rgba(139, 92, 246, 0.1)
Axis Labels: #9CA3AF, 12px
Data Points: Constellation tier colors or semantic colors
Tooltips: #2D2A5F background, white text
Legend: Horizontal, 12px, icons + labels
```
**Progress Bars**
```
Track: #1E1B4B
Fill: Linear gradient with tier/category color
Height: 8px (thin) / 12px (medium) / 16px (thick)
Border Radius: 9999px
Label: Above or inline, monospace numbers
```
**Badges & Pills**
```
Background: Semantic color with 15% opacity
Text: Semantic color (full saturation)
Border: 1px solid semantic color with 30% opacity
Padding: 4px 10px
Border Radius: 9999px
Font Size: 12px
Font Weight: 500
Status Examples:
Active: Green
Pending: Amber
Inactive: Gray
Error: Red
Premium: Gold
```
### Icons
**Icon System**
- Use consistent icon family (e.g., Lucide, Heroicons, Phosphor)
- Line-style icons, not filled (except for active states)
- Stroke width: 1.5px-2px
- Sizes: 16px (small), 20px (default), 24px (large), 32px (extra large)
**Icon Colors**
- Default: #9CA3AF (Cosmic Gray)
- Active/Selected: #8B5CF6 (Aurora Purple)
- Success: #10B981
- Warning: #F59E0B
- Error: #DC2626
**Celestial Icon Themes**
- Stars, constellations, orbits for branding
- Minimalist, geometric line art
- Avoid overly detailed or realistic astronomy images
---
## Animation & Motion
### Principles
- **Purposeful**: Animations guide attention and provide feedback
- **Subtle**: No distracting or excessive motion
- **Fast**: Snappy interactions (150-300ms)
- **Professional**: Ease curves that feel polished
### Timing Functions
```
ease-out Default for most interactions
ease-in-out Modal/panel transitions
ease-in Exit animations
spring Micro-interactions (subtle bounce)
```
### Standard Durations
```
Instant 0ms State changes
Fast 150ms Button hover, color changes
Standard 200ms Card hover, dropdown open
Moderate 300ms Modal open, page transitions
Slow 500ms Large panel animations
```
### Common Animations
**Hover Effects**
```css
transition: all 200ms ease-out;
transform: translateY(-2px);
box-shadow: [enhanced shadow];
```
**Focus States**
```css
transition: border 150ms ease-out, box-shadow 150ms ease-out;
border-color: #8B5CF6;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.2);
```
**Loading States**
```
Skeleton: Shimmer effect from left to right
Spinner: Rotating celestial icon or ring
Progress: Smooth bar fill with easing
```
**Page Transitions**
```
Fade in: Opacity 0 → 1 over 200ms
Slide up: TranslateY(20px) → 0 over 300ms
Blur fade: Blur + opacity for backdrop
```
---
## Responsive Behavior
### Breakpoints
```
Mobile < 640px
Tablet 640px - 1024px
Desktop > 1024px
Wide Desktop > 1536px
```
### Mobile Adaptations
- Sidebar collapses to hamburger menu
- Cards stack vertically
- Tables become horizontally scrollable or convert to card view
- Reduce padding and spacing by 25-50%
- Larger touch targets (minimum 44px)
- Bottom navigation for primary actions
### Tablet Optimizations
- Hybrid layouts (sidebar can be toggled)
- Adaptive grid (4 columns → 2 columns)
- Touch-friendly sizing maintained
- Utilize available space efficiently
---
## Accessibility
### Color Contrast
- Maintain WCAG AA standards minimum (4.5:1 for normal text)
- Critical actions and text meet AAA standards (7:1)
- Never rely on color alone for information
### Focus Indicators
- Always visible focus states
- 2px Aurora Purple outline with 3px glow
- Logical tab order follows visual hierarchy
### Screen Readers
- Semantic HTML structure
- ARIA labels for icon-only buttons
- Status messages announced appropriately
- Table headers properly associated
### Keyboard Navigation
- All interactive elements accessible via keyboard
- Modal traps focus within itself
- Escape key closes overlays
- Arrow keys for navigation where appropriate
---
## Dark Mode Philosophy
**Aurora Admin is dark-first by design.** The interface assumes a dark environment and doesn't offer a light mode toggle. This decision is intentional:
- **Focus**: Dark reduces eye strain during extended admin sessions
- **Data Emphasis**: Light text on dark makes numbers/data more prominent
- **Celestial Theme**: Dark backgrounds reinforce the cosmic aesthetic
- **Professional**: Dark UIs feel more serious and technical
If light mode is ever required, avoid pure white—use off-white (#F9FAFB) backgrounds with careful contrast management.
---
## Theming & Customization
### Constellation Tier Theming
When displaying constellation-specific data:
- Use tier colors for accents, not backgrounds
- Apply colors to borders, icons, badges
- Maintain readability—don't overwhelm with color
### Admin Privilege Levels
Different admin roles can have subtle UI indicators:
- Super Admin: Gold accents
- Moderator: Purple accents
- Viewer: Blue accents
These are subtle hints, not dominant visual themes.
---
## Component Library Standards
### Consistency
- Reuse components extensively
- Maintain consistent spacing, sizing, behavior
- Document component variants clearly
- Avoid one-off custom elements
### Composability
- Build complex UIs from simple components
- Components should work together seamlessly
- Predictable prop APIs
- Flexible but not overly configurable
### Performance
- Lazy load heavy components
- Virtualize long lists
- Optimize re-renders
- Compress and cache assets
---
## Code Style (UI Framework Agnostic)
### Class Naming
Use clear, semantic names:
```
.card-stat Not .cs or .c1
.button-primary Not .btn-p or .bp
.table-header Not .th or .t-h
```
### Component Organization
```
/components
/ui Base components (buttons, inputs)
/layout Layout components (sidebar, header)
/data Data components (tables, charts)
/feedback Toasts, modals, alerts
/forms Form-specific components
```
### Style Organization
- Variables/tokens for all design values
- No magic numbers in components
- DRY—reuse common styles
- Mobile-first responsive approach
---
## Best Practices
### Do's ✓
- Use established patterns from these guidelines
- Maintain consistent spacing throughout
- Prioritize data clarity and scannability
- Test with real data, not lorem ipsum
- Consider loading and empty states
- Provide clear feedback for all actions
- Use progressive disclosure for complex features
### Don'ts ✗
- Don't use bright, saturated colors outside defined palette
- Don't create custom components when standard ones exist
- Don't sacrifice accessibility for aesthetics
- Don't use decorative animations that distract
- Don't hide critical actions in nested menus
- Don't use tiny fonts (below 12px) for functional text
- Don't ignore error states and edge cases
---
## Quality Checklist
Before considering any UI complete:
**Visual**
- [ ] Colors match defined palette exactly
- [ ] Spacing uses the 4px grid system
- [ ] Typography follows scale and hierarchy
- [ ] Borders and shadows are consistent
- [ ] Icons are properly sized and aligned
**Interaction**
- [ ] Hover states are defined for all interactive elements
- [ ] Focus states are visible and clear
- [ ] Loading states prevent user confusion
- [ ] Success/error feedback is immediate
- [ ] Animations are smooth and purposeful
**Responsive**
- [ ] Layout adapts to mobile, tablet, desktop
- [ ] Touch targets are minimum 44px on mobile
- [ ] Text remains readable at all sizes
- [ ] No horizontal scrolling (except intentional)
**Accessibility**
- [ ] Keyboard navigation works completely
- [ ] Focus indicators are always visible
- [ ] Color contrast meets WCAG AA minimum
- [ ] ARIA labels present where needed
- [ ] Screen reader tested for critical flows
**Data**
- [ ] Empty states are handled gracefully
- [ ] Error states provide actionable guidance
- [ ] Large datasets perform well
- [ ] Loading states prevent layout shift
---
## Reference Assets
### Suggested Icon Library
- **Lucide Icons**: Clean, consistent, extensive
- **Heroicons**: Tailwind-friendly, well-designed
- **Phosphor Icons**: Flexible weights and styles
### Font Resources
- **Inter**: [Google Fonts](https://fonts.google.com/specimen/Inter)
- **Space Grotesk**: [Google Fonts](https://fonts.google.com/specimen/Space+Grotesk)
- **JetBrains Mono**: [JetBrains](https://www.jetbrains.com/lp/mono/)
### Design Tools
- Use component libraries: shadcn/ui, Headless UI, Radix
- Tailwind CSS for utility-first styling
- CSS variables for theming
- Design tokens for consistency
---
## Conclusion
The Aurora Admin Panel is a sophisticated tool that demands respect through its design. Every pixel serves a purpose—whether to inform, to guide, or to reinforce the prestige of the academy it administers.
**Design with authority. Build with precision. Maintain the standard.**
---
*These design guidelines are living documentation. As Aurora evolves, so too should these standards. Propose updates through the standard development workflow.*

View File

@@ -4,6 +4,7 @@
"module": "bot/index.ts", "module": "bot/index.ts",
"type": "module", "type": "module",
"private": true, "private": true,
"workspaces": ["panel"],
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"drizzle-kit": "^0.31.8" "drizzle-kit": "^0.31.8"
@@ -12,19 +13,29 @@
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
"scripts": { "scripts": {
"generate": "docker compose run --rm app drizzle-kit generate", "dev": "bun --watch bot/index.ts",
"migrate": "docker compose run --rm app drizzle-kit migrate", "logs": "bash shared/scripts/logs.sh",
"remote": "bash shared/scripts/remote.sh",
"db:generate": "docker compose run --rm app drizzle-kit generate",
"db:migrate": "docker compose run --rm app drizzle-kit migrate",
"generate": "bun run db:generate",
"migrate": "bun run db:migrate",
"db:push": "docker compose run --rm app drizzle-kit push", "db:push": "docker compose run --rm app drizzle-kit push",
"db:push:local": "drizzle-kit push", "db:push:local": "drizzle-kit push",
"dev": "bun --watch bot/index.ts",
"db:studio": "drizzle-kit studio --port 4983 --host 0.0.0.0", "db:studio": "drizzle-kit studio --port 4983 --host 0.0.0.0",
"db:backup": "bash shared/scripts/db-backup.sh",
"db:restore": "bash shared/scripts/db-restore.sh",
"db:migrate-config": "docker compose run --rm app bun shared/scripts/migrate-config-to-db.ts", "db:migrate-config": "docker compose run --rm app bun shared/scripts/migrate-config-to-db.ts",
"db:migrate-game-config": "docker compose run --rm app bun shared/scripts/migrate-game-settings-to-db.ts", "db:migrate-game-config": "docker compose run --rm app bun shared/scripts/migrate-game-settings-to-db.ts",
"db:migrate-all": "docker compose run --rm app sh -c 'bun shared/scripts/migrate-config-to-db.ts && bun shared/scripts/migrate-game-settings-to-db.ts'", "db:migrate-all": "docker compose run --rm app sh -c 'bun shared/scripts/migrate-config-to-db.ts && bun shared/scripts/migrate-game-settings-to-db.ts'",
"remote": "bash shared/scripts/remote.sh", "test": "bash shared/scripts/test-sequential.sh",
"logs": "bash shared/scripts/logs.sh", "test:ci": "bash shared/scripts/test-sequential.sh --integration",
"db:backup": "bash shared/scripts/db-backup.sh", "test:simulate-ci": "bash shared/scripts/simulate-ci.sh",
"test": "bun test", "panel:dev": "cd panel && bun run dev",
"panel:build": "cd panel && bun run build",
"deploy": "bash shared/scripts/deploy.sh",
"deploy:remote": "bash shared/scripts/deploy-remote.sh",
"setup-server": "bash shared/scripts/setup-server.sh",
"docker:cleanup": "bash shared/scripts/docker-cleanup.sh" "docker:cleanup": "bash shared/scripts/docker-cleanup.sh"
}, },
"dependencies": { "dependencies": {
@@ -35,4 +46,4 @@
"postgres": "^3.4.8", "postgres": "^3.4.8",
"zod": "^4.3.6" "zod": "^4.3.6"
} }
} }

12
panel/index.html Normal file
View File

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

32
panel/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "panel",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@imgly/background-removal": "^1.7.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.564.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
},
"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",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.10",
"typescript": "^5.9.3",
"vite": "^6.3.5"
}
}

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

@@ -0,0 +1,91 @@
import { useState } from "react";
import { useAuth } from "./lib/useAuth";
import { Loader2 } from "lucide-react";
import Layout, { type Page } from "./components/Layout";
import Dashboard from "./pages/Dashboard";
import Settings from "./pages/Settings";
import Users from "./pages/Users";
import Items from "./pages/Items";
import PlaceholderPage from "./pages/PlaceholderPage";
const placeholders: Record<string, { title: string; description: string }> = {
users: {
title: "Users",
description: "Search, view, and manage user accounts, balances, XP, levels, and inventories.",
},
items: {
title: "Items",
description: "Create, edit, and manage game items with icons, rarities, and pricing.",
},
classes: {
title: "Classes",
description: "Manage academy classes, assign Discord roles, and track class balances.",
},
quests: {
title: "Quests",
description: "Configure quests with trigger events, targets, and XP/balance rewards.",
},
lootdrops: {
title: "Lootdrops",
description: "View active lootdrops, spawn new drops, and manage lootdrop history.",
},
moderation: {
title: "Moderation",
description: "Review moderation cases — warnings, timeouts, kicks, bans — and manage appeals.",
},
transactions: {
title: "Transactions",
description: "Browse the economy transaction log with filtering by user, type, and date.",
},
settings: {
title: "Settings",
description: "Configure bot settings for economy, leveling, commands, and guild preferences.",
},
};
export default function App() {
const { loading, user, logout } = useAuth();
const [page, setPage] = useState<Page>("dashboard");
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!user) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center max-w-xs">
<div className="font-display font-bold text-3xl tracking-tight mb-1">Aurora</div>
<p className="text-sm text-muted-foreground mb-8">Admin Panel</p>
<a
href={`/auth/discord?return_to=${encodeURIComponent(window.location.origin + '/')}`}
className="inline-flex items-center justify-center w-full rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium hover:bg-primary/90 transition-colors"
>
Sign in with Discord
</a>
<p className="text-xs text-muted-foreground/40 mt-6">Authorized administrators only</p>
</div>
</div>
);
}
return (
<Layout user={user} logout={logout} currentPage={page} onNavigate={setPage}>
{page === "dashboard" ? (
<Dashboard />
) : page === "users" ? (
<Users />
) : page === "items" ? (
<Items />
) : page === "settings" ? (
<Settings />
) : (
<PlaceholderPage {...placeholders[page]!} />
)}
</Layout>
);
}

View File

@@ -0,0 +1,140 @@
import {
LayoutDashboard,
Users,
Package,
Shield,
Scroll,
Gift,
ArrowLeftRight,
GraduationCap,
Settings,
LogOut,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { useState } from "react";
import { cn } from "../lib/utils";
import type { AuthUser } from "../lib/useAuth";
export type Page =
| "dashboard"
| "users"
| "items"
| "classes"
| "quests"
| "lootdrops"
| "moderation"
| "transactions"
| "settings";
const navItems: { page: Page; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ page: "dashboard", label: "Dashboard", icon: LayoutDashboard },
{ page: "users", label: "Users", icon: Users },
{ page: "items", label: "Items", icon: Package },
{ page: "classes", label: "Classes", icon: GraduationCap },
{ page: "quests", label: "Quests", icon: Scroll },
{ page: "lootdrops", label: "Lootdrops", icon: Gift },
{ page: "moderation", label: "Moderation", icon: Shield },
{ page: "transactions", label: "Transactions", icon: ArrowLeftRight },
{ page: "settings", label: "Settings", icon: Settings },
];
export default function Layout({
user,
logout,
currentPage,
onNavigate,
children,
}: {
user: AuthUser;
logout: () => Promise<void>;
currentPage: Page;
onNavigate: (page: Page) => void;
children: React.ReactNode;
}) {
const [collapsed, setCollapsed] = useState(false);
const avatarUrl = user.avatar
? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`
: null;
return (
<div className="min-h-screen flex">
{/* Sidebar */}
<aside
className={cn(
"fixed inset-y-0 left-0 z-50 flex flex-col bg-background border-r border-border transition-all duration-200",
collapsed ? "w-16" : "w-60"
)}
>
{/* Logo */}
<div className="flex items-center h-16 px-4 border-b border-border">
<div className="font-display text-xl font-bold tracking-tight">
{collapsed ? "A" : "Aurora"}
</div>
</div>
{/* Nav items */}
<nav className="flex-1 py-3 px-2 space-y-1 overflow-y-auto">
{navItems.map(({ page, label, icon: Icon }) => (
<button
key={page}
onClick={() => onNavigate(page)}
className={cn(
"w-full flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors",
currentPage === page
? "bg-primary/15 text-primary border-l-4 border-primary"
: "text-text-tertiary hover:bg-primary/8 hover:text-foreground"
)}
>
<Icon className={cn("w-5 h-5 shrink-0", currentPage === page && "text-primary")} />
{!collapsed && <span>{label}</span>}
</button>
))}
</nav>
{/* User & collapse */}
<div className="border-t border-border p-3 space-y-2">
{!collapsed && (
<div className="flex items-center gap-3 px-2 py-1.5">
{avatarUrl ? (
<img src={avatarUrl} alt={user.username} className="w-8 h-8 rounded-full" />
) : (
<div className="w-8 h-8 rounded-full bg-surface flex items-center justify-center text-xs font-medium">
{user.username[0]?.toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{user.username}</div>
</div>
</div>
)}
<div className={cn("flex", collapsed ? "flex-col items-center gap-2" : "items-center justify-between px-2")}>
<button
onClick={logout}
className="inline-flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-tertiary hover:text-destructive hover:bg-destructive/10 transition-colors"
title="Sign out"
>
<LogOut className="w-4 h-4" />
{!collapsed && <span>Sign out</span>}
</button>
<button
onClick={() => setCollapsed((c) => !c)}
className="p-1.5 rounded-md text-text-tertiary hover:text-foreground hover:bg-primary/10 transition-colors"
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
</div>
</div>
</aside>
{/* Main content */}
<main className={cn("flex-1 transition-all duration-200", collapsed ? "ml-16" : "ml-60")}>
<div className="max-w-[1600px] mx-auto px-6 py-8">
{children}
</div>
</main>
</div>
);
}

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

@@ -0,0 +1,51 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@600;700&family=JetBrains+Mono:wght@400;500&display=swap');
@import "tailwindcss";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: #0A0A0F;
--color-foreground: #F9FAFB;
--color-muted: #151520;
--color-muted-foreground: #9CA3AF;
--color-border: rgba(139, 92, 246, 0.15);
--color-input: #1E1B4B;
--color-ring: #8B5CF6;
--color-primary: #8B5CF6;
--color-primary-foreground: #FFFFFF;
--color-secondary: #1E1B4B;
--color-secondary-foreground: #F9FAFB;
--color-accent: #2D2A5F;
--color-accent-foreground: #F9FAFB;
--color-destructive: #DC2626;
--color-destructive-foreground: #FFFFFF;
--color-card: #151520;
--color-card-foreground: #F9FAFB;
--color-success: #10B981;
--color-warning: #F59E0B;
--color-info: #3B82F6;
--color-gold: #FCD34D;
--color-surface: #1E1B4B;
--color-raised: #2D2A5F;
--color-text-secondary: #E5E7EB;
--color-text-tertiary: #9CA3AF;
--color-text-disabled: #6B7280;
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--font-display: 'Space Grotesk', 'Inter', sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
}
body {
background-color: var(--color-background);
color: var(--color-foreground);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
* {
border-color: var(--color-border);
}

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

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

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

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

View File

@@ -0,0 +1,51 @@
import { useState, useEffect } from "react";
import { get } from "./api";
export interface DashboardStats {
bot: { name: string; avatarUrl: string | null; status: string | null };
guilds: { count: number };
users: { active: number; total: number };
commands: { total: number; active: number; disabled: number };
ping: { avg: number };
economy: {
totalWealth: string;
avgLevel: number;
topStreak: number;
totalItems?: number;
};
recentEvents: Array<{
type: "success" | "error" | "info" | "warn";
message: string;
timestamp: string;
icon?: string;
}>;
activeLootdrops?: Array<{
rewardAmount: number;
currency: string;
createdAt: string;
expiresAt: string | null;
}>;
leaderboards?: {
topLevels: Array<{ username: string; level: number }>;
topWealth: Array<{ username: string; balance: string }>;
topNetWorth: Array<{ username: string; netWorth: string }>;
};
uptime: number;
lastCommandTimestamp: number | null;
maintenanceMode: boolean;
}
export function useDashboard() {
const [data, setData] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
get<DashboardStats>("/api/stats")
.then(setData)
.catch((e) => setError(e.error ?? "Failed to load stats"))
.finally(() => setLoading(false));
}, []);
return { data, loading, error };
}

99
panel/src/lib/useItems.ts Normal file
View File

@@ -0,0 +1,99 @@
import { useCallback, useEffect, useState } from "react";
import { get } from "./api";
export interface Item {
id: number;
name: string;
description: string | null;
type: string;
rarity: string;
price: string | null;
iconUrl: string;
imageUrl: string;
}
export interface ItemFilters {
search: string;
type: string | null;
rarity: string | null;
}
export function useItems() {
const [items, setItems] = useState<Item[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [limit, setLimit] = useState(50);
const [filters, setFiltersState] = useState<ItemFilters>({
search: "",
type: null,
rarity: null,
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchItems = useCallback(async () => {
try {
setLoading(true);
setError(null);
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.type) params.set("type", filters.type);
if (filters.rarity) params.set("rarity", filters.rarity);
params.set("limit", String(limit));
params.set("offset", String((currentPage - 1) * limit));
const data = await get<{ items: Item[]; total: number }>(
`/api/items?${params.toString()}`
);
setItems(data.items);
setTotal(data.total);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load items");
} finally {
setLoading(false);
}
}, [filters, currentPage, limit]);
const setFilters = useCallback((newFilters: Partial<ItemFilters>) => {
setFiltersState((prev) => ({ ...prev, ...newFilters }));
setCurrentPage(1);
}, []);
const setSearchDebounced = useCallback(
(() => {
let timeoutId: NodeJS.Timeout;
return (search: string) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
setFilters({ search });
}, 300);
};
})(),
[setFilters]
);
const setPage = useCallback((page: number) => {
setCurrentPage(page);
}, []);
useEffect(() => {
fetchItems();
}, [fetchItems]);
return {
items,
total,
currentPage,
limit,
setLimit,
filters,
setFilters,
setSearchDebounced,
setPage,
loading,
error,
refetch: fetchItems,
};
}

View File

@@ -0,0 +1,192 @@
import { useCallback, useEffect, useState } from "react";
import { get, post, put } from "./api";
export interface LevelingConfig {
base: number;
exponent: number;
chat: {
cooldownMs: number;
minXp: number;
maxXp: number;
};
}
export interface EconomyConfig {
daily: {
amount: string;
streakBonus: string;
weeklyBonus: string;
cooldownMs: number;
};
transfers: {
allowSelfTransfer: boolean;
minAmount: string;
};
exam: {
multMin: number;
multMax: number;
};
}
export interface InventoryConfig {
maxStackSize: string;
maxSlots: number;
}
export interface LootdropConfig {
activityWindowMs: number;
minMessages: number;
spawnChance: number;
cooldownMs: number;
reward: {
min: number;
max: number;
currency: string;
};
}
export interface TriviaConfig {
entryFee: string;
rewardMultiplier: number;
timeoutSeconds: number;
cooldownMs: number;
categories: number[];
difficulty: string;
}
export interface ModerationConfig {
prune: {
maxAmount: number;
confirmThreshold: number;
batchSize: number;
batchDelayMs: number;
};
}
export interface GameSettings {
leveling: LevelingConfig;
economy: EconomyConfig;
inventory: InventoryConfig;
lootdrop: LootdropConfig;
trivia: TriviaConfig;
moderation: ModerationConfig;
commands: Record<string, boolean>;
system: Record<string, unknown>;
}
export interface GuildSettings {
guildId: string;
configured: boolean;
studentRoleId?: string;
visitorRoleId?: string;
colorRoleIds?: string[];
welcomeChannelId?: string;
welcomeMessage?: string;
feedbackChannelId?: string;
terminalChannelId?: string;
terminalMessageId?: string;
moderationLogChannelId?: string;
moderationDmOnWarn?: boolean;
moderationAutoTimeoutThreshold?: number;
featureOverrides?: Record<string, boolean>;
}
export interface SettingsMeta {
guildId?: string;
roles: Array<{ id: string; name: string; color: string }>;
channels: Array<{ id: string; name: string; type: number }>;
commands: Array<{ name: string; category: string }>;
}
export function useSettings() {
const [settings, setSettings] = useState<GameSettings | null>(null);
const [guildSettings, setGuildSettings] = useState<GuildSettings | null>(null);
const [meta, setMeta] = useState<SettingsMeta | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchSettings = useCallback(async () => {
try {
setLoading(true);
setError(null);
const [settingsData, metaData] = await Promise.all([
get<GameSettings>("/api/settings"),
get<SettingsMeta>("/api/settings/meta"),
]);
setSettings(settingsData);
setMeta(metaData);
// Fetch guild settings if we have a guild ID
if (metaData.guildId) {
const gs = await get<GuildSettings>(
`/api/guilds/${metaData.guildId}/settings`
);
setGuildSettings(gs);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load settings");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchSettings();
}, [fetchSettings]);
const saveSettings = useCallback(
async (partial: Record<string, unknown>) => {
try {
setSaving(true);
setError(null);
await post("/api/settings", partial);
const updated = await get<GameSettings>("/api/settings");
setSettings(updated);
return true;
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save settings");
return false;
} finally {
setSaving(false);
}
},
[]
);
const saveGuildSettings = useCallback(
async (data: Partial<GuildSettings>) => {
if (!meta?.guildId) return false;
try {
setSaving(true);
setError(null);
await put(`/api/guilds/${meta.guildId}/settings`, data);
const updated = await get<GuildSettings>(
`/api/guilds/${meta.guildId}/settings`
);
setGuildSettings(updated);
return true;
} catch (e) {
setError(
e instanceof Error ? e.message : "Failed to save guild settings"
);
return false;
} finally {
setSaving(false);
}
},
[meta?.guildId]
);
return {
settings,
guildSettings,
meta,
loading,
saving,
error,
saveSettings,
saveGuildSettings,
refetch: fetchSettings,
};
}

343
panel/src/lib/useUsers.ts Normal file
View File

@@ -0,0 +1,343 @@
import { useCallback, useEffect, useState } from "react";
import { get, put, post, del } from "./api";
export interface User {
id: string;
username: string;
classId: string | null;
isActive: boolean;
balance: string;
xp: string;
level: number;
dailyStreak: number;
settings: Record<string, unknown>;
createdAt: string;
updatedAt: string;
class?: Class;
}
export interface Class {
id: string;
name: string;
description: string;
icon: string;
}
export interface Item {
id: number;
name: string;
description: string;
rarity: string;
sellPrice: string;
buyPrice: string;
}
export interface InventoryEntry {
userId: string;
itemId: number;
quantity: string;
item?: Item;
}
export interface UserFilters {
search: string;
classId: string | null;
isActive: boolean | null;
sortBy: "username" | "level" | "balance" | "xp";
sortOrder: "asc" | "desc";
}
export function useUsers() {
// User list state
const [users, setUsers] = useState<User[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [limit, setLimit] = useState(50);
// Filters
const [filters, setFiltersState] = useState<UserFilters>({
search: "",
classId: null,
isActive: null,
sortBy: "balance",
sortOrder: "desc",
});
// Detail panel state
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [userDraft, setUserDraft] = useState<Partial<User> | null>(null);
const [inventoryDraft, setInventoryDraft] = useState<InventoryEntry[]>([]);
// Reference data
const [classes, setClasses] = useState<Class[]>([]);
const [items, setItems] = useState<Item[]>([]);
// UI state
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch users with filters and pagination
const fetchUsers = useCallback(async () => {
try {
setLoading(true);
setError(null);
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.classId) params.set("classId", filters.classId);
if (filters.isActive !== null) params.set("isActive", String(filters.isActive));
params.set("sortBy", filters.sortBy);
params.set("sortOrder", filters.sortOrder);
params.set("limit", String(limit));
params.set("offset", String((currentPage - 1) * limit));
const data = await get<{ users: User[]; total: number }>(
`/api/users?${params.toString()}`
);
setUsers(data.users);
setTotal(data.total);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load users");
} finally {
setLoading(false);
}
}, [filters, currentPage, limit]);
// Fetch single user by ID
const fetchUserById = useCallback(async (id: string) => {
try {
const user = await get<User>(`/api/users/${id}`);
return user;
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load user");
return null;
}
}, []);
// Fetch classes for filter dropdown
const fetchClasses = useCallback(async () => {
try {
const data = await get<{ classes: Class[] }>("/api/classes");
setClasses(data.classes || []);
} catch (e) {
console.error("Failed to load classes:", e);
setClasses([]);
}
}, []);
// Fetch items for inventory management
const fetchItems = useCallback(async () => {
try {
const data = await get<{ items: Item[]; total: number }>("/api/items");
setItems(data.items || []);
} catch (e) {
console.error("Failed to load items:", e);
setItems([]);
}
}, []);
// Fetch user inventory
const fetchInventory = useCallback(async (userId: string) => {
try {
const data = await get<{ inventory: InventoryEntry[] }>(
`/api/users/${userId}/inventory`
);
setInventoryDraft(data.inventory);
return data.inventory;
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load inventory");
return [];
}
}, []);
// Update user
const updateUser = useCallback(async (id: string, data: Partial<User>) => {
try {
setSaving(true);
setError(null);
const result = await put<{ success: boolean; user: User }>(
`/api/users/${id}`,
data
);
return result.user;
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to update user");
return null;
} finally {
setSaving(false);
}
}, []);
// Add item to inventory
const addInventoryItem = useCallback(
async (userId: string, itemId: number, quantity: string) => {
try {
await post(`/api/users/${userId}/inventory`, { itemId, quantity });
await fetchInventory(userId);
return true;
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to add item");
return false;
}
},
[fetchInventory]
);
// Remove item from inventory
const removeInventoryItem = useCallback(
async (userId: string, itemId: number) => {
try {
await del(`/api/users/${userId}/inventory/${itemId}`);
await fetchInventory(userId);
return true;
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to remove item");
return false;
}
},
[fetchInventory]
);
// Set filters and reset to page 1
const setFilters = useCallback((newFilters: Partial<UserFilters>) => {
setFiltersState((prev) => ({ ...prev, ...newFilters }));
setCurrentPage(1);
}, []);
// Debounced search setter
const setSearchDebounced = useCallback(
(() => {
let timeoutId: NodeJS.Timeout;
return (search: string) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
setFilters({ search });
}, 300);
};
})(),
[setFilters]
);
// Navigate to page
const setPage = useCallback((page: number) => {
setCurrentPage(page);
}, []);
// Select user and open detail panel
const selectUser = useCallback(
async (user: User) => {
setSelectedUser(user);
// Fetch fresh data
const freshUser = await fetchUserById(user.id);
if (freshUser) {
setSelectedUser(freshUser);
setUserDraft(structuredClone(freshUser));
await fetchInventory(freshUser.id);
}
},
[fetchUserById, fetchInventory]
);
// Close detail panel
const closeDetail = useCallback(() => {
setSelectedUser(null);
setUserDraft(null);
setInventoryDraft([]);
}, []);
// Update draft field
const updateDraft = useCallback((field: keyof User, value: unknown) => {
setUserDraft((prev) => {
if (!prev) return null;
return { ...prev, [field]: value };
});
}, []);
// Save draft changes
const saveDraft = useCallback(async () => {
if (!selectedUser || !userDraft) return false;
const updated = await updateUser(selectedUser.id, userDraft);
if (updated) {
setSelectedUser(updated);
setUserDraft(structuredClone(updated));
// Refresh the list to show updated data
await fetchUsers();
return true;
}
return false;
}, [selectedUser, userDraft, updateUser, fetchUsers]);
// Discard draft changes
const discardDraft = useCallback(() => {
if (selectedUser) {
setUserDraft(structuredClone(selectedUser));
}
}, [selectedUser]);
// Check if draft has changes
const isDirty = useCallback(() => {
if (!selectedUser || !userDraft) return false;
return JSON.stringify(selectedUser) !== JSON.stringify(userDraft);
}, [selectedUser, userDraft]);
// Initial load
useEffect(() => {
fetchUsers();
fetchClasses();
fetchItems();
}, [fetchUsers, fetchClasses, fetchItems]);
return {
// User list
users,
total,
currentPage,
limit,
setLimit,
// Filters
filters,
setFilters,
setSearchDebounced,
// Pagination
setPage,
// Detail panel
selectedUser,
selectUser,
closeDetail,
// Editing
userDraft,
updateDraft,
saveDraft,
discardDraft,
isDirty: isDirty(),
// Inventory
inventoryDraft,
addInventoryItem,
removeInventoryItem,
// Reference data
classes,
items,
// UI state
loading,
saving,
error,
// Actions
refetch: fetchUsers,
};
}

6
panel/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

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

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

View File

@@ -0,0 +1,882 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { Upload, Download, X, Wand2, ImageIcon, Loader2 } from "lucide-react";
import { cn } from "../lib/utils";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CHECKERBOARD: React.CSSProperties = {
backgroundImage: `
linear-gradient(45deg, #444 25%, transparent 25%),
linear-gradient(-45deg, #444 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #444 75%),
linear-gradient(-45deg, transparent 75%, #444 75%)
`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
backgroundColor: "#2a2a2a",
};
type BgPreset = { label: string; style: React.CSSProperties };
const BG_PRESETS: BgPreset[] = [
{ label: "Checker", style: CHECKERBOARD },
{ label: "White", style: { backgroundColor: "#ffffff" } },
{ label: "Black", style: { backgroundColor: "#000000" } },
{ label: "Red", style: { backgroundColor: "#e53e3e" } },
{ label: "Green", style: { backgroundColor: "#38a169" } },
{ label: "Blue", style: { backgroundColor: "#3182ce" } },
];
// Max normalised distances for each keying space
const MAX_RGB = Math.sqrt(3); // ≈ 1.732
const MAX_HSV = 1.5; // sqrt(0.5² × 4 + 1² + 1² × 0.25)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function rgbToHex(r: number, g: number, b: number): string {
return `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
}
function hexToRgb(hex: string): [number, number, number] | null {
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex.trim());
if (!m) return null;
return [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)];
}
// ---------------------------------------------------------------------------
// WebGL — shaders
// ---------------------------------------------------------------------------
const VERT = `
attribute vec2 aPos;
varying vec2 vUV;
void main() {
vUV = aPos * 0.5 + 0.5;
gl_Position = vec4(aPos, 0.0, 1.0);
}`;
const KEY_FRAG = `
precision mediump float;
uniform sampler2D uImage;
uniform vec3 uKey;
uniform float uTol;
uniform float uFeather;
uniform float uSatMin;
uniform float uSpill;
uniform float uHueMode;
varying vec2 vUV;
vec3 rgbToHsv(vec3 c) {
float cmax = max(c.r, max(c.g, c.b));
float cmin = min(c.r, min(c.g, c.b));
float delta = cmax - cmin;
float h = 0.0;
float s = (cmax < 0.0001) ? 0.0 : delta / cmax;
if (delta > 0.0001) {
if (cmax == c.r) h = mod((c.g - c.b) / delta, 6.0);
else if (cmax == c.g) h = (c.b - c.r) / delta + 2.0;
else h = (c.r - c.g) / delta + 4.0;
h /= 6.0;
}
return vec3(h, s, cmax);
}
void main() {
vec4 c = texture2D(uImage, vUV);
vec3 hsv = rgbToHsv(c.rgb);
vec3 keyHsv = rgbToHsv(uKey);
float d;
if (uHueMode > 0.5) {
float dh = abs(hsv.x - keyHsv.x);
if (dh > 0.5) dh = 1.0 - dh;
float ds = abs(hsv.y - keyHsv.y);
float dv = abs(hsv.z - keyHsv.z);
d = sqrt(dh * dh * 4.0 + ds * ds + dv * dv * 0.25);
} else {
d = distance(c.rgb, uKey);
}
float a = c.a;
if (hsv.y >= uSatMin) {
if (d <= uTol) {
a = 0.0;
} else if (uFeather > 0.0 && d <= uTol + uFeather) {
a = (d - uTol) / uFeather * c.a;
}
}
vec3 rgb = c.rgb;
if (uSpill > 0.0) {
float edgeDist = max(0.0, d - uTol);
float spillZone = max(uFeather + uTol * 0.5, 0.01);
float spillFact = clamp(1.0 - edgeDist / spillZone, 0.0, 1.0) * uSpill;
if (uKey.g >= uKey.r && uKey.g >= uKey.b) {
float excess = rgb.g - max(rgb.r, rgb.b);
if (excess > 0.0) rgb.g -= excess * spillFact;
} else if (uKey.b >= uKey.r && uKey.b >= uKey.g) {
float excess = rgb.b - max(rgb.r, rgb.g);
if (excess > 0.0) rgb.b -= excess * spillFact;
} else {
float excess = rgb.r - max(rgb.g, rgb.b);
if (excess > 0.0) rgb.r -= excess * spillFact;
}
}
gl_FragColor = vec4(rgb, a);
}`;
const HALO_FRAG = `
precision mediump float;
uniform sampler2D uKeyed;
uniform float uHaloStr;
uniform float uHaloRadius;
uniform vec2 uTexelSize;
varying vec2 vUV;
void main() {
vec4 c = texture2D(uKeyed, vUV);
if (uHaloStr <= 0.0) {
gl_FragColor = c;
return;
}
vec2 r = uTexelSize * uHaloRadius;
vec2 rd = r * 0.7071;
float minA = c.a;
minA = min(minA, texture2D(uKeyed, vUV + vec2( r.x, 0.0 )).a);
minA = min(minA, texture2D(uKeyed, vUV + vec2(-r.x, 0.0 )).a);
minA = min(minA, texture2D(uKeyed, vUV + vec2( 0.0, r.y )).a);
minA = min(minA, texture2D(uKeyed, vUV + vec2( 0.0, -r.y )).a);
minA = min(minA, texture2D(uKeyed, vUV + vec2( rd.x, rd.y)).a);
minA = min(minA, texture2D(uKeyed, vUV + vec2(-rd.x, rd.y)).a);
minA = min(minA, texture2D(uKeyed, vUV + vec2( rd.x, -rd.y)).a);
minA = min(minA, texture2D(uKeyed, vUV + vec2(-rd.x, -rd.y)).a);
gl_FragColor = vec4(c.rgb, mix(c.a, minA, uHaloStr));
}`;
// ---------------------------------------------------------------------------
// WebGL — types + init
// ---------------------------------------------------------------------------
type GlState = {
gl: WebGLRenderingContext;
kProg: WebGLProgram;
srcTex: WebGLTexture;
fbo: WebGLFramebuffer;
fboTex: WebGLTexture;
uKey: WebGLUniformLocation;
uTol: WebGLUniformLocation;
uFeather: WebGLUniformLocation;
uSatMin: WebGLUniformLocation;
uSpill: WebGLUniformLocation;
uHueMode: WebGLUniformLocation;
hProg: WebGLProgram;
uHaloStr: WebGLUniformLocation;
uHaloRadius: WebGLUniformLocation;
uTexelSize: WebGLUniformLocation;
};
function compileShader(gl: WebGLRenderingContext, type: number, src: string): WebGLShader {
const s = gl.createShader(type)!;
gl.shaderSource(s, src);
gl.compileShader(s);
return s;
}
function makeProgram(gl: WebGLRenderingContext, vs: WebGLShader, fs: WebGLShader): WebGLProgram {
const p = gl.createProgram()!;
gl.attachShader(p, vs);
gl.attachShader(p, fs);
gl.bindAttribLocation(p, 0, "aPos");
gl.linkProgram(p);
return p;
}
function makeTexture(gl: WebGLRenderingContext): WebGLTexture {
const t = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
return t;
}
function initGl(canvas: HTMLCanvasElement): GlState | null {
const gl = canvas.getContext("webgl", {
premultipliedAlpha: false,
preserveDrawingBuffer: true,
}) as WebGLRenderingContext | null;
if (!gl) return null;
const vs = compileShader(gl, gl.VERTEX_SHADER, VERT);
const kFrag = compileShader(gl, gl.FRAGMENT_SHADER, KEY_FRAG);
const hFrag = compileShader(gl, gl.FRAGMENT_SHADER, HALO_FRAG);
const kProg = makeProgram(gl, vs, kFrag);
const hProg = makeProgram(gl, vs, hFrag);
const buf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
const srcTex = makeTexture(gl);
const fboTex = makeTexture(gl);
const fbo = gl.createFramebuffer()!;
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, fboTex, 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return {
gl, kProg, hProg, srcTex, fbo, fboTex,
uKey: gl.getUniformLocation(kProg, "uKey")!,
uTol: gl.getUniformLocation(kProg, "uTol")!,
uFeather: gl.getUniformLocation(kProg, "uFeather")!,
uSatMin: gl.getUniformLocation(kProg, "uSatMin")!,
uSpill: gl.getUniformLocation(kProg, "uSpill")!,
uHueMode: gl.getUniformLocation(kProg, "uHueMode")!,
uHaloStr: gl.getUniformLocation(hProg, "uHaloStr")!,
uHaloRadius: gl.getUniformLocation(hProg, "uHaloRadius")!,
uTexelSize: gl.getUniformLocation(hProg, "uTexelSize")!,
};
}
// ---------------------------------------------------------------------------
// AI remove tab
// ---------------------------------------------------------------------------
type AiStatus = "idle" | "loading" | "done" | "error";
function AiRemoveTab({ imageFile, imageSrc, onClear }: {
imageFile: File;
imageSrc: string;
onClear: () => void;
}) {
const [status, setStatus] = useState<AiStatus>("idle");
const [resultUrl, setResultUrl] = useState<string | null>(null);
const [bgPreset, setBgPreset] = useState(0);
const [progress, setProgress] = useState("");
const handleRemove = async () => {
setStatus("loading");
setProgress("Loading AI model…");
try {
const { removeBackground } = await import("@imgly/background-removal");
setProgress("Removing background…");
const blob = await removeBackground(imageSrc, {
progress: (_key: string, current: number, total: number) => {
if (total > 0) {
setProgress(`Downloading model… ${Math.round((current / total) * 100)}%`);
}
},
});
const url = URL.createObjectURL(blob);
setResultUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return url; });
setStatus("done");
} catch (err) {
console.error(err);
setStatus("error");
}
};
const handleDownload = () => {
if (!resultUrl) return;
const a = document.createElement("a");
a.download = imageFile.name.replace(/\.[^.]+$/, "") + "_nobg.png";
a.href = resultUrl;
a.click();
};
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap">
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
{imageFile.name}
</span>
<button
onClick={onClear}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
"hover:text-destructive hover:border-destructive transition-colors",
)}
>
<X className="w-3.5 h-3.5" /> Clear
</button>
{status !== "done" ? (
<button
onClick={handleRemove}
disabled={status === "loading"}
className={cn(
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
status === "loading"
? "bg-raised border border-border text-text-tertiary cursor-not-allowed"
: "bg-primary text-white hover:bg-primary/90",
)}
>
{status === "loading" ? (
<><Loader2 className="w-3.5 h-3.5 animate-spin" /> {progress}</>
) : (
<><Wand2 className="w-3.5 h-3.5" /> Remove Background</>
)}
</button>
) : (
<button
onClick={handleDownload}
className="flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold bg-primary text-white hover:bg-primary/90 transition-colors"
>
<Download className="w-3.5 h-3.5" /> Download PNG
</button>
)}
</div>
{status === "error" && (
<p className="text-xs text-destructive">
Something went wrong. Check the console for details.
</p>
)}
{/* Side-by-side */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Original</p>
<div className="bg-card border border-border rounded-lg overflow-hidden">
<img src={imageSrc} className="w-full block" alt="Original" />
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex-1">
Result transparent background
</p>
<div className="flex items-center gap-1">
{BG_PRESETS.map((preset, i) => (
<button
key={preset.label}
title={preset.label}
onClick={() => setBgPreset(i)}
className={cn(
"w-5 h-5 rounded border transition-all",
i === bgPreset
? "ring-2 ring-primary ring-offset-1 ring-offset-background border-transparent"
: "border-border hover:border-primary/50",
)}
style={preset.style}
/>
))}
</div>
</div>
<div className="bg-card border border-border rounded-lg overflow-hidden">
{resultUrl ? (
<div style={BG_PRESETS[bgPreset].style}>
<img src={resultUrl} className="w-full block" alt="Result" />
</div>
) : (
<div className="aspect-square flex flex-col items-center justify-center text-text-tertiary gap-3 p-8">
{status === "loading" ? (
<><Loader2 className="w-10 h-10 opacity-40 animate-spin" /><span className="text-xs opacity-40 text-center">{progress}</span></>
) : (
<><ImageIcon className="w-10 h-10 opacity-20" /><span className="text-xs opacity-40 text-center">Click Remove Background to process</span></>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// BackgroundRemoval component
// ---------------------------------------------------------------------------
type Mode = "chroma" | "ai";
export function BackgroundRemoval() {
const [mode, setMode] = useState<Mode>("chroma");
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imageReady, setImageReady] = useState(false);
const [keyColor, setKeyColor] = useState<[number, number, number] | null>(null);
const [hexInput, setHexInput] = useState("");
const [tolerance, setTolerance] = useState(30);
const [feather, setFeather] = useState(10);
const [satMin, setSatMin] = useState(0);
const [spillStr, setSpillStr] = useState(0);
const [hueMode, setHueMode] = useState(false);
const [haloStr, setHaloStr] = useState(0);
const [haloRadius, setHaloRadius] = useState(2);
const [dragOver, setDragOver] = useState(false);
const [bgPreset, setBgPreset] = useState(0);
const sourceCanvasRef = useRef<HTMLCanvasElement>(null);
const glCanvasRef = useRef<HTMLCanvasElement>(null);
const glRef = useRef<GlState | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (keyColor) setHexInput(rgbToHex(...keyColor));
}, [keyColor]);
useEffect(() => {
if (!imageSrc) return;
setImageReady(false);
setKeyColor(null);
setHexInput("");
const img = new Image();
img.onload = () => {
const src = sourceCanvasRef.current;
if (!src) return;
src.width = img.naturalWidth;
src.height = img.naturalHeight;
src.getContext("2d")!.drawImage(img, 0, 0);
const glCanvas = glCanvasRef.current;
if (!glCanvas) return;
glCanvas.width = img.naturalWidth;
glCanvas.height = img.naturalHeight;
if (!glRef.current) {
glRef.current = initGl(glCanvas);
if (!glRef.current) {
console.error("BackgroundRemoval: WebGL not supported");
return;
}
}
const { gl, srcTex, fboTex } = glRef.current;
gl.bindTexture(gl.TEXTURE_2D, srcTex);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.bindTexture(gl.TEXTURE_2D, fboTex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, img.naturalWidth, img.naturalHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.viewport(0, 0, img.naturalWidth, img.naturalHeight);
setImageReady(true);
};
img.src = imageSrc;
}, [imageSrc]);
useEffect(() => {
const state = glRef.current;
if (!state || !imageReady || !keyColor) return;
const w = glCanvasRef.current!.width;
const h = glCanvasRef.current!.height;
const { gl } = state;
const MAX = hueMode ? MAX_HSV : MAX_RGB;
gl.bindFramebuffer(gl.FRAMEBUFFER, state.fbo);
gl.viewport(0, 0, w, h);
gl.useProgram(state.kProg);
gl.bindTexture(gl.TEXTURE_2D, state.srcTex);
gl.uniform3f(state.uKey, keyColor[0] / 255, keyColor[1] / 255, keyColor[2] / 255);
gl.uniform1f(state.uTol, (tolerance / 100) * MAX);
gl.uniform1f(state.uFeather, (feather / 100) * MAX);
gl.uniform1f(state.uSatMin, satMin / 100);
gl.uniform1f(state.uSpill, spillStr / 100);
gl.uniform1f(state.uHueMode, hueMode ? 1.0 : 0.0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, w, h);
gl.useProgram(state.hProg);
gl.bindTexture(gl.TEXTURE_2D, state.fboTex);
gl.uniform1f(state.uHaloStr, haloStr / 100);
gl.uniform1f(state.uHaloRadius, haloRadius);
gl.uniform2f(state.uTexelSize, 1 / w, 1 / h);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}, [keyColor, tolerance, feather, satMin, spillStr, hueMode, haloStr, haloRadius, imageReady]);
const loadFile = useCallback((file: File) => {
if (imageSrc) URL.revokeObjectURL(imageSrc);
setImageSrc(URL.createObjectURL(file));
setImageFile(file);
}, [imageSrc]);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file?.type.startsWith("image/")) loadFile(file);
};
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = sourceCanvasRef.current;
if (!canvas || !imageReady) return;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const x = Math.floor((e.clientX - rect.left) * scaleX);
const y = Math.floor((e.clientY - rect.top) * scaleY);
const px = canvas.getContext("2d")!.getImageData(x, y, 1, 1).data;
setKeyColor([px[0], px[1], px[2]]);
};
const handleHexInput = (v: string) => {
setHexInput(v);
const parsed = hexToRgb(v);
if (parsed) setKeyColor(parsed);
};
const handleDownload = () => {
const glCanvas = glCanvasRef.current;
if (!glCanvas || !keyColor || !imageFile) return;
glCanvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.download = imageFile.name.replace(/\.[^.]+$/, "") + "_transparent.png";
a.href = url;
a.click();
URL.revokeObjectURL(url);
}, "image/png");
};
const clearAll = useCallback(() => {
if (imageSrc) URL.revokeObjectURL(imageSrc);
setImageSrc(null);
setImageFile(null);
setImageReady(false);
setKeyColor(null);
setHexInput("");
glRef.current = null;
if (fileInputRef.current) fileInputRef.current.value = "";
}, [imageSrc]);
// ── Upload screen ──────────────────────────────────────────────────────────
if (!imageSrc) {
return (
<div className="max-w-2xl mx-auto py-8 space-y-4">
{/* Page header + mode toggle */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h2 className="text-lg font-semibold text-foreground mb-1">
Background Removal
</h2>
<p className="text-sm text-text-secondary">
Upload an image then remove its background either by selecting a
key color (chroma key) or using the AI model.
</p>
</div>
<ModeToggle mode={mode} onChange={setMode} />
</div>
<div
onDrop={handleDrop}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onClick={() => fileInputRef.current?.click()}
className={cn(
"border-2 border-dashed rounded-xl p-16 flex flex-col items-center gap-4 cursor-pointer transition-all select-none",
dragOver
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-primary/3",
)}
>
<Upload className={cn("w-10 h-10 transition-colors", dragOver ? "text-primary" : "text-text-tertiary")} />
<div className="text-center">
<p className="text-sm font-medium text-text-secondary">
{dragOver ? "Drop to upload" : "Drop an image here"}
</p>
<p className="text-xs text-text-tertiary mt-1">
or click to browse · PNG, JPEG, WebP · max 15 MB
</p>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(e) => { const f = e.target.files?.[0]; if (f) loadFile(f); }}
className="hidden"
/>
</div>
);
}
// ── Editor screen ──────────────────────────────────────────────────────────
const hasResult = imageReady && keyColor !== null;
return (
<div className="space-y-4 pb-8">
{/* Page header + mode toggle */}
<div className="flex items-center gap-3 flex-wrap">
<h2 className="text-lg font-semibold text-foreground flex-1">
Background Removal
</h2>
<ModeToggle mode={mode} onChange={setMode} />
</div>
{/* AI tab */}
{mode === "ai" && (
<AiRemoveTab imageFile={imageFile!} imageSrc={imageSrc} onClear={clearAll} />
)}
{/* Chroma key tab */}
{mode === "chroma" && (
<>
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap">
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
{imageFile?.name}
</span>
<button
onClick={clearAll}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
"hover:text-destructive hover:border-destructive transition-colors",
)}
>
<X className="w-3.5 h-3.5" /> Clear
</button>
<button
onClick={handleDownload}
disabled={!hasResult}
className={cn(
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
hasResult
? "bg-primary text-white hover:bg-primary/90"
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
)}
>
<Download className="w-3.5 h-3.5" /> Download PNG
</button>
</div>
{/* Controls */}
<div className="bg-card border border-border rounded-xl p-4 space-y-4">
{/* Row 1 — Key color + mode */}
<div className="flex flex-wrap gap-6 items-center">
<div className="space-y-1.5 shrink-0">
<p className="text-xs font-medium text-text-secondary">Key Color</p>
<div className="flex items-center gap-2">
<div
className="w-7 h-7 rounded-md border border-border shadow-inner shrink-0"
style={
keyColor
? { backgroundColor: rgbToHex(...keyColor) }
: {
backgroundImage:
"linear-gradient(45deg,#444 25%,transparent 25%),linear-gradient(-45deg,#444 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#444 75%),linear-gradient(-45deg,transparent 75%,#444 75%)",
backgroundSize: "8px 8px",
backgroundPosition: "0 0,0 4px,4px -4px,-4px 0",
backgroundColor: "#2a2a2a",
}
}
/>
<input
type="text"
value={hexInput}
onChange={(e) => handleHexInput(e.target.value)}
placeholder="#rrggbb"
maxLength={7}
spellCheck={false}
className={cn(
"w-[76px] text-xs font-mono bg-transparent border rounded px-1.5 py-0.5",
"text-text-secondary focus:outline-none transition-colors",
hexInput && !hexToRgb(hexInput)
? "border-destructive focus:border-destructive"
: "border-border focus:border-primary",
)}
/>
</div>
</div>
<div className="space-y-1.5 shrink-0">
<p className="text-xs font-medium text-text-secondary">Keying Mode</p>
<div className="flex rounded-md border border-border overflow-hidden text-xs font-medium">
<button
onClick={() => setHueMode(false)}
className={cn(
"px-3 py-1.5 transition-colors",
!hueMode ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
)}
>
RGB
</button>
<button
onClick={() => setHueMode(true)}
className={cn(
"px-3 py-1.5 transition-colors border-l border-border",
hueMode ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
)}
>
HSV
</button>
</div>
</div>
{!keyColor && (
<span className="text-xs text-text-tertiary flex items-center gap-1.5 ml-auto">
<Wand2 className="w-3.5 h-3.5" /> Click the image to pick a key color
</span>
)}
</div>
{/* Row 2 — Matte */}
<div>
<p className="text-[10px] font-semibold uppercase tracking-wider text-text-tertiary mb-2">Matte</p>
<div className="flex flex-wrap gap-6">
<div className="space-y-1.5 flex-1 min-w-[130px]">
<div className="flex justify-between">
<p className="text-xs font-medium text-text-secondary">Tolerance</p>
<span className="text-xs font-mono text-text-tertiary">{tolerance}%</span>
</div>
<input type="range" min="0" max="100" value={tolerance} onChange={(e) => setTolerance(Number(e.target.value))} className="w-full accent-primary" />
</div>
<div className="space-y-1.5 flex-1 min-w-[130px]">
<div className="flex justify-between">
<p className="text-xs font-medium text-text-secondary">Sat. Gate</p>
<span className="text-xs font-mono text-text-tertiary">{satMin}%</span>
</div>
<input type="range" min="0" max="100" value={satMin} onChange={(e) => setSatMin(Number(e.target.value))} className="w-full accent-primary" />
<p className="text-[10px] text-text-tertiary leading-tight">Skip pixels below this saturation preserves neutral tones</p>
</div>
<div className="space-y-1.5 flex-1 min-w-[130px]">
<div className="flex justify-between">
<p className="text-xs font-medium text-text-secondary">Edge Feather</p>
<span className="text-xs font-mono text-text-tertiary">{feather}%</span>
</div>
<input type="range" min="0" max="50" value={feather} onChange={(e) => setFeather(Number(e.target.value))} className="w-full accent-primary" />
</div>
</div>
</div>
{/* Row 3 — Cleanup */}
<div>
<p className="text-[10px] font-semibold uppercase tracking-wider text-text-tertiary mb-2">Cleanup</p>
<div className="flex flex-wrap gap-6">
<div className="space-y-1.5 flex-1 min-w-[130px]">
<div className="flex justify-between">
<p className="text-xs font-medium text-text-secondary">Despill</p>
<span className="text-xs font-mono text-text-tertiary">{spillStr}%</span>
</div>
<input type="range" min="0" max="100" value={spillStr} onChange={(e) => setSpillStr(Number(e.target.value))} className="w-full accent-primary" />
<p className="text-[10px] text-text-tertiary leading-tight">Suppress key-color fringing on subject edges</p>
</div>
<div className="space-y-1.5 flex-1 min-w-[130px]">
<div className="flex justify-between">
<p className="text-xs font-medium text-text-secondary">Halo Remove</p>
<span className="text-xs font-mono text-text-tertiary">{haloStr}%</span>
</div>
<input type="range" min="0" max="100" value={haloStr} onChange={(e) => setHaloStr(Number(e.target.value))} className="w-full accent-primary" />
<p className="text-[10px] text-text-tertiary leading-tight">Erode the matte inward to eliminate bright rim pixels</p>
</div>
<div className="space-y-1.5 flex-1 min-w-[130px]">
<div className="flex justify-between">
<p className="text-xs font-medium text-text-secondary">Halo Radius</p>
<span className="text-xs font-mono text-text-tertiary">{haloRadius} px</span>
</div>
<input type="range" min="1" max="8" step="1" value={haloRadius} onChange={(e) => setHaloRadius(Number(e.target.value))} className="w-full accent-primary" disabled={haloStr === 0} />
<p className="text-[10px] text-text-tertiary leading-tight">How far to look for transparent neighbours</p>
</div>
</div>
</div>
</div>
{/* Side-by-side view */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
Original click to pick key color
</p>
<div className="bg-card border border-border rounded-lg overflow-hidden">
<canvas ref={sourceCanvasRef} className="w-full cursor-crosshair block" onClick={handleCanvasClick} title="Click a pixel to set it as the chroma key color" />
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex-1">
Result transparent background
</p>
<div className="flex items-center gap-1">
{BG_PRESETS.map((preset, i) => (
<button
key={preset.label}
title={preset.label}
onClick={() => setBgPreset(i)}
className={cn(
"w-5 h-5 rounded border transition-all",
i === bgPreset
? "ring-2 ring-primary ring-offset-1 ring-offset-background border-transparent"
: "border-border hover:border-primary/50",
)}
style={preset.style}
/>
))}
</div>
</div>
<div className="bg-card border border-border rounded-lg overflow-hidden">
<div style={BG_PRESETS[bgPreset].style} className={cn("w-full", !hasResult && "hidden")}>
<canvas ref={glCanvasRef} className="w-full block" />
</div>
{!hasResult && (
<div className="aspect-square flex flex-col items-center justify-center text-text-tertiary gap-3 p-8">
<ImageIcon className="w-10 h-10 opacity-20" />
<span className="text-xs opacity-40 text-center leading-relaxed">
{imageReady ? "Click the image on the left to pick a key color" : "Loading image…"}
</span>
</div>
)}
</div>
</div>
</div>
</>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// ModeToggle
// ---------------------------------------------------------------------------
function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
return (
<div className="flex rounded-md border border-border overflow-hidden text-xs font-medium shrink-0">
<button
onClick={() => onChange("chroma")}
className={cn(
"px-3 py-1.5 transition-colors",
mode === "chroma" ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
)}
>
Chroma Key
</button>
<button
onClick={() => onChange("ai")}
className={cn(
"px-3 py-1.5 transition-colors border-l border-border",
mode === "ai" ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
)}
>
AI Remove
</button>
</div>
);
}

View File

@@ -0,0 +1,509 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { Upload, Download, X, Lock, Unlock, ImageIcon, Maximize2 } from "lucide-react";
import { cn } from "../lib/utils";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CHECKERBOARD: React.CSSProperties = {
backgroundImage: `
linear-gradient(45deg, #444 25%, transparent 25%),
linear-gradient(-45deg, #444 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #444 75%),
linear-gradient(-45deg, transparent 75%, #444 75%)
`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
backgroundColor: "#2a2a2a",
};
type BgPreset = { label: string; style: React.CSSProperties };
const BG_PRESETS: BgPreset[] = [
{ label: "Checker", style: CHECKERBOARD },
{ label: "White", style: { backgroundColor: "#ffffff" } },
{ label: "Black", style: { backgroundColor: "#000000" } },
{ label: "Red", style: { backgroundColor: "#e53e3e" } },
{ label: "Green", style: { backgroundColor: "#38a169" } },
{ label: "Blue", style: { backgroundColor: "#3182ce" } },
];
type ScaleMode = "fit" | "fill" | "stretch" | "original";
const SCALE_MODES: { id: ScaleMode; label: string; desc: string }[] = [
{ id: "fit", label: "Fit", desc: "Scale to fit canvas, preserve ratio (letterbox)" },
{ id: "fill", label: "Fill", desc: "Scale to fill canvas, preserve ratio (crop edges)" },
{ id: "stretch", label: "Stretch", desc: "Stretch to exact dimensions, ignore ratio" },
{ id: "original", label: "Original", desc: "No scaling — align/center only" },
];
type Align = [-1 | 0 | 1, -1 | 0 | 1];
const ALIGN_GRID: Align[] = [
[-1, -1], [0, -1], [1, -1],
[-1, 0], [0, 0], [1, 0],
[-1, 1], [0, 1], [1, 1],
];
const SIZE_PRESETS: { label: string; w: number; h: number }[] = [
{ label: "64", w: 64, h: 64 },
{ label: "128", w: 128, h: 128 },
{ label: "256", w: 256, h: 256 },
{ label: "512", w: 512, h: 512 },
{ label: "1024", w: 1024, h: 1024 },
{ label: "960×540", w: 960, h: 540 },
{ label: "1920×1080", w: 1920, h: 1080 },
];
// ---------------------------------------------------------------------------
// CanvasTool
// ---------------------------------------------------------------------------
export function CanvasTool() {
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imageReady, setImageReady] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [naturalW, setNaturalW] = useState(1);
const [naturalH, setNaturalH] = useState(1);
const [outW, setOutW] = useState("256");
const [outH, setOutH] = useState("256");
const [aspectLock, setAspectLock] = useState(true);
const [scaleMode, setScaleMode] = useState<ScaleMode>("fit");
const [alignment, setAlignment] = useState<Align>([0, 0]);
const [bgPreset, setBgPreset] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const sourceCanvasRef = useRef<HTMLCanvasElement>(null);
// previewCanvasRef — drawn to directly on every setting change; no toDataURL
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
// ── Load image onto hidden source canvas ──────────────────────────────────
useEffect(() => {
if (!imageSrc) return;
setImageReady(false);
const img = new Image();
img.onload = () => {
const canvas = sourceCanvasRef.current;
if (!canvas) return;
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext("2d")!.drawImage(img, 0, 0);
setNaturalW(img.naturalWidth);
setNaturalH(img.naturalHeight);
setOutW(String(img.naturalWidth));
setOutH(String(img.naturalHeight));
setImageReady(true);
};
img.src = imageSrc;
}, [imageSrc]);
// ── Draw output directly into previewCanvasRef whenever settings change ────
// drawImage is GPU-accelerated; skipping toDataURL eliminates the main bottleneck.
useEffect(() => {
if (!imageReady) return;
const src = sourceCanvasRef.current;
const prev = previewCanvasRef.current;
if (!src || !prev) return;
const w = parseInt(outW);
const h = parseInt(outH);
if (!w || !h || w <= 0 || h <= 0) return;
const frame = requestAnimationFrame(() => {
prev.width = w;
prev.height = h;
const ctx = prev.getContext("2d")!;
ctx.clearRect(0, 0, w, h);
const srcW = src.width;
const srcH = src.height;
let drawW: number;
let drawH: number;
if (scaleMode === "fit") {
const scale = Math.min(w / srcW, h / srcH);
drawW = srcW * scale;
drawH = srcH * scale;
} else if (scaleMode === "fill") {
const scale = Math.max(w / srcW, h / srcH);
drawW = srcW * scale;
drawH = srcH * scale;
} else if (scaleMode === "stretch") {
drawW = w;
drawH = h;
} else {
drawW = srcW;
drawH = srcH;
}
const x = (w - drawW) * (alignment[0] + 1) / 2;
const y = (h - drawH) * (alignment[1] + 1) / 2;
ctx.drawImage(src, x, y, drawW, drawH);
});
return () => cancelAnimationFrame(frame);
}, [imageReady, outW, outH, scaleMode, alignment]);
// ── Width / height with optional aspect lock ───────────────────────────────
const handleWChange = (v: string) => {
setOutW(v);
if (aspectLock) {
const n = parseInt(v);
if (!isNaN(n) && n > 0) setOutH(String(Math.round(n * naturalH / naturalW)));
}
};
const handleHChange = (v: string) => {
setOutH(v);
if (aspectLock) {
const n = parseInt(v);
if (!isNaN(n) && n > 0) setOutW(String(Math.round(n * naturalW / naturalH)));
}
};
// ── File loading ───────────────────────────────────────────────────────────
const loadFile = useCallback((file: File) => {
if (imageSrc) URL.revokeObjectURL(imageSrc);
setImageSrc(URL.createObjectURL(file));
setImageFile(file);
}, [imageSrc]);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file?.type.startsWith("image/")) loadFile(file);
};
// Download: toBlob from previewCanvasRef — only at explicit user request
const handleDownload = () => {
const prev = previewCanvasRef.current;
if (!prev || !imageReady || !imageFile) return;
prev.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.download = imageFile.name.replace(/\.[^.]+$/, "") + `_${outW}x${outH}.png`;
a.href = url;
a.click();
URL.revokeObjectURL(url);
}, "image/png");
};
const clearAll = useCallback(() => {
if (imageSrc) URL.revokeObjectURL(imageSrc);
setImageSrc(null);
setImageFile(null);
setImageReady(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}, [imageSrc]);
// ── Upload screen ──────────────────────────────────────────────────────────
if (!imageSrc) {
return (
<div className="max-w-2xl mx-auto py-8 space-y-4">
<div>
<h2 className="text-lg font-semibold text-foreground mb-1">Canvas Tool</h2>
<p className="text-sm text-text-secondary">
Upload an image to resize, scale, or center it on a canvas of any size.
</p>
</div>
<div
onDrop={handleDrop}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onClick={() => fileInputRef.current?.click()}
className={cn(
"border-2 border-dashed rounded-xl p-16 flex flex-col items-center gap-4 cursor-pointer transition-all select-none",
dragOver
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-primary/3",
)}
>
<Upload className={cn("w-10 h-10 transition-colors", dragOver ? "text-primary" : "text-text-tertiary")} />
<div className="text-center">
<p className="text-sm font-medium text-text-secondary">
{dragOver ? "Drop to upload" : "Drop an image here"}
</p>
<p className="text-xs text-text-tertiary mt-1">
or click to browse · PNG, JPEG, WebP · max 15 MB
</p>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(e) => { const f = e.target.files?.[0]; if (f) loadFile(f); }}
className="hidden"
/>
</div>
);
}
// ── Editor screen ──────────────────────────────────────────────────────────
const alignDisabled = scaleMode === "stretch";
return (
<div className="space-y-4 pb-8">
{/* Hidden source canvas */}
<canvas ref={sourceCanvasRef} className="hidden" />
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap">
<h2 className="text-lg font-semibold text-foreground flex-1">Canvas Tool</h2>
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
{imageFile?.name}
</span>
<span className="text-xs text-text-tertiary font-mono">
{naturalW}×{naturalH}
</span>
<button
onClick={clearAll}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
"hover:text-destructive hover:border-destructive transition-colors",
)}
>
<X className="w-3.5 h-3.5" /> Clear
</button>
<button
onClick={handleDownload}
disabled={!imageReady}
className={cn(
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
imageReady
? "bg-primary text-white hover:bg-primary/90"
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
)}
>
<Download className="w-3.5 h-3.5" /> Download PNG
</button>
</div>
{/* Controls */}
<div className="bg-card border border-border rounded-xl p-4 space-y-4">
{/* Row 1: output size */}
<div className="flex flex-wrap gap-4 items-end">
<div className="space-y-1.5">
<p className="text-xs font-medium text-text-secondary">Output Size</p>
<div className="flex items-center gap-2">
<input
type="number" min="1" max="4096" value={outW}
onChange={(e) => handleWChange(e.target.value)}
className={cn(
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
)}
placeholder="W"
/>
<span className="text-text-tertiary text-xs">×</span>
<input
type="number" min="1" max="4096" value={outH}
onChange={(e) => handleHChange(e.target.value)}
className={cn(
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
)}
placeholder="H"
/>
<button
onClick={() => setAspectLock((l) => !l)}
title={aspectLock ? "Unlock aspect ratio" : "Lock aspect ratio"}
className={cn(
"p-1.5 rounded-md border transition-colors",
aspectLock
? "border-primary text-primary bg-primary/10"
: "border-border text-text-tertiary hover:border-primary/50",
)}
>
{aspectLock ? <Lock className="w-3.5 h-3.5" /> : <Unlock className="w-3.5 h-3.5" />}
</button>
</div>
</div>
{/* Size presets */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-text-secondary">Presets</p>
<div className="flex flex-wrap gap-1">
<button
onClick={() => { setOutW(String(naturalW)); setOutH(String(naturalH)); }}
className={cn(
"px-2 py-1 rounded text-xs border transition-colors",
outW === String(naturalW) && outH === String(naturalH)
? "border-primary text-primary bg-primary/10"
: "border-border text-text-tertiary hover:border-primary/50 hover:text-foreground",
)}
>
Original
</button>
{SIZE_PRESETS.map((p) => (
<button
key={p.label}
onClick={() => {
if (aspectLock) {
const scale = Math.min(p.w / naturalW, p.h / naturalH);
setOutW(String(Math.round(naturalW * scale)));
setOutH(String(Math.round(naturalH * scale)));
} else {
setOutW(String(p.w));
setOutH(String(p.h));
}
}}
className={cn(
"px-2 py-1 rounded text-xs border transition-colors",
outW === String(p.w) && outH === String(p.h)
? "border-primary text-primary bg-primary/10"
: "border-border text-text-tertiary hover:border-primary/50 hover:text-foreground",
)}
>
{p.label}
</button>
))}
</div>
</div>
</div>
{/* Row 2: scale mode + alignment */}
<div className="flex flex-wrap gap-6 items-start">
<div className="space-y-1.5">
<p className="text-xs font-medium text-text-secondary">Scale Mode</p>
<div className="flex gap-1">
{SCALE_MODES.map((m) => (
<button
key={m.id}
onClick={() => setScaleMode(m.id)}
title={m.desc}
className={cn(
"px-3 py-1.5 rounded-md text-xs font-medium border transition-colors",
scaleMode === m.id
? "border-primary text-primary bg-primary/10"
: "border-border text-text-tertiary hover:border-primary/50 hover:text-foreground",
)}
>
{m.label}
</button>
))}
</div>
<p className="text-xs text-text-tertiary">
{SCALE_MODES.find((m) => m.id === scaleMode)?.desc}
</p>
</div>
<div className={cn("space-y-1.5", alignDisabled && "opacity-40 pointer-events-none")}>
<p className="text-xs font-medium text-text-secondary">
{scaleMode === "fill" ? "Crop Position" : "Alignment"}
</p>
<div className="grid grid-cols-3 gap-0.5 w-fit">
{ALIGN_GRID.map(([col, row], i) => {
const active = alignment[0] === col && alignment[1] === row;
return (
<button
key={i}
onClick={() => setAlignment([col, row])}
title={`${row === -1 ? "Top" : row === 0 ? "Middle" : "Bottom"}-${col === -1 ? "Left" : col === 0 ? "Center" : "Right"}`}
className={cn(
"w-7 h-7 rounded flex items-center justify-center border transition-colors",
active
? "border-primary bg-primary/20"
: "border-border hover:border-primary/50",
)}
>
<span className={cn(
"w-2 h-2 rounded-full transition-colors",
active ? "bg-primary" : "bg-text-tertiary/40",
)} />
</button>
);
})}
</div>
</div>
</div>
</div>
{/* Side-by-side preview */}
<div className="grid grid-cols-2 gap-4">
{/* Original */}
<div className="space-y-2">
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
Original {naturalW}×{naturalH}
</p>
<div className="bg-card border border-border rounded-lg overflow-hidden">
<div style={CHECKERBOARD}>
<img src={imageSrc} alt="Source" className="w-full block" />
</div>
</div>
</div>
{/* Result — canvas drawn directly, no toDataURL */}
<div className="space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex-1">
Result {outW || "?"}×{outH || "?"}
</p>
<div className="flex items-center gap-1">
{BG_PRESETS.map((preset, i) => (
<button
key={preset.label}
title={preset.label}
onClick={() => setBgPreset(i)}
className={cn(
"w-5 h-5 rounded border transition-all",
i === bgPreset
? "ring-2 ring-primary ring-offset-1 ring-offset-background border-transparent"
: "border-border hover:border-primary/50",
)}
style={preset.style}
/>
))}
</div>
</div>
<div className="bg-card border border-border rounded-lg overflow-hidden">
<div style={BG_PRESETS[bgPreset].style} className={cn("w-full", !imageReady && "hidden")}>
<canvas ref={previewCanvasRef} className="w-full block" />
</div>
{!imageReady && (
<div className="aspect-square flex flex-col items-center justify-center text-text-tertiary gap-3 p-8">
<ImageIcon className="w-10 h-10 opacity-20" />
<span className="text-xs opacity-40 text-center leading-relaxed">
Loading image
</span>
</div>
)}
</div>
</div>
</div>
{/* Info strip */}
{imageReady && (
<div className="flex items-center gap-2 text-xs text-text-tertiary">
<Maximize2 className="w-3.5 h-3.5 shrink-0" />
<span>
Output: <span className="font-mono text-foreground">{outW}×{outH} px</span>
{" · "}
Mode: <span className="text-foreground capitalize">{scaleMode}</span>
{scaleMode !== "stretch" && (
<>
{" · "}
Align:{" "}
<span className="text-foreground">
{alignment[1] === -1 ? "Top" : alignment[1] === 0 ? "Middle" : "Bottom"}
-{alignment[0] === -1 ? "Left" : alignment[0] === 0 ? "Center" : "Right"}
</span>
</>
)}
</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,653 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { Upload, Download, X, ImageIcon, Crosshair, Maximize2 } from "lucide-react";
import { cn } from "../lib/utils";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CHECKERBOARD: React.CSSProperties = {
backgroundImage: `
linear-gradient(45deg, #444 25%, transparent 25%),
linear-gradient(-45deg, #444 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #444 75%),
linear-gradient(-45deg, transparent 75%, #444 75%)
`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
backgroundColor: "#2a2a2a",
};
// Extra space around the output rect in the editor so you can see the image
// when it extends (or will be panned) outside the output boundaries.
const PAD = 120;
const HANDLE_HIT = 8;
const MIN_CROP = 4;
// ---------------------------------------------------------------------------
// Module-level checkerboard tile cache
// Avoids recreating a canvas element on every drawEditor call.
// ---------------------------------------------------------------------------
let _checkerTile: HTMLCanvasElement | null = null;
function checkerTile(): HTMLCanvasElement {
if (_checkerTile) return _checkerTile;
const c = document.createElement("canvas");
c.width = 16; c.height = 16;
const ctx = c.getContext("2d")!;
ctx.fillStyle = "#2a2a2a"; ctx.fillRect(0, 0, 16, 16);
ctx.fillStyle = "#3d3d3d"; ctx.fillRect(0, 0, 8, 8); ctx.fillRect(8, 8, 8, 8);
_checkerTile = c;
return c;
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
// "pan" = drag inside output rect to move the image
type DragHandle =
| "nw" | "n" | "ne"
| "w" | "e"
| "sw" | "s" | "se"
| "pan";
type DragState = {
handle: DragHandle;
startX: number;
startY: number;
origW: number;
origH: number;
origImgX: number;
origImgY: number;
};
// ---------------------------------------------------------------------------
// Pure helpers
// ---------------------------------------------------------------------------
function getHandlePoints(
cx0: number, cy0: number, cx1: number, cy1: number
): [DragHandle, number, number][] {
const mx = (cx0 + cx1) / 2;
const my = (cy0 + cy1) / 2;
return [
["nw", cx0, cy0], ["n", mx, cy0], ["ne", cx1, cy0],
["w", cx0, my], ["e", cx1, my],
["sw", cx0, cy1], ["s", mx, cy1], ["se", cx1, cy1],
];
}
function hitHandle(
px: number, py: number,
cx0: number, cy0: number, cx1: number, cy1: number
): DragHandle | null {
for (const [name, hx, hy] of getHandlePoints(cx0, cy0, cx1, cy1)) {
if (Math.abs(px - hx) <= HANDLE_HIT && Math.abs(py - hy) <= HANDLE_HIT) {
return name;
}
}
if (px >= cx0 && px <= cx1 && py >= cy0 && py <= cy1) return "pan";
return null;
}
function handleCursor(h: DragHandle | null, dragging = false): string {
if (!h) return "default";
if (h === "pan") return dragging ? "grabbing" : "grab";
if (h === "nw" || h === "se") return "nwse-resize";
if (h === "ne" || h === "sw") return "nesw-resize";
if (h === "n" || h === "s") return "ns-resize";
return "ew-resize";
}
// ---------------------------------------------------------------------------
// drawEditor
//
// Mental model: the output canvas (cropW × cropH) is pinned at (PAD, PAD).
// The source image sits at (PAD + imgX, PAD + imgY) and can extend outside
// the output in any direction. Areas inside the output not covered by the
// image are transparent (shown as checkerboard).
// ---------------------------------------------------------------------------
function drawEditor(
canvas: HTMLCanvasElement,
img: HTMLImageElement,
imgW: number, imgH: number,
cropW: number, cropH: number,
imgX: number, imgY: number,
) {
const cW = Math.max(cropW, imgW) + 2 * PAD;
const cH = Math.max(cropH, imgH) + 2 * PAD;
if (canvas.width !== cW || canvas.height !== cH) {
canvas.width = cW;
canvas.height = cH;
}
const ctx = canvas.getContext("2d")!;
// 1. Checkerboard background — use cached tile to avoid createElement every frame
ctx.fillStyle = ctx.createPattern(checkerTile(), "repeat")!;
ctx.fillRect(0, 0, cW, cH);
// 2. Faint image boundary indicator (dashed border around full source image)
ctx.save();
ctx.strokeStyle = "rgba(255,255,255,0.2)";
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.strokeRect(PAD + imgX + 0.5, PAD + imgY + 0.5, imgW - 1, imgH - 1);
ctx.setLineDash([]);
ctx.restore();
// 3. Draw source image
ctx.drawImage(img, PAD + imgX, PAD + imgY);
// 4. Dark overlay outside the output rect
const ox0 = PAD, oy0 = PAD, ox1 = PAD + cropW, oy1 = PAD + cropH;
ctx.fillStyle = "rgba(0,0,0,0.55)";
ctx.fillRect(0, 0, cW, oy0); // top
ctx.fillRect(0, oy1, cW, cH - oy1); // bottom
ctx.fillRect(0, oy0, ox0, oy1 - oy0); // left
ctx.fillRect(ox1, oy0, cW - ox1, oy1 - oy0); // right
// 5. Rule-of-thirds grid
ctx.strokeStyle = "rgba(255,255,255,0.18)";
ctx.lineWidth = 0.75;
for (const t of [1 / 3, 2 / 3]) {
ctx.beginPath(); ctx.moveTo(ox0 + cropW * t, oy0); ctx.lineTo(ox0 + cropW * t, oy1); ctx.stroke();
ctx.beginPath(); ctx.moveTo(ox0, oy0 + cropH * t); ctx.lineTo(ox1, oy0 + cropH * t); ctx.stroke();
}
// 6. Output rect border
ctx.strokeStyle = "#fff";
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 3]);
ctx.strokeRect(ox0 + 0.5, oy0 + 0.5, cropW - 1, cropH - 1);
ctx.setLineDash([]);
// 7. Resize handles
ctx.fillStyle = "#fff";
ctx.strokeStyle = "#555";
ctx.lineWidth = 1;
for (const [, hx, hy] of getHandlePoints(ox0, oy0, ox1, oy1)) {
ctx.beginPath();
ctx.rect(hx - 4, hy - 4, 8, 8);
ctx.fill();
ctx.stroke();
}
}
// ---------------------------------------------------------------------------
// CropTool
// ---------------------------------------------------------------------------
export function CropTool() {
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imgW, setImgW] = useState(0);
const [imgH, setImgH] = useState(0);
const [imageReady, setImageReady] = useState(false);
const [dragOver, setDragOver] = useState(false);
// Output canvas dimensions
const [cropW, setCropW] = useState(128);
const [cropH, setCropH] = useState(128);
// Where the source image's top-left sits within the output canvas.
const [imgX, setImgX] = useState(0);
const [imgY, setImgY] = useState(0);
// Padding used by "Fit to Content"
const [padding, setPadding] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const displayCanvasRef = useRef<HTMLCanvasElement>(null);
const sourceCanvasRef = useRef<HTMLCanvasElement>(null);
// Direct canvas preview — avoids toDataURL on every drag frame
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const imgElRef = useRef<HTMLImageElement | null>(null);
const dragStateRef = useRef<DragState | null>(null);
// Always-fresh values for use inside stable callbacks
const outputRef = useRef({ w: cropW, h: cropH, imgX, imgY, padding });
useEffect(() => { outputRef.current = { w: cropW, h: cropH, imgX, imgY, padding }; });
// ── Load image onto hidden source canvas ────────────────────────────────────
useEffect(() => {
if (!imageSrc) return;
setImageReady(false);
const img = new Image();
img.onload = () => {
const src = sourceCanvasRef.current!;
src.width = img.naturalWidth;
src.height = img.naturalHeight;
src.getContext("2d")!.drawImage(img, 0, 0);
imgElRef.current = img;
setImgW(img.naturalWidth);
setImgH(img.naturalHeight);
setCropW(img.naturalWidth);
setCropH(img.naturalHeight);
setImgX(0);
setImgY(0);
setImageReady(true);
};
img.src = imageSrc;
}, [imageSrc]);
// ── Redraw editor whenever state changes ─────────────────────────────────────
useEffect(() => {
if (!imageReady || !displayCanvasRef.current || !imgElRef.current) return;
drawEditor(displayCanvasRef.current, imgElRef.current, imgW, imgH, cropW, cropH, imgX, imgY);
}, [imageReady, imgW, imgH, cropW, cropH, imgX, imgY]);
// ── Live preview — draw directly into previewCanvasRef (no toDataURL) ────────
useEffect(() => {
if (!imageReady || !previewCanvasRef.current) return;
const src = sourceCanvasRef.current!;
const prev = previewCanvasRef.current;
prev.width = Math.max(1, Math.round(cropW));
prev.height = Math.max(1, Math.round(cropH));
// Clear to transparent, then composite the cropped region
const ctx = prev.getContext("2d")!;
ctx.clearRect(0, 0, prev.width, prev.height);
ctx.drawImage(src, Math.round(imgX), Math.round(imgY));
}, [imageReady, cropW, cropH, imgX, imgY]);
// ── Auto-center ──────────────────────────────────────────────────────────────
const autoCenter = useCallback(() => {
if (!imageReady) return;
const src = sourceCanvasRef.current!;
const { data, width, height } = src.getContext("2d")!.getImageData(0, 0, src.width, src.height);
let minX = width, minY = height, maxX = -1, maxY = -1;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (data[(y * width + x) * 4 + 3] > 0) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (maxX === -1) return;
const contentCX = (minX + maxX) / 2;
const contentCY = (minY + maxY) / 2;
const { w, h } = outputRef.current;
setImgX(Math.round(w / 2 - contentCX));
setImgY(Math.round(h / 2 - contentCY));
}, [imageReady]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Fit to content ───────────────────────────────────────────────────────────
const fitToContent = useCallback(() => {
if (!imageReady) return;
const src = sourceCanvasRef.current!;
const { data, width, height } = src.getContext("2d")!.getImageData(0, 0, src.width, src.height);
let minX = width, minY = height, maxX = -1, maxY = -1;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (data[(y * width + x) * 4 + 3] > 0) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (maxX === -1) return;
const pad = outputRef.current.padding;
const contentW = maxX - minX + 1;
const contentH = maxY - minY + 1;
setCropW(contentW + 2 * pad);
setCropH(contentH + 2 * pad);
setImgX(pad - minX);
setImgY(pad - minY);
}, [imageReady]); // eslint-disable-line react-hooks/exhaustive-deps
// ── File loading ─────────────────────────────────────────────────────────────
const loadFile = useCallback((file: File) => {
if (imageSrc) URL.revokeObjectURL(imageSrc);
setImageSrc(URL.createObjectURL(file));
setImageFile(file);
}, [imageSrc]);
const clearAll = useCallback(() => {
if (imageSrc) URL.revokeObjectURL(imageSrc);
setImageSrc(null);
setImageFile(null);
setImageReady(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}, [imageSrc]);
// Download: toBlob from previewCanvasRef — only at explicit user request
const handleDownload = () => {
const prev = previewCanvasRef.current;
if (!prev || !imageReady || !imageFile) return;
prev.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.download = imageFile.name.replace(/\.[^.]+$/, "") + "_cropped.png";
a.href = url;
a.click();
URL.revokeObjectURL(url);
}, "image/png");
};
// ── Canvas interaction ───────────────────────────────────────────────────────
const getCanvasXY = (e: React.MouseEvent<HTMLCanvasElement>): [number, number] => {
const canvas = displayCanvasRef.current!;
const rect = canvas.getBoundingClientRect();
return [
(e.clientX - rect.left) * (canvas.width / rect.width),
(e.clientY - rect.top) * (canvas.height / rect.height),
];
};
const onMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const [px, py] = getCanvasXY(e);
const { w, h, imgX: ix, imgY: iy } = outputRef.current;
const handle = hitHandle(px, py, PAD, PAD, PAD + w, PAD + h);
if (!handle) return;
dragStateRef.current = {
handle,
startX: px, startY: py,
origW: w, origH: h,
origImgX: ix, origImgY: iy,
};
if (displayCanvasRef.current) {
displayCanvasRef.current.style.cursor = handleCursor(handle, true);
}
e.preventDefault();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const onMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = displayCanvasRef.current;
if (!canvas) return;
const [px, py] = getCanvasXY(e);
if (!dragStateRef.current) {
const { w, h } = outputRef.current;
canvas.style.cursor = handleCursor(hitHandle(px, py, PAD, PAD, PAD + w, PAD + h));
return;
}
const { handle, startX, startY, origW, origH, origImgX, origImgY } = dragStateRef.current;
const dx = Math.round(px - startX);
const dy = Math.round(py - startY);
let nw = origW, nh = origH, nix = origImgX, niy = origImgY;
if (handle === "pan") {
nix = origImgX + dx;
niy = origImgY + dy;
} else {
if (handle.includes("e")) nw = Math.max(MIN_CROP, origW + dx);
if (handle.includes("s")) nh = Math.max(MIN_CROP, origH + dy);
if (handle.includes("w")) {
const d = Math.min(dx, origW - MIN_CROP);
nw = origW - d;
nix = origImgX - d;
}
if (handle.includes("n")) {
const d = Math.min(dy, origH - MIN_CROP);
nh = origH - d;
niy = origImgY - d;
}
}
setCropW(nw);
setCropH(nh);
setImgX(nix);
setImgY(niy);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const onMouseUp = useCallback(() => {
dragStateRef.current = null;
if (displayCanvasRef.current) displayCanvasRef.current.style.cursor = "default";
}, []);
const parseIntSafe = (v: string, fallback: number) => {
const n = parseInt(v, 10);
return isNaN(n) ? fallback : n;
};
// ── Upload screen ────────────────────────────────────────────────────────────
if (!imageSrc) {
return (
<div className="max-w-2xl mx-auto py-8 space-y-4">
<div>
<h2 className="text-lg font-semibold text-foreground mb-1">Crop Tool</h2>
<p className="text-sm text-text-secondary">
Upload an image, then define an output canvas. Drag inside the canvas
to pan the image within it, or drag the edge handles to resize. The
output canvas can extend beyond the image to add transparent padding.
</p>
</div>
<div
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files[0];
if (f?.type.startsWith("image/")) loadFile(f);
}}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onClick={() => fileInputRef.current?.click()}
className={cn(
"border-2 border-dashed rounded-xl p-16 flex flex-col items-center gap-4 cursor-pointer transition-all select-none",
dragOver
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-primary/3",
)}
>
<Upload className={cn("w-10 h-10 transition-colors", dragOver ? "text-primary" : "text-text-tertiary")} />
<div className="text-center">
<p className="text-sm font-medium text-text-secondary">
{dragOver ? "Drop to upload" : "Drop an image here"}
</p>
<p className="text-xs text-text-tertiary mt-1">
or click to browse · PNG, JPEG, WebP · max 15 MB
</p>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(e) => { const f = e.target.files?.[0]; if (f) loadFile(f); }}
className="hidden"
/>
</div>
);
}
// ── Editor screen ────────────────────────────────────────────────────────────
return (
<div className="space-y-4 pb-8">
<canvas ref={sourceCanvasRef} className="hidden" />
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap">
<h2 className="text-lg font-semibold text-foreground flex-1">Crop Tool</h2>
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
{imageFile?.name}
</span>
<button
onClick={clearAll}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
"hover:text-destructive hover:border-destructive transition-colors",
)}
>
<X className="w-3.5 h-3.5" /> Clear
</button>
<button
onClick={handleDownload}
disabled={!imageReady}
className={cn(
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
imageReady
? "bg-primary text-white hover:bg-primary/90"
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
)}
>
<Download className="w-3.5 h-3.5" /> Download PNG
</button>
</div>
{/* Controls */}
<div className="bg-card border border-border rounded-xl p-4 space-y-3">
<div className="flex flex-wrap gap-4 items-end">
<label className="space-y-1.5">
<span className="text-xs font-medium text-text-secondary block">Output W (px)</span>
<input
type="number"
min={MIN_CROP}
value={cropW}
onChange={(e) => setCropW(Math.max(MIN_CROP, parseIntSafe(e.target.value, MIN_CROP)))}
className={cn(
"w-24 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
)}
/>
</label>
<label className="space-y-1.5">
<span className="text-xs font-medium text-text-secondary block">Output H (px)</span>
<input
type="number"
min={MIN_CROP}
value={cropH}
onChange={(e) => setCropH(Math.max(MIN_CROP, parseIntSafe(e.target.value, MIN_CROP)))}
className={cn(
"w-24 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
)}
/>
</label>
<label className="space-y-1.5">
<span className="text-xs font-medium text-text-secondary block">Image X</span>
<input
type="number"
value={imgX}
onChange={(e) => setImgX(parseIntSafe(e.target.value, 0))}
className={cn(
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
)}
/>
</label>
<label className="space-y-1.5">
<span className="text-xs font-medium text-text-secondary block">Image Y</span>
<input
type="number"
value={imgY}
onChange={(e) => setImgY(parseIntSafe(e.target.value, 0))}
className={cn(
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
)}
/>
</label>
<span className="text-xs text-text-tertiary self-center font-mono pb-1">
src: {imgW} × {imgH}
</span>
</div>
<div className="flex flex-wrap items-center gap-3 pt-1 border-t border-border">
<label className="space-y-1.5">
<span className="text-xs font-medium text-text-secondary block">Padding (px)</span>
<input
type="number"
min={0}
value={padding}
onChange={(e) => setPadding(Math.max(0, parseIntSafe(e.target.value, 0)))}
className={cn(
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
)}
/>
</label>
<div className="flex gap-2 self-end pb-0.5">
<button
onClick={autoCenter}
className={cn(
"flex items-center gap-1.5 px-3 py-2 rounded-md border border-border text-xs font-medium transition-colors",
"bg-input text-text-secondary hover:text-primary hover:border-primary/60",
)}
title="Pan the image so non-transparent content is centered within the current output canvas"
>
<Crosshair className="w-3.5 h-3.5" /> Auto-center
</button>
<button
onClick={fitToContent}
className={cn(
"flex items-center gap-1.5 px-3 py-2 rounded-md border border-border text-xs font-medium transition-colors",
"bg-input text-text-secondary hover:text-primary hover:border-primary/60",
)}
title="Resize output to the non-transparent content bounding box + padding, then center"
>
<Maximize2 className="w-3.5 h-3.5" /> Fit to Content
</button>
</div>
<p className="text-xs text-text-tertiary self-end pb-1 ml-auto">
Drag inside the canvas to pan · drag handles to resize
</p>
</div>
</div>
{/* Editor + Preview */}
<div className="grid grid-cols-[1fr_260px] gap-4">
<div className="space-y-2">
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
Editor
</p>
<div
className="bg-card border border-border rounded-lg overflow-auto"
style={{ maxHeight: "62vh" }}
>
<canvas
ref={displayCanvasRef}
className="block"
style={{ maxWidth: "100%" }}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
/>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
Preview
</p>
<div className="bg-card border border-border rounded-lg overflow-hidden">
{imageReady ? (
<div style={CHECKERBOARD}>
<canvas
ref={previewCanvasRef}
className="w-full block"
style={{ imageRendering: (cropW < 64 || cropH < 64) ? "pixelated" : "auto" }}
/>
</div>
) : (
<div className="aspect-square flex items-center justify-center">
<ImageIcon className="w-10 h-10 text-text-tertiary opacity-20" />
</div>
)}
</div>
{imageReady && (
<p className="text-xs text-text-tertiary text-center font-mono">
{Math.round(cropW)} × {Math.round(cropH)} px
</p>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,297 @@
import {
Users,
Coins,
TrendingUp,
Gift,
Loader2,
AlertTriangle,
CheckCircle,
XCircle,
Info,
Clock,
Wifi,
Trophy,
Crown,
Gem,
Wrench,
} from "lucide-react";
import { cn } from "../lib/utils";
import { useDashboard, type DashboardStats } from "../lib/useDashboard";
function formatNumber(n: number | string): string {
const num = typeof n === "string" ? parseFloat(n) : n;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
return num.toLocaleString();
}
function formatUptime(ms: number): string {
const hours = Math.floor(ms / 3_600_000);
const minutes = Math.floor((ms % 3_600_000) / 60_000);
if (hours >= 24) {
const days = Math.floor(hours / 24);
return `${days}d ${hours % 24}h`;
}
return `${hours}h ${minutes}m`;
}
function timeAgo(ts: string | Date): string {
const diff = Date.now() - new Date(ts).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
const eventIcons = {
success: CheckCircle,
error: XCircle,
warn: AlertTriangle,
info: Info,
} as const;
const eventColors = {
success: "text-success",
error: "text-destructive",
warn: "text-warning",
info: "text-info",
} as const;
function StatCard({
icon: Icon,
label,
value,
sub,
accent = "border-primary",
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: string;
sub?: string;
accent?: string;
}) {
return (
<div
className={cn(
"bg-gradient-to-br from-card to-surface rounded-lg border border-border p-6 border-l-4",
accent
)}
>
<div className="flex items-center gap-3 mb-3">
<Icon className="w-5 h-5 text-primary" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-text-tertiary">
{label}
</span>
</div>
<div className="text-3xl font-bold font-display tracking-tight">{value}</div>
{sub && <div className="text-sm text-text-tertiary mt-1">{sub}</div>}
</div>
);
}
function LeaderboardColumn({
title,
icon: Icon,
entries,
valueKey,
valuePrefix,
}: {
title: string;
icon: React.ComponentType<{ className?: string }>;
entries: Array<{ username: string; [k: string]: unknown }>;
valueKey: string;
valuePrefix?: string;
}) {
return (
<div className="bg-card rounded-lg border border-border">
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
<Icon className="w-4 h-4 text-primary" />
<h3 className="text-sm font-semibold">{title}</h3>
</div>
<div className="divide-y divide-border">
{entries.length === 0 && (
<div className="px-5 py-4 text-sm text-text-tertiary">No data</div>
)}
{entries.slice(0, 10).map((entry, i) => (
<div
key={entry.username}
className="flex items-center justify-between px-5 py-3 hover:bg-raised/40 transition-colors"
>
<div className="flex items-center gap-3">
<span
className={cn(
"w-6 text-xs font-mono font-medium text-right",
i === 0
? "text-gold"
: i === 1
? "text-text-secondary"
: i === 2
? "text-warning"
: "text-text-tertiary"
)}
>
#{i + 1}
</span>
<span className="text-sm">{entry.username}</span>
</div>
<span className="text-sm font-mono text-text-secondary">
{valuePrefix}
{formatNumber(entry[valueKey] as string | number)}
</span>
</div>
))}
</div>
</div>
);
}
export default function Dashboard() {
const { data, loading, error } = useDashboard();
if (loading) {
return (
<div className="flex items-center justify-center py-32">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center py-32">
<div className="text-center">
<AlertTriangle className="w-8 h-8 text-warning mx-auto mb-3" />
<p className="text-sm text-text-tertiary">{error}</p>
</div>
</div>
);
}
if (!data) return null;
return <DashboardContent data={data} />;
}
function DashboardContent({ data }: { data: DashboardStats }) {
return (
<div className="space-y-8">
{/* Maintenance banner */}
{data.maintenanceMode && (
<div className="flex items-center gap-3 bg-warning/10 border border-warning/30 rounded-lg px-5 py-3">
<Wrench className="w-4 h-4 text-warning shrink-0" />
<span className="text-sm text-warning font-medium">
Maintenance mode is active
</span>
</div>
)}
{/* Stat cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
icon={Users}
label="Total Users"
value={formatNumber(data.users.total)}
sub={`${formatNumber(data.users.active)} active`}
accent="border-info"
/>
<StatCard
icon={Coins}
label="Total Wealth"
value={formatNumber(data.economy.totalWealth)}
accent="border-gold"
/>
<StatCard
icon={TrendingUp}
label="Avg Level"
value={data.economy.avgLevel.toFixed(1)}
sub={`Top streak: ${data.economy.topStreak}`}
accent="border-success"
/>
<StatCard
icon={Gift}
label="Active Lootdrops"
value={String(data.activeLootdrops?.length ?? 0)}
accent="border-primary"
/>
</div>
{/* Leaderboards */}
{data.leaderboards && (
<section>
<h2 className="font-display text-lg font-semibold mb-4">Leaderboards</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<LeaderboardColumn
title="Top Levels"
icon={Trophy}
entries={data.leaderboards.topLevels}
valueKey="level"
valuePrefix="Lv. "
/>
<LeaderboardColumn
title="Top Wealth"
icon={Crown}
entries={data.leaderboards.topWealth}
valueKey="balance"
/>
<LeaderboardColumn
title="Top Net Worth"
icon={Gem}
entries={data.leaderboards.topNetWorth}
valueKey="netWorth"
/>
</div>
</section>
)}
{/* Recent Events */}
{data.recentEvents.length > 0 && (
<section>
<h2 className="font-display text-lg font-semibold mb-4">Recent Events</h2>
<div className="bg-card rounded-lg border border-border divide-y divide-border">
{data.recentEvents.slice(0, 20).map((event, i) => {
const Icon = eventIcons[event.type];
return (
<div
key={i}
className="flex items-start gap-3 px-5 py-3 hover:bg-raised/40 transition-colors"
>
<Icon
className={cn("w-4 h-4 mt-0.5 shrink-0", eventColors[event.type])}
/>
<span className="text-sm flex-1">
{event.icon && <span className="mr-1.5">{event.icon}</span>}
{event.message}
</span>
<span className="text-xs text-text-tertiary font-mono whitespace-nowrap">
{timeAgo(event.timestamp)}
</span>
</div>
);
})}
</div>
</section>
)}
{/* Bot status footer */}
<footer className="flex flex-wrap items-center gap-x-6 gap-y-2 text-xs text-text-tertiary border-t border-border pt-6">
<span className="font-medium text-text-secondary">{data.bot.name}</span>
<span className="flex items-center gap-1.5">
<Wifi className="w-3 h-3" />
{Math.round(data.ping.avg)}ms
</span>
<span className="flex items-center gap-1.5">
<Clock className="w-3 h-3" />
{formatUptime(data.uptime)}
</span>
{data.bot.status && (
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-success" />
{data.bot.status}
</span>
)}
</footer>
</div>
);
}

View File

@@ -0,0 +1,470 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { Upload, Download, X, ImageIcon } from "lucide-react";
import { cn } from "../lib/utils";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CHECKERBOARD: React.CSSProperties = {
backgroundImage: `
linear-gradient(45deg, #444 25%, transparent 25%),
linear-gradient(-45deg, #444 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #444 75%),
linear-gradient(-45deg, transparent 75%, #444 75%)
`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
backgroundColor: "#2a2a2a",
};
// ---------------------------------------------------------------------------
// WebGL — shaders + init
// ---------------------------------------------------------------------------
const VERT = `
attribute vec2 aPos;
varying vec2 vUV;
void main() {
vUV = aPos * 0.5 + 0.5;
vUV.y = 1.0 - vUV.y;
gl_Position = vec4(aPos, 0.0, 1.0);
}`;
// RGB↔HSL math in GLSL — runs massively parallel on GPU
const FRAG = `
precision mediump float;
uniform sampler2D uImage;
uniform float uHueShift;
uniform float uSaturation;
uniform float uLightness;
varying vec2 vUV;
vec3 rgb2hsl(vec3 c) {
float maxC = max(c.r, max(c.g, c.b));
float minC = min(c.r, min(c.g, c.b));
float l = (maxC + minC) * 0.5;
float d = maxC - minC;
if (d < 0.001) return vec3(0.0, 0.0, l);
float s = l > 0.5 ? d / (2.0 - maxC - minC) : d / (maxC + minC);
float h;
if (maxC == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
else if (maxC == c.g) h = (c.b - c.r) / d + 2.0;
else h = (c.r - c.g) / d + 4.0;
return vec3(h / 6.0, s, l);
}
float hue2rgb(float p, float q, float t) {
if (t < 0.0) t += 1.0;
if (t > 1.0) t -= 1.0;
if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t;
if (t < 0.5) return q;
if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
return p;
}
vec3 hsl2rgb(vec3 hsl) {
float h = hsl.x, s = hsl.y, l = hsl.z;
if (s < 0.001) return vec3(l);
float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s;
float p = 2.0 * l - q;
return vec3(
hue2rgb(p, q, h + 1.0 / 3.0),
hue2rgb(p, q, h),
hue2rgb(p, q, h - 1.0 / 3.0)
);
}
void main() {
vec4 c = texture2D(uImage, vUV);
if (c.a < 0.001) { gl_FragColor = c; return; }
vec3 hsl = rgb2hsl(c.rgb);
hsl.x = fract(hsl.x + uHueShift + 1.0);
hsl.y = clamp(hsl.y + uSaturation, 0.0, 1.0);
hsl.z = clamp(hsl.z + uLightness, 0.0, 1.0);
gl_FragColor = vec4(hsl2rgb(hsl), c.a);
}`;
type GlState = {
gl: WebGLRenderingContext;
tex: WebGLTexture;
uHueShift: WebGLUniformLocation;
uSaturation: WebGLUniformLocation;
uLightness: WebGLUniformLocation;
};
function initGl(canvas: HTMLCanvasElement): GlState | null {
const gl = canvas.getContext("webgl", {
premultipliedAlpha: false,
preserveDrawingBuffer: true,
}) as WebGLRenderingContext | null;
if (!gl) return null;
const vert = gl.createShader(gl.VERTEX_SHADER)!;
gl.shaderSource(vert, VERT);
gl.compileShader(vert);
const frag = gl.createShader(gl.FRAGMENT_SHADER)!;
gl.shaderSource(frag, FRAG);
gl.compileShader(frag);
const prog = gl.createProgram()!;
gl.attachShader(prog, vert);
gl.attachShader(prog, frag);
gl.linkProgram(prog);
gl.useProgram(prog);
const buf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.STATIC_DRAW,
);
const aPos = gl.getAttribLocation(prog, "aPos");
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
return {
gl,
tex,
uHueShift: gl.getUniformLocation(prog, "uHueShift")!,
uSaturation: gl.getUniformLocation(prog, "uSaturation")!,
uLightness: gl.getUniformLocation(prog, "uLightness")!,
};
}
// ---------------------------------------------------------------------------
// Slider
// ---------------------------------------------------------------------------
function Slider({
label, value, min, max, unit, onChange, gradient,
}: {
label: string;
value: number;
min: number;
max: number;
unit: string;
onChange: (v: number) => void;
gradient?: string;
}) {
return (
<div className="space-y-2">
<div className="flex justify-between items-baseline">
<p className="text-xs font-medium text-text-secondary">{label}</p>
<span className="text-xs font-mono text-text-tertiary tabular-nums w-16 text-right">
{`${value > 0 ? "+" : ""}${value}${unit}`}
</span>
</div>
<div className="relative h-5 flex items-center">
{gradient && (
<div
className="absolute inset-x-0 h-2 rounded-full pointer-events-none"
style={{ background: gradient }}
/>
)}
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className={cn(
"relative z-10 w-full appearance-none bg-transparent cursor-pointer",
"[&::-webkit-slider-thumb]:appearance-none",
"[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4",
"[&::-webkit-slider-thumb]:-mt-1",
"[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white",
"[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-border",
"[&::-webkit-slider-thumb]:shadow-sm",
gradient
? "[&::-webkit-slider-runnable-track]:h-2 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-transparent"
: "accent-primary",
)}
/>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// HueShifter
// ---------------------------------------------------------------------------
export function HueShifter() {
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imageReady, setImageReady] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [hueShift, setHueShift] = useState(0);
const [saturation, setSaturation] = useState(0);
const [lightness, setLightness] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
// glCanvasRef — WebGL result canvas (also the preview)
const glCanvasRef = useRef<HTMLCanvasElement>(null);
const glRef = useRef<GlState | null>(null);
// ── Load image → upload texture once ──────────────────────────────────────
useEffect(() => {
if (!imageSrc) return;
setImageReady(false);
const img = new Image();
img.onload = () => {
const glCanvas = glCanvasRef.current;
if (!glCanvas) return;
glCanvas.width = img.naturalWidth;
glCanvas.height = img.naturalHeight;
if (!glRef.current) {
glRef.current = initGl(glCanvas);
if (!glRef.current) {
console.error("HueShifter: WebGL not supported");
return;
}
}
const { gl, tex } = glRef.current;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
gl.viewport(0, 0, img.naturalWidth, img.naturalHeight);
setImageReady(true);
};
img.src = imageSrc;
}, [imageSrc]);
// ── Re-render on GPU whenever sliders change ───────────────────────────────
useEffect(() => {
const state = glRef.current;
if (!state || !imageReady) return;
const { gl, uHueShift, uSaturation, uLightness } = state;
gl.uniform1f(uHueShift, hueShift / 360);
gl.uniform1f(uSaturation, saturation / 100);
gl.uniform1f(uLightness, lightness / 100);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}, [imageReady, hueShift, saturation, lightness]);
// ── Helpers ────────────────────────────────────────────────────────────────
const loadFile = useCallback((file: File) => {
if (imageSrc) URL.revokeObjectURL(imageSrc);
setImageSrc(URL.createObjectURL(file));
setImageFile(file);
setHueShift(0);
setSaturation(0);
setLightness(0);
}, [imageSrc]);
const clearAll = useCallback(() => {
if (imageSrc) URL.revokeObjectURL(imageSrc);
setImageSrc(null);
setImageFile(null);
setImageReady(false);
glRef.current = null;
if (fileInputRef.current) fileInputRef.current.value = "";
}, [imageSrc]);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file?.type.startsWith("image/")) loadFile(file);
};
const handleDownload = () => {
const glCanvas = glCanvasRef.current;
if (!glCanvas || !imageFile || !imageReady) return;
glCanvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.download = imageFile.name.replace(/\.[^.]+$/, "") + "_recolored.png";
a.href = url;
a.click();
URL.revokeObjectURL(url);
}, "image/png");
};
const handleReset = () => {
setHueShift(0);
setSaturation(0);
setLightness(0);
};
const isDefault = hueShift === 0 && saturation === 0 && lightness === 0;
// ── Upload screen ──────────────────────────────────────────────────────────
if (!imageSrc) {
return (
<div className="max-w-2xl mx-auto py-8 space-y-4">
<div>
<h2 className="text-lg font-semibold text-foreground mb-1">Hue Shifter</h2>
<p className="text-sm text-text-secondary">
Upload an image and shift its hue, saturation, and lightness to create colour
variants. Fully transparent pixels are preserved.
</p>
</div>
<div
onDrop={handleDrop}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onClick={() => fileInputRef.current?.click()}
className={cn(
"border-2 border-dashed rounded-xl p-16 flex flex-col items-center gap-4 cursor-pointer transition-all select-none",
dragOver
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-primary/3",
)}
>
<Upload
className={cn(
"w-10 h-10 transition-colors",
dragOver ? "text-primary" : "text-text-tertiary",
)}
/>
<div className="text-center">
<p className="text-sm font-medium text-text-secondary">
{dragOver ? "Drop to upload" : "Drop an image here"}
</p>
<p className="text-xs text-text-tertiary mt-1">
or click to browse · PNG, JPEG, WebP · max 15 MB
</p>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(e) => { const f = e.target.files?.[0]; if (f) loadFile(f); }}
className="hidden"
/>
</div>
);
}
// ── Editor screen ──────────────────────────────────────────────────────────
return (
<div className="space-y-4 pb-8">
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap">
<h2 className="text-lg font-semibold text-foreground flex-1">Hue Shifter</h2>
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
{imageFile?.name}
</span>
<button
onClick={handleReset}
disabled={isDefault}
className={cn(
"px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary transition-colors",
isDefault
? "opacity-40 cursor-not-allowed"
: "hover:text-foreground hover:border-primary/40",
)}
>
Reset
</button>
<button
onClick={clearAll}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
"hover:text-destructive hover:border-destructive transition-colors",
)}
>
<X className="w-3.5 h-3.5" /> Clear
</button>
<button
onClick={handleDownload}
disabled={!imageReady || isDefault}
className={cn(
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
imageReady && !isDefault
? "bg-primary text-white hover:bg-primary/90"
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
)}
>
<Download className="w-3.5 h-3.5" /> Download PNG
</button>
</div>
{/* Controls */}
<div className="bg-card border border-border rounded-xl p-5 space-y-5">
<Slider
label="Hue Shift"
value={hueShift}
min={-180}
max={180}
unit="°"
onChange={setHueShift}
gradient="linear-gradient(to right, hsl(0,80%,55%), hsl(60,80%,55%), hsl(120,80%,55%), hsl(180,80%,55%), hsl(240,80%,55%), hsl(300,80%,55%), hsl(360,80%,55%))"
/>
<Slider
label="Saturation"
value={saturation}
min={-100}
max={100}
unit="%"
onChange={setSaturation}
gradient="linear-gradient(to right, hsl(210,0%,50%), hsl(210,0%,55%) 50%, hsl(210,90%,55%))"
/>
<Slider
label="Lightness"
value={lightness}
min={-100}
max={100}
unit="%"
onChange={setLightness}
gradient="linear-gradient(to right, hsl(0,0%,10%), hsl(0,0%,50%) 50%, hsl(0,0%,92%))"
/>
</div>
{/* Side-by-side */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
Original
</p>
<div className="bg-card border border-border rounded-lg overflow-hidden">
<div style={CHECKERBOARD}>
<img src={imageSrc} alt="Original" className="w-full block" />
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
Result
</p>
<div className="bg-card border border-border rounded-lg overflow-hidden">
{/* WebGL canvas always in DOM; hidden until image is ready */}
<div style={CHECKERBOARD}>
<canvas
ref={glCanvasRef}
className={cn("w-full block", !imageReady && "hidden")}
/>
{!imageReady && (
<div className="aspect-square flex items-center justify-center">
<ImageIcon className="w-10 h-10 opacity-20 text-white" />
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,569 @@
import { useState, useEffect } from "react";
import {
Search,
ChevronLeft,
ChevronRight,
Package,
AlertTriangle,
Sparkles,
Scissors,
Crop,
Palette,
Maximize2,
} from "lucide-react";
import { cn } from "../lib/utils";
import { useItems, type Item } from "../lib/useItems";
import { ItemStudio } from "./ItemStudio";
import { BackgroundRemoval } from "./BackgroundRemoval";
import { CropTool } from "./CropTool";
import { HueShifter } from "./HueShifter";
import { CanvasTool } from "./CanvasTool";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatNumber(num: number | string): string {
const n = typeof num === "string" ? parseInt(num) : num;
return n.toLocaleString();
}
const RARITY_COLORS: Record<string, string> = {
C: "bg-gray-500/20 text-gray-400",
R: "bg-blue-500/20 text-blue-400",
SR: "bg-purple-500/20 text-purple-400",
SSR: "bg-amber-500/20 text-amber-400",
};
type Tab = "all" | "studio" | "bgremoval" | "crop" | "hue" | "canvas";
// ---------------------------------------------------------------------------
// SearchFilterBar
// ---------------------------------------------------------------------------
function SearchFilterBar({
search,
onSearchChange,
type,
onTypeChange,
rarity,
onRarityChange,
onClear,
}: {
search: string;
onSearchChange: (v: string) => void;
type: string | null;
onTypeChange: (v: string | null) => void;
rarity: string | null;
onRarityChange: (v: string | null) => void;
onClear: () => void;
}) {
return (
<div className="flex flex-wrap gap-3 items-center">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search items..."
className={cn(
"w-full bg-input border border-border rounded-md pl-10 pr-3 py-2 text-sm text-foreground",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
"transition-colors"
)}
/>
</div>
<select
value={type ?? ""}
onChange={(e) => onTypeChange(e.target.value || null)}
className={cn(
"bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
"transition-colors"
)}
>
<option value="">All Types</option>
<option value="MATERIAL">Material</option>
<option value="CONSUMABLE">Consumable</option>
<option value="EQUIPMENT">Equipment</option>
<option value="QUEST">Quest</option>
</select>
<select
value={rarity ?? ""}
onChange={(e) => onRarityChange(e.target.value || null)}
className={cn(
"bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
"transition-colors"
)}
>
<option value="">All Rarities</option>
<option value="C">Common (C)</option>
<option value="R">Rare (R)</option>
<option value="SR">Super Rare (SR)</option>
<option value="SSR">SSR</option>
</select>
<button
onClick={onClear}
className={cn(
"bg-input border border-border rounded-md px-3 py-2 text-sm text-text-secondary",
"hover:bg-destructive hover:text-white hover:border-destructive transition-colors"
)}
>
Clear
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// ItemTable
// ---------------------------------------------------------------------------
function ItemTable({
items,
loading,
onItemClick,
}: {
items: Item[];
loading: boolean;
onItemClick: (item: Item) => void;
}) {
const columns = ["ID", "Icon", "Name", "Type", "Rarity", "Price", "Description"];
if (loading) {
return (
<div className="bg-card border border-border rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-raised border-b border-border">
<tr>
{columns.map((col) => (
<th key={col} className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
{col}
</th>
))}
</tr>
</thead>
<tbody>
{[...Array(5)].map((_, i) => (
<tr key={i} className="border-b border-border">
{columns.map((col) => (
<td key={col} className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-20"></div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
if (items.length === 0) {
return (
<div className="bg-card border border-border rounded-lg p-12 text-center">
<Package className="w-16 h-16 mx-auto mb-4 text-text-tertiary" />
<p className="text-lg font-semibold text-text-secondary mb-2">
No items found
</p>
<p className="text-sm text-text-tertiary">
Try adjusting your search or filter criteria
</p>
</div>
);
}
return (
<div className="bg-card border border-border rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-raised border-b border-border">
<tr>
{columns.map((col) => (
<th key={col} className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
{col}
</th>
))}
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr
key={item.id}
className="border-b border-border hover:bg-raised transition-colors cursor-pointer"
onClick={() => onItemClick(item)}
title="Click to edit"
>
<td className="px-4 py-3">
<span className="text-sm font-mono text-text-tertiary">
{item.id}
</span>
</td>
<td className="px-4 py-3">
<img
src={item.iconUrl}
alt={item.name}
className="w-8 h-8 rounded object-cover bg-raised"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
</td>
<td className="px-4 py-3">
<span className="text-sm font-medium text-foreground">
{item.name}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-foreground capitalize">
{item.type.toLowerCase()}
</span>
</td>
<td className="px-4 py-3">
<span
className={cn(
"inline-flex items-center px-2 py-1 rounded-full text-xs font-medium",
RARITY_COLORS[item.rarity] ?? "bg-gray-500/20 text-gray-400"
)}
>
{item.rarity}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm font-mono text-foreground">
{item.price ? formatNumber(item.price) : "—"}
</span>
</td>
<td className="px-4 py-3 max-w-[200px]">
<span className="text-sm text-text-secondary truncate block">
{item.description || "—"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Pagination
// ---------------------------------------------------------------------------
function Pagination({
currentPage,
totalPages,
limit,
total,
onPageChange,
onLimitChange,
}: {
currentPage: number;
totalPages: number;
limit: number;
total: number;
onPageChange: (page: number) => void;
onLimitChange: (limit: number) => void;
}) {
const startItem = (currentPage - 1) * limit + 1;
const endItem = Math.min(currentPage * limit, total);
const getPageNumbers = () => {
const pages: (number | string)[] = [];
const showPages = 5;
const halfShow = Math.floor(showPages / 2);
let start = Math.max(1, currentPage - halfShow);
const end = Math.min(totalPages, start + showPages - 1);
if (end - start < showPages - 1) {
start = Math.max(1, end - showPages + 1);
}
if (start > 1) {
pages.push(1);
if (start > 2) pages.push("...");
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (end < totalPages) {
if (end < totalPages - 1) pages.push("...");
pages.push(totalPages);
}
return pages;
};
return (
<div className="mt-4 flex flex-wrap gap-4 items-center justify-between">
<p className="text-sm text-text-secondary">
Showing {startItem}{endItem} of {formatNumber(total)} items
</p>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className={cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
currentPage === 1
? "bg-raised text-text-tertiary cursor-not-allowed"
: "bg-input border border-border text-foreground hover:bg-raised"
)}
>
<ChevronLeft className="w-4 h-4" />
</button>
{getPageNumbers().map((page, i) =>
typeof page === "number" ? (
<button
key={i}
onClick={() => onPageChange(page)}
className={cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors min-w-[40px]",
page === currentPage
? "bg-primary text-white"
: "bg-input border border-border text-foreground hover:bg-raised"
)}
>
{page}
</button>
) : (
<span key={i} className="px-2 text-text-tertiary">
{page}
</span>
)
)}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className={cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
currentPage === totalPages
? "bg-raised text-text-tertiary cursor-not-allowed"
: "bg-input border border-border text-foreground hover:bg-raised"
)}
>
<ChevronRight className="w-4 h-4" />
</button>
<select
value={limit}
onChange={(e) => onLimitChange(Number(e.target.value))}
className={cn(
"ml-2 bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
"transition-colors"
)}
>
<option value="10">10 / page</option>
<option value="25">25 / page</option>
<option value="50">50 / page</option>
<option value="100">100 / page</option>
</select>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main Items Component
// ---------------------------------------------------------------------------
export default function Items() {
const {
items,
total,
currentPage,
limit,
setLimit,
filters,
setFilters,
setSearchDebounced,
setPage,
loading,
error,
refetch,
} = useItems();
const [activeTab, setActiveTab] = useState<Tab>("all");
const [searchInput, setSearchInput] = useState(filters.search);
const [editingItemId, setEditingItemId] = useState<number | null>(null);
useEffect(() => {
setSearchInput(filters.search);
}, [filters.search]);
const handleSearchChange = (value: string) => {
setSearchInput(value);
setSearchDebounced(value);
};
const handleClearFilters = () => {
setSearchInput("");
setFilters({ search: "", type: null, rarity: null });
};
const totalPages = Math.ceil(total / limit);
return (
<div className="flex flex-col h-full">
{/* Header */}
<header className="border-b border-border p-6 space-y-4">
<h1 className="text-2xl font-bold text-foreground">Items</h1>
{/* Tabs */}
<div className="flex gap-1 border-b border-border -mb-4 pb-px">
<button
onClick={() => setActiveTab("all")}
className={cn(
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
activeTab === "all"
? "border-primary text-primary"
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
)}
>
<Package className="w-4 h-4" />
All Items
</button>
<button
onClick={() => { setEditingItemId(null); setActiveTab("studio"); }}
className={cn(
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
activeTab === "studio"
? "border-primary text-primary"
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
)}
>
<Sparkles className="w-4 h-4" />
Item Studio
</button>
<button
onClick={() => setActiveTab("bgremoval")}
className={cn(
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
activeTab === "bgremoval"
? "border-primary text-primary"
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
)}
>
<Scissors className="w-4 h-4" />
Background Removal
</button>
<button
onClick={() => setActiveTab("crop")}
className={cn(
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
activeTab === "crop"
? "border-primary text-primary"
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
)}
>
<Crop className="w-4 h-4" />
Crop
</button>
<button
onClick={() => setActiveTab("hue")}
className={cn(
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
activeTab === "hue"
? "border-primary text-primary"
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
)}
>
<Palette className="w-4 h-4" />
Hue Shifter
</button>
<button
onClick={() => setActiveTab("canvas")}
className={cn(
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
activeTab === "canvas"
? "border-primary text-primary"
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
)}
>
<Maximize2 className="w-4 h-4" />
Canvas
</button>
</div>
</header>
{/* Error banner */}
{error && (
<div className="mx-6 mt-4 bg-destructive/10 border border-destructive/30 rounded-lg p-4 flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-semibold text-destructive">Error</p>
<p className="text-sm text-destructive/90">{error}</p>
</div>
</div>
)}
{/* Content */}
<div className="flex-1 overflow-auto p-6">
{activeTab === "all" ? (
<div className="space-y-4">
<SearchFilterBar
search={searchInput}
onSearchChange={handleSearchChange}
type={filters.type}
onTypeChange={(v) => setFilters({ type: v })}
rarity={filters.rarity}
onRarityChange={(v) => setFilters({ rarity: v })}
onClear={handleClearFilters}
/>
<ItemTable
items={items}
loading={loading}
onItemClick={(item) => {
setEditingItemId(item.id);
setActiveTab("studio");
}}
/>
{!loading && items.length > 0 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
limit={limit}
total={total}
onPageChange={setPage}
onLimitChange={setLimit}
/>
)}
</div>
) : activeTab === "studio" ? (
<ItemStudio
editItemId={editingItemId ?? undefined}
onSuccess={() => {
setEditingItemId(null);
refetch();
setActiveTab("all");
}}
/>
) : activeTab === "bgremoval" ? (
<BackgroundRemoval />
) : activeTab === "crop" ? (
<CropTool />
) : activeTab === "hue" ? (
<HueShifter />
) : (
<CanvasTool />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { Construction } from "lucide-react";
export default function PlaceholderPage({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<div className="flex flex-col items-center justify-center py-32 text-center">
<Construction className="w-10 h-10 text-text-tertiary mb-4" />
<h1 className="font-display text-2xl font-bold mb-2">{title}</h1>
<p className="text-sm text-text-tertiary max-w-md">{description}</p>
</div>
);
}

1411
panel/src/pages/Settings.tsx Normal file

File diff suppressed because it is too large Load Diff

1062
panel/src/pages/Users.tsx Normal file

File diff suppressed because it is too large Load Diff

23
panel/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"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,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

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

@@ -0,0 +1,29 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
port: 5173,
proxy: {
"/api": "http://localhost:3000",
"/auth": "http://localhost:3000",
"/assets": "http://localhost:3000",
"/ws": {
target: "ws://localhost:3000",
ws: true,
},
},
},
build: {
outDir: "dist",
assetsDir: "static",
},
});

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -10,6 +10,26 @@ import { relations, type InferSelectModel, type InferInsertModel } from 'drizzle
export type GuildSettings = InferSelectModel<typeof guildSettings>; export type GuildSettings = InferSelectModel<typeof guildSettings>;
export type GuildSettingsInsert = InferInsertModel<typeof guildSettings>; export type GuildSettingsInsert = InferInsertModel<typeof guildSettings>;
export interface GuildConfig {
studentRole?: string;
visitorRole?: string;
colorRoles: string[];
welcomeChannelId?: string;
welcomeMessage?: string;
feedbackChannelId?: string;
terminal?: {
channelId: string;
messageId: string;
};
moderation: {
cases: {
dmOnWarn: boolean;
logChannelId?: string;
autoTimeoutThreshold?: number;
};
};
}
export const guildSettings = pgTable('guild_settings', { export const guildSettings = pgTable('guild_settings', {
guildId: bigint('guild_id', { mode: 'bigint' }).primaryKey(), guildId: bigint('guild_id', { mode: 'bigint' }).primaryKey(),
studentRoleId: bigint('student_role_id', { mode: 'bigint' }), studentRoleId: bigint('student_role_id', { mode: 'bigint' }),

View File

@@ -1,29 +1,12 @@
import { jsonReplacer } from './utils'; import type {
import { readFileSync, existsSync, writeFileSync } from 'node:fs'; LevelingConfig,
import { join } from 'node:path'; EconomyConfig as EconomyConfigDB,
import { z } from 'zod'; InventoryConfig as InventoryConfigDB,
LootdropConfig,
const configPath = join(import.meta.dir, '..', 'config', 'config.json'); TriviaConfig as TriviaConfigDB,
ModerationConfig
export interface GuildConfig { } from "@db/schema/game-settings";
studentRole?: string; import type { GuildConfig } from "@db/schema/guild-settings";
visitorRole?: string;
colorRoles: string[];
welcomeChannelId?: string;
welcomeMessage?: string;
feedbackChannelId?: string;
terminal?: {
channelId: string;
messageId: string;
};
moderation: {
cases: {
dmOnWarn: boolean;
logChannelId?: string;
autoTimeoutThreshold?: number;
};
};
}
const guildConfigCache = new Map<string, { config: GuildConfig; timestamp: number }>(); const guildConfigCache = new Map<string, { config: GuildConfig; timestamp: number }>();
const CACHE_TTL_MS = 60000; const CACHE_TTL_MS = 60000;
@@ -82,16 +65,10 @@ export function invalidateGuildConfigCache(guildId: string) {
guildConfigCache.delete(guildId); guildConfigCache.delete(guildId);
} }
export interface LevelingConfig { // Re-export DB types
base: number; export type { LevelingConfig, LootdropConfig, ModerationConfig };
exponent: number;
chat: {
cooldownMs: number;
minXp: number;
maxXp: number;
};
}
// Runtime config types with BigInt for numeric fields
export interface EconomyConfig { export interface EconomyConfig {
daily: { daily: {
amount: bigint; amount: bigint;
@@ -114,18 +91,6 @@ export interface InventoryConfig {
maxSlots: number; maxSlots: number;
} }
export interface LootdropConfig {
activityWindowMs: number;
minMessages: number;
spawnChance: number;
cooldownMs: number;
reward: {
min: number;
max: number;
currency: string;
};
}
export interface TriviaConfig { export interface TriviaConfig {
entryFee: bigint; entryFee: bigint;
rewardMultiplier: number; rewardMultiplier: number;
@@ -135,20 +100,6 @@ export interface TriviaConfig {
difficulty: 'easy' | 'medium' | 'hard' | 'random'; difficulty: 'easy' | 'medium' | 'hard' | 'random';
} }
export interface ModerationConfig {
prune: {
maxAmount: number;
confirmThreshold: number;
batchSize: number;
batchDelayMs: number;
};
cases: {
dmOnWarn: boolean;
logChannelId?: string;
autoTimeoutThreshold?: number;
};
}
export interface GameConfigType { export interface GameConfigType {
leveling: LevelingConfig; leveling: LevelingConfig;
economy: EconomyConfig; economy: EconomyConfig;
@@ -158,160 +109,11 @@ export interface GameConfigType {
trivia: TriviaConfig; trivia: TriviaConfig;
moderation: ModerationConfig; moderation: ModerationConfig;
system: Record<string, unknown>; system: Record<string, unknown>;
studentRole: string;
visitorRole: string;
colorRoles: string[];
welcomeChannelId?: string;
welcomeMessage?: string;
feedbackChannelId?: string;
terminal?: {
channelId: string;
messageId: string;
};
} }
export const config: GameConfigType = {} as GameConfigType; export const config: GameConfigType = {} as GameConfigType;
const bigIntSchema = z.union([z.string(), z.number(), z.bigint()]) export const GameConfig = config;
.refine((val) => {
try {
BigInt(val);
return true;
} catch {
return false;
}
}, { message: "Must be a valid integer" })
.transform((val) => BigInt(val));
const fileConfigSchema = z.object({
leveling: z.object({
base: z.number(),
exponent: z.number(),
chat: z.object({
cooldownMs: z.number(),
minXp: z.number(),
maxXp: z.number(),
})
}),
economy: z.object({
daily: z.object({
amount: bigIntSchema,
streakBonus: bigIntSchema,
weeklyBonus: bigIntSchema.default(50n),
cooldownMs: z.number(),
}),
transfers: z.object({
allowSelfTransfer: z.boolean(),
minAmount: bigIntSchema,
}),
exam: z.object({
multMin: z.number(),
multMax: z.number(),
})
}),
inventory: z.object({
maxStackSize: bigIntSchema,
maxSlots: z.number(),
}),
commands: z.record(z.string(), z.boolean()),
lootdrop: z.object({
activityWindowMs: z.number(),
minMessages: z.number(),
spawnChance: z.number(),
cooldownMs: z.number(),
reward: z.object({
min: z.number(),
max: z.number(),
currency: z.string(),
})
}),
studentRole: z.string(),
visitorRole: z.string(),
colorRoles: z.array(z.string()).default([]),
welcomeChannelId: z.string().optional(),
welcomeMessage: z.string().optional(),
feedbackChannelId: z.string().optional(),
terminal: z.object({
channelId: z.string(),
messageId: z.string()
}).optional(),
moderation: z.object({
prune: z.object({
maxAmount: z.number().default(100),
confirmThreshold: z.number().default(50),
batchSize: z.number().default(100),
batchDelayMs: z.number().default(1000)
}),
cases: z.object({
dmOnWarn: z.boolean().default(true),
logChannelId: z.string().optional(),
autoTimeoutThreshold: z.number().optional()
})
}).default({
prune: {
maxAmount: 100,
confirmThreshold: 50,
batchSize: 100,
batchDelayMs: 1000
},
cases: {
dmOnWarn: true
}
}),
trivia: z.object({
entryFee: bigIntSchema,
rewardMultiplier: z.number().min(0).max(10),
timeoutSeconds: z.number().min(5).max(300),
cooldownMs: z.number().min(0),
categories: z.array(z.number()).default([]),
difficulty: z.enum(['easy', 'medium', 'hard', 'random']).default('random'),
}).default({
entryFee: 50n,
rewardMultiplier: 1.8,
timeoutSeconds: 30,
cooldownMs: 60000,
categories: [],
difficulty: 'random'
}),
system: z.record(z.string(), z.any()).default({}),
});
type FileConfig = z.infer<typeof fileConfigSchema>;
function loadFromFile(): FileConfig | null {
if (!existsSync(configPath)) {
return null;
}
try {
const raw = readFileSync(configPath, 'utf-8');
const rawConfig = JSON.parse(raw);
return fileConfigSchema.parse(rawConfig);
} catch (error) {
console.error("Failed to load config from file:", error);
return null;
}
}
function applyFileConfig(fileConfig: FileConfig) {
Object.assign(config, {
leveling: fileConfig.leveling,
economy: fileConfig.economy,
inventory: fileConfig.inventory,
commands: fileConfig.commands,
lootdrop: fileConfig.lootdrop,
trivia: fileConfig.trivia,
moderation: fileConfig.moderation,
system: fileConfig.system,
studentRole: fileConfig.studentRole,
visitorRole: fileConfig.visitorRole,
colorRoles: fileConfig.colorRoles,
welcomeChannelId: fileConfig.welcomeChannelId,
welcomeMessage: fileConfig.welcomeMessage,
feedbackChannelId: fileConfig.feedbackChannelId,
terminal: fileConfig.terminal,
});
}
async function loadFromDatabase(): Promise<boolean> { async function loadFromDatabase(): Promise<boolean> {
try { try {
@@ -358,88 +160,48 @@ async function loadFromDatabase(): Promise<boolean> {
return false; return false;
} }
async function loadDefaults(): Promise<void> {
console.warn("⚠️ No game config found in database. Using defaults.");
const { gameSettingsService } = await import('@shared/modules/game-settings/game-settings.service');
const defaults = gameSettingsService.getDefaults();
Object.assign(config, {
leveling: defaults.leveling,
economy: {
...defaults.economy,
daily: {
...defaults.economy.daily,
amount: BigInt(defaults.economy.daily.amount),
streakBonus: BigInt(defaults.economy.daily.streakBonus),
weeklyBonus: BigInt(defaults.economy.daily.weeklyBonus),
},
transfers: {
...defaults.economy.transfers,
minAmount: BigInt(defaults.economy.transfers.minAmount),
},
},
inventory: {
...defaults.inventory,
maxStackSize: BigInt(defaults.inventory.maxStackSize),
},
commands: defaults.commands,
lootdrop: defaults.lootdrop,
trivia: {
...defaults.trivia,
entryFee: BigInt(defaults.trivia.entryFee),
},
moderation: defaults.moderation,
system: defaults.system,
});
}
export async function reloadConfig(): Promise<void> { export async function reloadConfig(): Promise<void> {
const dbLoaded = await loadFromDatabase(); const dbLoaded = await loadFromDatabase();
if (!dbLoaded) { if (!dbLoaded) {
const fileConfig = loadFromFile(); await loadDefaults();
if (fileConfig) {
applyFileConfig(fileConfig);
console.log("📄 Game config loaded from file (database not available).");
} else {
console.warn("⚠️ No game config found in database or file. Using defaults.");
const { gameSettingsService } = await import('@shared/modules/game-settings/game-settings.service');
const defaults = gameSettingsService.getDefaults();
Object.assign(config, {
leveling: defaults.leveling,
economy: {
...defaults.economy,
daily: {
...defaults.economy.daily,
amount: BigInt(defaults.economy.daily.amount),
streakBonus: BigInt(defaults.economy.daily.streakBonus),
weeklyBonus: BigInt(defaults.economy.daily.weeklyBonus),
},
transfers: {
...defaults.economy.transfers,
minAmount: BigInt(defaults.economy.transfers.minAmount),
},
},
inventory: {
...defaults.inventory,
maxStackSize: BigInt(defaults.inventory.maxStackSize),
},
commands: defaults.commands,
lootdrop: defaults.lootdrop,
trivia: {
...defaults.trivia,
entryFee: BigInt(defaults.trivia.entryFee),
},
moderation: defaults.moderation,
system: defaults.system,
});
}
} }
} }
export function loadFileSync(): void {
const fileConfig = loadFromFile();
if (fileConfig) {
applyFileConfig(fileConfig);
console.log("📄 Game config loaded from file (sync).");
}
}
export const GameConfig = config;
export function saveConfig(newConfig: unknown) {
const validatedConfig = fileConfigSchema.parse(newConfig);
const jsonString = JSON.stringify(validatedConfig, jsonReplacer, 4);
writeFileSync(configPath, jsonString, 'utf-8');
applyFileConfig(validatedConfig);
console.log("🔄 Config saved to file.");
}
export function toggleCommand(commandName: string, enabled: boolean) {
const fileConfig = loadFromFile();
if (!fileConfig) {
console.error("Cannot toggle command: no file config available");
return;
}
const newConfig = {
...fileConfig,
commands: {
...fileConfig.commands,
[commandName]: enabled
}
};
saveConfig(newConfig);
}
export async function initializeConfig(): Promise<void> { export async function initializeConfig(): Promise<void> {
loadFileSync();
await reloadConfig(); await reloadConfig();
} }
loadFileSync();

Some files were not shown because too many files have changed in this diff Show More