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_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)
# Use a non-root user (see shared/scripts/setup-server.sh)
VPS_USER=deploy

View File

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

4
.gitignore vendored
View File

@@ -3,6 +3,7 @@ node_modules
docker-compose.override.yml
shared/db-logs
shared/db/data
shared/db/backups
shared/db/loga
.cursor
# dependencies (bun install)
@@ -46,5 +47,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
src/db/data
src/db/log
scratchpad/
tickets/
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");
```
### 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
try {
const result = await service.method();
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
} catch (error) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
console.error("Unexpected error:", error);
await interaction.editReply({
embeds: [createErrorEmbed("An unexpected error occurred.")],
});
}
}
import { withCommandErrorHandling } from "@lib/commandUtils";
export const myCommand = createCommand({
data: new SlashCommandBuilder()
.setName("mycommand")
.setDescription("Does something"),
execute: async (interaction) => {
await withCommandErrorHandling(
interaction,
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
@@ -240,3 +254,4 @@ describe("serviceName", () => {
| Environment | `shared/lib/env.ts` |
| Embed helpers | `bot/lib/embeds.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 package.json bun.lock ./
COPY panel/package.json panel/
# Install dependencies
RUN bun install --frozen-lockfile
@@ -33,3 +34,44 @@ EXPOSE 3000
# Default command
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)
![Drizzle ORM](https://img.shields.io/badge/Drizzle_ORM-0.30+-C5F74F)
![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.
**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, {
headers: {
"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 { authRoutes, isAuthenticated } from "./auth.routes";
import { healthRoutes } from "./health.routes";
import { statsRoutes } from "./stats.routes";
import { actionsRoutes } from "./actions.routes";
@@ -17,13 +18,16 @@ import { moderationRoutes } from "./moderation.routes";
import { transactionsRoutes } from "./transactions.routes";
import { lootdropsRoutes } from "./lootdrops.routes";
import { assetsRoutes } from "./assets.routes";
import { errorResponse } from "./utils";
/**
* All registered route modules in order of precedence.
* Routes are checked in order; the first matching route wins.
*/
const routeModules: RouteModule[] = [
/** Routes that do NOT require authentication */
const publicRoutes: RouteModule[] = [
authRoutes,
healthRoutes,
];
/** Routes that require an authenticated admin session */
const protectedRoutes: RouteModule[] = [
statsRoutes,
actionsRoutes,
questsRoutes,
@@ -58,14 +62,25 @@ export async function handleRequest(req: Request, url: URL): Promise<Response |
pathname: url.pathname,
};
// Try each route module in order
for (const module of routeModules) {
// Try public routes first (auth, health)
for (const module of publicRoutes) {
const response = await module.handler(ctx);
if (response !== null) {
return response;
if (response !== null) 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;
}
@@ -74,5 +89,5 @@ export async function handleRequest(req: Request, url: URL): Promise<Response |
* Useful for debugging and documentation.
*/
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 type { RouteContext, RouteModule } from "./types";
import type { CreateItemDTO, UpdateItemDTO } from "@shared/modules/items/items.service";
import {
jsonResponse,
errorResponse,
@@ -121,7 +122,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
return withErrorHandling(async () => {
const contentType = req.headers.get("content-type") || "";
let itemData: any;
let itemData: CreateItemDTO | null = null;
let imageFile: File | null = null;
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;
if (typeof jsonData === "string") {
itemData = JSON.parse(jsonData);
itemData = JSON.parse(jsonData) as CreateItemDTO;
} else {
return errorResponse("Missing item data", 400);
}
} else {
itemData = await req.json();
itemData = await req.json() as CreateItemDTO;
}
if (!itemData) {
return errorResponse("Missing item data", 400);
}
// Validate required fields
@@ -183,7 +188,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
const filePath = join(assetsDir, fileName);
await Bun.write(filePath, buffer);
const assetUrl = `/assets/items/${fileName}`;
const assetUrl = `/assets/items/${fileName}?v=${Date.now()}`;
await itemsService.updateItem(item.id, {
iconUrl: assetUrl,
imageUrl: assetUrl,
@@ -235,7 +240,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
if (!id) return null;
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);
if (!existing) {
@@ -250,7 +255,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
}
// Build update data
const updateData: any = {};
const updateData: Partial<UpdateItemDTO> = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description;
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);
await Bun.write(filePath, buffer);
const assetUrl = `/assets/items/${fileName}`;
const assetUrl = `/assets/items/${fileName}?v=${Date.now()}`;
const updatedItem = await itemsService.updateItem(id, {
iconUrl: assetUrl,
imageUrl: assetUrl,

View File

@@ -29,7 +29,7 @@ async function handler(ctx: RouteContext): Promise<Response | 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
@@ -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.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 });
}, "fetch moderation cases");
}
@@ -97,7 +97,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
const caseId = pathname.split("/").pop()!.toUpperCase();
return withErrorHandling(async () => {
const moderationCase = await ModerationService.getCaseById(caseId);
const moderationCase = await moderationService.getCaseById(caseId);
if (!moderationCase) {
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,
userId: data.userId,
username: data.username,
@@ -193,7 +193,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
return errorResponse("Missing required fields: clearedBy, clearedByName", 400);
}
const updatedCase = await ModerationService.clearCase({
const updatedCase = await moderationService.clearCase({
caseId,
clearedBy: data.clearedBy,
clearedByName: data.clearedByName,

View File

@@ -7,13 +7,6 @@
import type { RouteContext, RouteModule } from "./types";
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.
*
@@ -32,26 +25,31 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
/**
* @route GET /api/settings
* @description Returns the current bot configuration.
* @description Returns the current bot configuration from database.
* Configuration includes economy settings, leveling 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
*
* @example
* // Response
* {
* "economy": { "dailyReward": 100, "streakBonus": 10 },
* "leveling": { "xpPerMessage": 15, "levelUpChannel": "123456789" },
* "economy": { "daily": { "amount": "100", "streakBonus": "10" } },
* "leveling": { "base": 100, "exponent": 1.5 },
* "commands": { "disabled": [], "channelLocks": {} }
* }
*/
if (pathname === "/api/settings" && method === "GET") {
return withErrorHandling(async () => {
const { config } = await import("@shared/lib/config");
return new Response(JSON.stringify(config, jsonReplacer), {
headers: { "Content-Type": "application/json" }
});
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
const settings = await gameSettingsService.getSettings();
if (!settings) {
// Return defaults if no settings in DB yet
return jsonResponse(gameSettingsService.getDefaults());
}
return jsonResponse(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.
* 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 400 - Validation error
* @response 500 - Error saving settings
@@ -69,19 +67,15 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
* @example
* // Request - Only update economy daily reward
* POST /api/settings
* { "economy": { "dailyReward": 150 } }
* { "economy": { "daily": { "amount": "150" } } }
*/
if (pathname === "/api/settings" && method === "POST") {
try {
const partialConfig = await req.json();
const { saveConfig, config: currentConfig } = await import("@shared/lib/config");
const { deepMerge } = await import("@shared/lib/utils");
// Merge partial update into current config
const mergedConfig = deepMerge(currentConfig, partialConfig);
// saveConfig throws if validation fails
saveConfig(mergedConfig);
const partialConfig = await req.json() as Record<string, unknown>;
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
// Use upsertSettings to merge partial update
await gameSettingsService.upsertSettings(partialConfig as Record<string, unknown>);
const { systemEvents, EVENTS } = await import("@shared/lib/events");
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 jsonResponse({ roles, channels, commands });
return jsonResponse({ guildId: env.DISCORD_GUILD_ID, roles, channels, commands });
}, "fetch settings meta");
}

View File

@@ -132,6 +132,13 @@ mock.module("@shared/lib/utils", () => ({
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.module("@shared/lib/logger", () => ({
logger: {
@@ -403,8 +410,11 @@ describe("Items API", () => {
});
test("should prevent path traversal attacks", async () => {
const response = await fetch(`${baseUrl}/assets/../../../etc/passwd`);
// Should either return 403 (Forbidden) or 404 (Not found after sanitization)
// Note: fetch() and HTTP servers normalize ".." segments before the handler sees them,
// 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);
});
});

View File

@@ -1,40 +1,57 @@
import { describe, expect, it, mock, beforeEach, afterEach, jest } from "bun:test";
import { type WebServerInstance } from "./server";
// Mock the dependencies
const mockConfig = {
// Mock gameSettingsService — the route now uses this instead of config/saveConfig
const mockSettings = {
leveling: {
base: 100,
exponent: 1.5,
chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 }
},
economy: {
daily: { amount: 100n, streakBonus: 10n, weeklyBonus: 50n, cooldownMs: 86400000 },
transfers: { allowSelfTransfer: false, minAmount: 50n },
daily: { amount: "100", streakBonus: "10", weeklyBonus: "50", cooldownMs: 86400000 },
transfers: { allowSelfTransfer: false, minAmount: "1" },
exam: { multMin: 1.5, multMax: 2.5 }
},
inventory: { maxStackSize: 99n, maxSlots: 20 },
inventory: { maxStackSize: "99", maxSlots: 20 },
lootdrop: {
spawnChance: 0.1,
cooldownMs: 3600000,
minMessages: 10,
activityWindowMs: 300000,
reward: { min: 100, max: 500, currency: "gold" }
},
commands: { "help": true },
system: {},
moderation: {
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/lib/config", () => ({
config: mockConfig,
saveConfig: mockSaveConfig,
GameConfigType: {}
mock.module("@shared/modules/game-settings/game-settings.service", () => ({
gameSettingsService: {
getSettings: mockGetSettings,
upsertSettings: mockUpsertSettings,
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)
@@ -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 } from "./server";
@@ -104,6 +128,8 @@ describe("Settings API", () => {
beforeEach(async () => {
jest.clearAllMocks();
mockGetSettings.mockImplementation(() => Promise.resolve(mockSettings));
mockUpsertSettings.mockImplementation(() => Promise.resolve(mockSettings));
serverInstance = await createWebServer({ port: PORT, hostname: HOSTNAME });
});
@@ -117,18 +143,14 @@ describe("Settings API", () => {
const res = await fetch(`${BASE_URL}/api/settings`);
expect(res.status).toBe(200);
const data = await res.json();
// Check if BigInts are converted to strings
const data = await res.json() as any;
// Check values come through correctly
expect(data.economy.daily.amount).toBe("100");
expect(data.leveling.base).toBe(100);
});
it("POST /api/settings should save valid configuration via merge", async () => {
// We only send a partial update, expecting the server to merge it
// 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 partialConfig = { economy: { daily: { amount: "200" } } };
const res = await fetch(`${BASE_URL}/api/settings`, {
method: "POST",
@@ -137,26 +159,27 @@ describe("Settings API", () => {
});
expect(res.status).toBe(200);
// Expect saveConfig to be called with the MERGED result
expect(mockSaveConfig).toHaveBeenCalledWith(expect.objectContaining({
studentRole: "new-role-partial",
leveling: mockConfig.leveling // Should keep existing values
}));
// upsertSettings should be called with the partial config
expect(mockUpsertSettings).toHaveBeenCalledWith(
expect.objectContaining({
economy: { daily: { amount: "200" } }
})
);
});
it("POST /api/settings should return 400 when save fails", async () => {
mockSaveConfig.mockImplementationOnce(() => {
mockUpsertSettings.mockImplementationOnce(() => {
throw new Error("Validation failed");
});
const res = await fetch(`${BASE_URL}/api/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}) // Empty might be valid partial, but mocks throw
body: JSON.stringify({})
});
expect(res.status).toBe(400);
const data = await res.json();
const data = await res.json() as any;
expect(data.details).toBe("Validation failed");
});
@@ -164,7 +187,7 @@ describe("Settings API", () => {
const res = await fetch(`${BASE_URL}/api/settings/meta`);
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[0]).toEqual({ id: "role1", name: "Admin", color: "#ffffff" });
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 type { WebServerInstance } from "./server";
import { createWebServer } from "./server";
interface MockBotStats {
bot: { name: string; avatarUrl: string | null };
@@ -13,21 +12,21 @@ interface MockBotStats {
}
// 1. Mock DrizzleClient (dependency of dashboardService)
// Must provide full chainable builder for select().from().leftJoin().groupBy().orderBy().limit()
mock.module("@shared/db/DrizzleClient", () => {
const mockBuilder = {
where: mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }])),
then: (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]),
orderBy: mock(() => mockBuilder), // Chainable
limit: mock(() => Promise.resolve([])), // Terminal
};
const mockFrom = {
from: mock(() => mockBuilder),
};
const mockBuilder: Record<string, any> = {};
// Every chainable method returns mock builder; terminal calls return resolved promise
mockBuilder.where = mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]));
mockBuilder.then = (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]);
mockBuilder.orderBy = mock(() => mockBuilder);
mockBuilder.limit = mock(() => Promise.resolve([]));
mockBuilder.leftJoin = mock(() => mockBuilder);
mockBuilder.groupBy = mock(() => mockBuilder);
mockBuilder.from = mock(() => mockBuilder);
return {
DrizzleClient: {
select: mock(() => mockFrom),
select: mock(() => mockBuilder),
query: {
transactions: { 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", () => {
const port = 3001;

View File

@@ -7,10 +7,11 @@
* 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 { handleRequest } from "./routes";
import { getFullDashboardStats } from "./routes/stats.helper";
import { join } from "path";
export interface WebServerConfig {
port?: number;
@@ -38,6 +39,54 @@ export interface WebServerInstance {
* // To stop the server:
* 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> {
const { port = 3000, hostname = "localhost" } = config;
@@ -72,6 +121,11 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
const response = await handleRequest(req, url);
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
return new Response("Not Found", { status: 404 });
},

View File

@@ -1,7 +1,8 @@
import { createCommand } from "@shared/lib/utils";
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 { withCommandErrorHandling } from "@lib/commandUtils";
export const moderationCase = createCommand({
data: new SlashCommandBuilder()
@@ -16,39 +17,35 @@ export const moderationCase = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
await withCommandErrorHandling(
interaction,
async () => {
const caseId = interaction.options.getString("case_id", true).toUpperCase();
try {
const caseId = interaction.options.getString("case_id", true).toUpperCase();
// Validate case ID format
if (!caseId.match(/^CASE-\d+$/)) {
await interaction.editReply({
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
});
return;
}
// Validate case ID format
if (!caseId.match(/^CASE-\d+$/)) {
// 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: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
embeds: [getCaseEmbed(moderationCase)]
});
return;
}
// 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.")]
});
}
},
{ ephemeral: true }
);
}
});

View File

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

View File

@@ -1,7 +1,8 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service";
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { moderationService } from "@shared/modules/moderation/moderation.service";
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const clearwarning = createCommand({
data: new SlashCommandBuilder()
@@ -23,62 +24,58 @@ export const clearwarning = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
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 {
const caseId = interaction.options.getString("case_id", true).toUpperCase();
const reason = interaction.options.getString("reason") || "Cleared by moderator";
// Validate case ID format
if (!caseId.match(/^CASE-\d+$/)) {
await interaction.editReply({
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
});
return;
}
// Validate case ID format
if (!caseId.match(/^CASE-\d+$/)) {
await interaction.editReply({
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
// Check if case exists and is active
const existingCase = await moderationService.getCaseById(caseId);
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
const existingCase = await ModerationService.getCaseById(caseId);
if (!existingCase) {
// Send success message
await interaction.editReply({
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
embeds: [getClearSuccessEmbed(caseId)]
});
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
});
// 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.")]
});
}
},
{ ephemeral: true }
);
}
});

View File

@@ -1,10 +1,11 @@
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 { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { items } from "@db/schema";
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const createColor = createCommand({
data: new SlashCommandBuilder()
@@ -32,62 +33,60 @@ export const createColor = createCommand({
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
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);
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";
// 1. Validate Color
const colorRegex = /^#([0-9A-F]{3}){1,2}$/i;
if (!colorRegex.test(colorInput)) {
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] });
return;
}
// 1. Validate Color
const colorRegex = /^#([0-9A-F]{3}){1,2}$/i;
if (!colorRegex.test(colorInput)) {
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] });
return;
}
// 2. Create Role
const role = await interaction.guild?.roles.create({
name: name,
color: colorInput as any,
reason: `Created via /createcolor by ${interaction.user.tag}`
});
try {
// 2. 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) {
throw new Error("Failed to create role.");
}
if (!role) {
throw new Error("Failed to create role.");
}
// 3. Add to guild settings
await guildSettingsService.addColorRole(interaction.guildId!, role.id);
invalidateGuildConfigCache(interaction.guildId!);
// 3. Add to guild settings
await guildSettingsService.addColorRole(interaction.guildId!, role.id);
invalidateGuildConfigCache(interaction.guildId!);
// 4. Create Item
await DrizzleClient.insert(items).values({
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
await DrizzleClient.insert(items).values({
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
});
// 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}`)] });
}
// 5. Success
await interaction.editReply({
embeds: [createSuccessEmbed(
`**Role:** <@&${role.id}> (${colorInput})\n**Item:** Color Role - ${name}\n**Price:** ${price} 🪙`,
"✅ Color Role & Item Created"
)]
});
},
{ ephemeral: true }
);
}
});

View File

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

View File

@@ -7,12 +7,12 @@ import {
} from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { createErrorEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors";
import { items } from "@db/schema";
import { ilike, isNotNull, and, inArray } from "drizzle-orm";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { getShopListingMessage } from "@/modules/economy/shop.view";
import { EffectType, LootType } from "@shared/lib/constants";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const listing = createCommand({
data: new SlashCommandBuilder()
@@ -31,72 +31,67 @@ export const listing = createCommand({
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
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);
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
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' });
if (!targetChannel || !targetChannel.isSendable()) {
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
return;
}
}
}
const listingMessage = getShopListingMessage({
...item,
rarity: item.rarity || undefined,
formattedPrice: `${item.price} 🪙`,
price: item.price
}, context);
const item = await inventoryService.getItem(itemId);
if (!item) {
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
return;
}
try {
await targetChannel.send(listingMessage as any);
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
} catch (error: any) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
console.error("Error creating listing:", error);
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
}
}
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({
...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) => {
const focusedValue = interaction.options.getFocused();

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
import { config } from "@shared/lib/config";
import { PruneService } from "@shared/modules/moderation/prune.service";
import { pruneService } from "@shared/modules/moderation/prune.service";
import {
getConfirmationMessage,
getProgressEmbed,
@@ -10,6 +10,7 @@ import {
getPruneWarningEmbed,
getCancelledEmbed
} from "@/modules/moderation/prune.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const prune = createCommand({
data: new SlashCommandBuilder()
@@ -38,142 +39,126 @@ export const prune = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
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 {
const amount = interaction.options.getInteger("amount");
const user = interaction.options.getUser("user");
const all = interaction.options.getBoolean("all") || false;
// 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
// Validate inputs
if (!amount && !all) {
// Default to 10 messages
} else if (amount && all) {
await interaction.editReply({
embeds: [getSuccessEmbed(result)],
components: []
embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
});
} 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;
}
await interaction.editReply({
embeds: [getSuccessEmbed(result)]
});
}
const finalAmount = all ? 'all' : (amount || 10);
const confirmThreshold = config.moderation.prune.confirmThreshold;
} catch (error) {
console.error("Prune command error:", error);
// Check if confirmation is needed
const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
let errorMessage = "An unexpected error occurred while trying to delete messages.";
if (error instanceof Error) {
if (error.message.includes("permission")) {
errorMessage = "I don't have permission to delete messages in this channel.";
} else if (error.message.includes("channel type")) {
errorMessage = "This command cannot be used in this type of channel.";
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({
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 {
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({
embeds: [getPruneErrorEmbed(errorMessage)]
});
}
// 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;
}
await interaction.editReply({
embeds: [getSuccessEmbed(result)]
});
}
},
{ ephemeral: true }
);
}
});

View File

@@ -1,7 +1,8 @@
import { createCommand } from "@shared/lib/utils";
import { AuroraClient } from "@/lib/BotClient";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { createSuccessEmbed } from "@lib/embeds";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const refresh = createCommand({
data: new SlashCommandBuilder()
@@ -9,25 +10,24 @@ export const refresh = createCommand({
.setDescription("Reloads all commands and config without restarting")
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
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 {
const start = Date.now();
await AuroraClient.loadCommands(true);
const duration = Date.now() - start;
// Deploy commands
await AuroraClient.deployCommands();
// Deploy commands
await AuroraClient.deployCommands();
const embed = createSuccessEmbed(
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
"System Refreshed"
);
const embed = createSuccessEmbed(
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
"System Refreshed"
);
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")] });
}
await interaction.editReply({ embeds: [embed] });
},
{ ephemeral: true }
);
}
});

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service";
import { moderationService } from "@shared/modules/moderation/moderation.service";
import {
getWarnSuccessEmbed,
getModerationErrorEmbed,
getUserWarningEmbed
} from "@/modules/moderation/moderation.view";
import { getGuildConfig } from "@shared/lib/config";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const warn = createCommand({
data: new SlashCommandBuilder()
@@ -28,67 +28,63 @@ export const warn = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
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 {
const targetUser = interaction.options.getUser("user", true);
const reason = interaction.options.getString("reason", true);
// Don't allow warning bots
if (targetUser.bot) {
await interaction.editReply({
embeds: [getModerationErrorEmbed("You cannot warn bots.")]
});
return;
}
// Don't allow warning bots
if (targetUser.bot) {
// Don't allow self-warnings
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({
embeds: [getModerationErrorEmbed("You cannot warn bots.")]
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
});
return;
}
// Don't allow self-warnings
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({
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.")]
});
}
// 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
});
}
},
{ ephemeral: true }
);
}
});

View File

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

View File

@@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { createErrorEmbed } from "@/lib/embeds";
import { sendWebhookMessage } from "@/lib/webhookUtils";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const webhook = createCommand({
data: new SlashCommandBuilder()
@@ -14,43 +15,40 @@ export const webhook = createCommand({
.setRequired(true)
),
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);
let payload;
try {
payload = JSON.parse(payloadString);
} catch (error) {
await interaction.editReply({
embeds: [createErrorEmbed("The provided payload is not valid JSON.", "Invalid JSON")]
});
return;
}
try {
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;
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 interaction.editReply({
embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")]
});
return;
}
await sendWebhookMessage(
channel,
payload,
interaction.client.user,
`Proxy message requested by ${interaction.user.tag}`
);
try {
await sendWebhookMessage(
channel,
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")]
});
}
await interaction.editReply({ content: "Message sent successfully!" });
},
{ ephemeral: true }
);
}
});

View File

@@ -2,35 +2,29 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { economyService } from "@shared/modules/economy/economy.service";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors";
import { createSuccessEmbed } from "@lib/embeds";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const daily = createCommand({
data: new SlashCommandBuilder()
.setName("daily")
.setDescription("Claim your daily reward"),
execute: async (interaction) => {
await interaction.deferReply();
try {
const result = await economyService.claimDaily(interaction.user.id);
await withCommandErrorHandling(
interaction,
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!")
.addFields(
{ 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: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R> `, inline: true }
)
.setColor("Gold");
const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!")
.addFields(
{ 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: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R> `, inline: true }
)
.setColor("Gold");
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.")] });
await interaction.editReply({ embeds: [embed] });
}
}
);
}
});

View File

@@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
import { withCommandErrorHandling } from "@lib/commandUtils";
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
@@ -10,66 +11,62 @@ export const exam = createCommand({
.setName("exam")
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
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 {
// First, try to take the exam or check status
const result = await examService.takeExam(interaction.user.id);
if (result.status === ExamStatus.NOT_REGISTERED) {
// Register the user
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) {
// Register the user
const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username);
const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000);
await interaction.editReply({
embeds: [createSuccessEmbed(
`You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
`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({
embeds: [createSuccessEmbed(
`You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
`Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
"Exam Registration Successful"
`**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!"
)]
});
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 { config } from "@shared/lib/config";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const pay = createCommand({
data: new SlashCommandBuilder()
@@ -50,20 +50,14 @@ export const pay = createCommand({
return;
}
try {
await interaction.deferReply();
await economyService.transfer(senderId, receiverId.toString(), amount);
await withCommandErrorHandling(
interaction,
async () => {
await economyService.transfer(senderId, receiverId.toString(), amount);
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
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.")] });
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
}
}
);
}
});

View File

@@ -6,6 +6,7 @@ import { createErrorEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors";
import { config } from "@shared/lib/config";
import { TriviaCategory } from "@shared/lib/constants";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const trivia = createCommand({
data: new SlashCommandBuilder()
@@ -53,64 +54,54 @@ export const trivia = createCommand({
return;
}
// User can play - defer publicly for trivia question
await interaction.deferReply();
// User can play - use standardized error handling for the main operation
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)
const session = await triviaService.startTrivia(
interaction.user.id,
interaction.user.username,
categoryId ? parseInt(categoryId) : undefined
// 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
}
);
// 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) {
// Handle errors from the pre-defer canPlayTrivia check
if (error instanceof UserError) {
// Check if we've already deferred
if (interaction.deferred) {
await interaction.editReply({
embeds: [createErrorEmbed(error.message)]
});
} else {
await interaction.reply({
embeds: [createErrorEmbed(error.message)],
ephemeral: true
});
}
await interaction.reply({
embeds: [createErrorEmbed(error.message)],
ephemeral: true
});
} else {
console.error("Error in trivia command:", error);
// Check if we've already deferred
if (interaction.deferred) {
await interaction.editReply({
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
});
}
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 { createErrorEmbed } from "@lib/embeds";
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
import type { ItemUsageData } from "@shared/lib/types";
import { UserError } from "@shared/lib/errors";
import { withCommandErrorHandling } from "@lib/commandUtils";
import { getGuildConfig } from "@shared/lib/config";
export const use = createCommand({
@@ -19,57 +18,50 @@ export const use = createCommand({
.setAutocomplete(true)
),
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 colorRoles = guildConfig.colorRoles ?? [];
const itemId = interaction.options.getNumber("item", true);
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 user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
if (!user) {
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
return;
}
const result = await inventoryService.useItem(user.id.toString(), itemId);
try {
const result = await inventoryService.useItem(user.id.toString(), itemId);
const usageData = result.usageData;
if (usageData) {
for (const effect of usageData.effects) {
if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') {
try {
const member = await interaction.guild?.members.fetch(user.id.toString());
if (member) {
if (effect.type === 'TEMP_ROLE') {
await member.roles.add(effect.roleId);
} else if (effect.type === 'COLOR_ROLE') {
// Remove existing color roles
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);
const usageData = result.usageData;
if (usageData) {
for (const effect of usageData.effects) {
if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') {
try {
const member = await interaction.guild?.members.fetch(user.id.toString());
if (member) {
if (effect.type === 'TEMP_ROLE') {
await member.roles.add(effect.roleId);
} else if (effect.type === 'COLOR_ROLE') {
// Remove existing color roles
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) => {
const focusedValue = interaction.options.getFocused();

View File

@@ -3,7 +3,7 @@ import { env } from "@shared/lib/env";
import { join } from "node:path";
import { initializeConfig } from "@shared/lib/config";
import { startWebServerFromRoot } from "../web/src/server";
import { startWebServerFromRoot } from "../api/src/server";
// Initialize config from database
await initializeConfig();
@@ -18,7 +18,7 @@ console.log("🌐 Starting web server...");
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 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";
// Mock DrizzleClient
mock.module("./DrizzleClient", () => ({
// Mock DrizzleClient — must match the import path used in db.ts
mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: {
transaction: async (cb: any) => cb("MOCK_TX")
}

View File

@@ -1,7 +1,8 @@
import { levelingService } from "@shared/modules/leveling/leveling.service";
import { economyService } from "@shared/modules/economy/economy.service";
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 { inventoryService } from "@shared/modules/inventory/inventory.service";
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
};
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);
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);
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;
};
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 expiresAt = new Date(Date.now() + boostDuration * 1000);
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`;
};
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 roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
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`;
};
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";
};
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[];
if (!pool || pool.length === 0) return "The box is empty...";

View File

@@ -7,7 +7,10 @@ import {
handleColorRole,
handleLootbox
} 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> = {
'ADD_XP': handleAddXp,
@@ -18,3 +21,21 @@ export const effectHandlers: Record<string, EffectHandler> = {
'COLOR_ROLE': handleColorRole,
'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 { 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>;

345
bun.lock
View File

@@ -20,8 +20,71 @@
"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": {
"@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/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=="],
"@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-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=="],
"@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/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=="],
"@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/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/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=="],
"@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=="],
"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=="],
"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=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"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=="],
@@ -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=="],
"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-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=="],
"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=="],
"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-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
"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-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.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=="],
"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=="],
"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-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=="],
"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=="],
"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=="],
"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=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@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=="],
"@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-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],

View File

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

View File

@@ -47,6 +47,10 @@ services:
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
- 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:
db:
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",
"type": "module",
"private": true,
"workspaces": ["panel"],
"devDependencies": {
"@types/bun": "latest",
"drizzle-kit": "^0.31.8"
@@ -12,19 +13,29 @@
"typescript": "^5.9.3"
},
"scripts": {
"generate": "docker compose run --rm app drizzle-kit generate",
"migrate": "docker compose run --rm app drizzle-kit migrate",
"dev": "bun --watch bot/index.ts",
"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:local": "drizzle-kit push",
"dev": "bun --watch bot/index.ts",
"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-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'",
"remote": "bash shared/scripts/remote.sh",
"logs": "bash shared/scripts/logs.sh",
"db:backup": "bash shared/scripts/db-backup.sh",
"test": "bun test",
"test": "bash shared/scripts/test-sequential.sh",
"test:ci": "bash shared/scripts/test-sequential.sh --integration",
"test:simulate-ci": "bash shared/scripts/simulate-ci.sh",
"panel:dev": "cd panel && bun run dev",
"panel:build": "cd panel && bun run build",
"deploy": "bash shared/scripts/deploy.sh",
"deploy:remote": "bash shared/scripts/deploy-remote.sh",
"setup-server": "bash shared/scripts/setup-server.sh",
"docker:cleanup": "bash shared/scripts/docker-cleanup.sh"
},
"dependencies": {
@@ -35,4 +46,4 @@
"postgres": "^3.4.8",
"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,
"tag": "0004_bored_kat_farrell",
"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 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', {
guildId: bigint('guild_id', { mode: 'bigint' }).primaryKey(),
studentRoleId: bigint('student_role_id', { mode: 'bigint' }),

View File

@@ -1,29 +1,12 @@
import { jsonReplacer } from './utils';
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { z } from 'zod';
const configPath = join(import.meta.dir, '..', 'config', 'config.json');
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;
};
};
}
import type {
LevelingConfig,
EconomyConfig as EconomyConfigDB,
InventoryConfig as InventoryConfigDB,
LootdropConfig,
TriviaConfig as TriviaConfigDB,
ModerationConfig
} from "@db/schema/game-settings";
import type { GuildConfig } from "@db/schema/guild-settings";
const guildConfigCache = new Map<string, { config: GuildConfig; timestamp: number }>();
const CACHE_TTL_MS = 60000;
@@ -82,16 +65,10 @@ export function invalidateGuildConfigCache(guildId: string) {
guildConfigCache.delete(guildId);
}
export interface LevelingConfig {
base: number;
exponent: number;
chat: {
cooldownMs: number;
minXp: number;
maxXp: number;
};
}
// Re-export DB types
export type { LevelingConfig, LootdropConfig, ModerationConfig };
// Runtime config types with BigInt for numeric fields
export interface EconomyConfig {
daily: {
amount: bigint;
@@ -114,18 +91,6 @@ export interface InventoryConfig {
maxSlots: number;
}
export interface LootdropConfig {
activityWindowMs: number;
minMessages: number;
spawnChance: number;
cooldownMs: number;
reward: {
min: number;
max: number;
currency: string;
};
}
export interface TriviaConfig {
entryFee: bigint;
rewardMultiplier: number;
@@ -135,20 +100,6 @@ export interface TriviaConfig {
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 {
leveling: LevelingConfig;
economy: EconomyConfig;
@@ -158,160 +109,11 @@ export interface GameConfigType {
trivia: TriviaConfig;
moderation: ModerationConfig;
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;
const bigIntSchema = z.union([z.string(), z.number(), z.bigint()])
.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,
});
}
export const GameConfig = config;
async function loadFromDatabase(): Promise<boolean> {
try {
@@ -358,88 +160,48 @@ async function loadFromDatabase(): Promise<boolean> {
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> {
const dbLoaded = await loadFromDatabase();
if (!dbLoaded) {
const fileConfig = loadFromFile();
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,
});
}
await loadDefaults();
}
}
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> {
loadFileSync();
await reloadConfig();
}
loadFileSync();

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