Compare commits
63 Commits
5e8683a19f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10c84a8478 | ||
|
|
9eba64621a | ||
|
|
7cc2f61db6 | ||
|
|
f5fecb59cb | ||
|
|
65f5663c97 | ||
| de83307adc | |||
|
|
15e01906a3 | ||
|
|
fed27c0227 | ||
|
|
9751e62e30 | ||
|
|
87d5aa259c | ||
|
|
f0bfaecb0b | ||
|
|
9471b6fdab | ||
|
|
04e5851387 | ||
| 1a59c9e796 | |||
|
|
251616fe15 | ||
|
|
fbb2e0f010 | ||
|
|
dc10ad5c37 | ||
|
|
2381f073ba | ||
|
|
121c242168 | ||
|
|
942875e8d0 | ||
|
|
878e3306eb | ||
|
|
aca5538d57 | ||
|
|
f822d90dd3 | ||
|
|
141c3098f8 | ||
|
|
0c67a8754f | ||
|
|
bf20c61190 | ||
|
|
099601ce6d | ||
|
|
55d2376ca1 | ||
|
|
6eb4a32a12 | ||
|
|
2d35a5eabb | ||
|
|
570cdc69c1 | ||
|
|
c2b1fb6db1 | ||
|
|
d15d53e839 | ||
|
|
58374d1746 | ||
|
|
ae6a068197 | ||
|
|
43d32918ab | ||
|
|
0bc254b728 | ||
|
|
610d97bde3 | ||
|
|
babccfd08a | ||
|
|
ee7d63df3e | ||
|
|
5f107d03a7 | ||
|
|
1ff24b0f7f | ||
|
|
a5e3534260 | ||
|
|
228005322e | ||
|
|
67a3aa4b0f | ||
|
|
64804f7066 | ||
|
|
73ad889018 | ||
|
|
9c7f1e4418 | ||
|
|
efb50916b2 | ||
|
|
6abb52694e | ||
|
|
76968e31a6 | ||
|
|
29bf0e6f1c | ||
|
|
8c306fbd23 | ||
|
|
b0c3baf5b7 | ||
|
|
f575588b9a | ||
|
|
553b9b4952 | ||
|
|
073348fa55 | ||
|
|
4232674494 | ||
|
|
fbf1e52c28 | ||
|
|
20284dc57b | ||
|
|
36f9c76fa9 | ||
|
|
46e95ce7b3 | ||
|
|
9acd3f3d76 |
7
.citrine
Normal file
7
.citrine
Normal 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
|
||||||
@@ -20,6 +20,13 @@ DISCORD_BOT_TOKEN=your-discord-bot-token
|
|||||||
DISCORD_CLIENT_ID=your-discord-client-id
|
DISCORD_CLIENT_ID=your-discord-client-id
|
||||||
DISCORD_GUILD_ID=your-discord-guild-id
|
DISCORD_GUILD_ID=your-discord-guild-id
|
||||||
|
|
||||||
|
# Admin Panel (Discord OAuth)
|
||||||
|
# Get client secret from: https://discord.com/developers/applications → OAuth2
|
||||||
|
DISCORD_CLIENT_SECRET=your-discord-client-secret
|
||||||
|
SESSION_SECRET=change-me-to-a-random-string
|
||||||
|
ADMIN_USER_IDS=123456789012345678
|
||||||
|
PANEL_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
# Server (for remote access scripts)
|
# Server (for remote access scripts)
|
||||||
# Use a non-root user (see shared/scripts/setup-server.sh)
|
# Use a non-root user (see shared/scripts/setup-server.sh)
|
||||||
VPS_USER=deploy
|
VPS_USER=deploy
|
||||||
|
|||||||
6
.github/workflows/deploy.yml
vendored
6
.github/workflows/deploy.yml
vendored
@@ -43,9 +43,7 @@ jobs:
|
|||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: |
|
run: bun install --frozen-lockfile
|
||||||
bun install --frozen-lockfile
|
|
||||||
cd web && bun install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Create Config File
|
- name: Create Config File
|
||||||
run: |
|
run: |
|
||||||
@@ -97,6 +95,6 @@ jobs:
|
|||||||
ADMIN_TOKEN="admin_token_123"
|
ADMIN_TOKEN="admin_token_123"
|
||||||
LOG_LEVEL="error"
|
LOG_LEVEL="error"
|
||||||
EOF
|
EOF
|
||||||
bash shared/scripts/test-sequential.sh
|
bash shared/scripts/test-sequential.sh --integration
|
||||||
env:
|
env:
|
||||||
NODE_ENV: test
|
NODE_ENV: test
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,6 +3,7 @@ node_modules
|
|||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
shared/db-logs
|
shared/db-logs
|
||||||
shared/db/data
|
shared/db/data
|
||||||
|
shared/db/backups
|
||||||
shared/db/loga
|
shared/db/loga
|
||||||
.cursor
|
.cursor
|
||||||
# dependencies (bun install)
|
# dependencies (bun install)
|
||||||
@@ -46,5 +47,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
src/db/data
|
src/db/data
|
||||||
src/db/log
|
src/db/log
|
||||||
scratchpad/
|
scratchpad/
|
||||||
tickets/
|
|
||||||
bot/assets/graphics/items
|
bot/assets/graphics/items
|
||||||
|
tickets/
|
||||||
|
.citrine.local
|
||||||
|
|||||||
69
AGENTS.md
69
AGENTS.md
@@ -2,17 +2,16 @@
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
AuroraBot is a Discord bot with a web dashboard built using Bun, Discord.js, React, and PostgreSQL with Drizzle ORM.
|
AuroraBot is a Discord bot with a REST API built using Bun, Discord.js, and PostgreSQL with Drizzle ORM.
|
||||||
|
|
||||||
## Build/Lint/Test Commands
|
## Build/Lint/Test Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development
|
# Development
|
||||||
bun --watch bot/index.ts # Run bot with hot reload
|
bun --watch bot/index.ts # Run bot + API server with hot reload
|
||||||
bun --hot web/src/index.ts # Run web dashboard with hot reload
|
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
bun test # Run all tests ( expect some tests to fail when running all at once like this due to the nature of the tests )
|
bun test # Run all tests
|
||||||
bun test path/to/file.test.ts # Run single test file
|
bun test path/to/file.test.ts # Run single test file
|
||||||
bun test --watch # Watch mode
|
bun test --watch # Watch mode
|
||||||
bun test shared/modules/economy # Run tests in directory
|
bun test shared/modules/economy # Run tests in directory
|
||||||
@@ -24,9 +23,10 @@ bun run db:push # Push schema changes (Docker)
|
|||||||
bun run db:push:local # Push schema changes (local)
|
bun run db:push:local # Push schema changes (local)
|
||||||
bun run db:studio # Open Drizzle Studio
|
bun run db:studio # Open Drizzle Studio
|
||||||
|
|
||||||
# Web Dashboard
|
# Docker (recommended for local dev)
|
||||||
cd web && bun run build # Build production web assets
|
docker compose up # Start bot, API, and database
|
||||||
cd web && bun run dev # Development server
|
docker compose up app # Start just the app (bot + API)
|
||||||
|
docker compose up db # Start just the database
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
@@ -44,10 +44,8 @@ shared/ # Shared between bot and web
|
|||||||
├── lib/ # Utils, config, errors, types
|
├── lib/ # Utils, config, errors, types
|
||||||
└── modules/ # Domain services (economy, user, etc.)
|
└── modules/ # Domain services (economy, user, etc.)
|
||||||
|
|
||||||
web/ # React dashboard
|
web/ # API server
|
||||||
├── src/pages/ # React pages
|
└── src/routes/ # API route handlers
|
||||||
├── src/components/ # UI components (ShadCN/Radix)
|
|
||||||
└── src/hooks/ # React hooks
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Import Conventions
|
## Import Conventions
|
||||||
@@ -143,22 +141,36 @@ throw new UserError("You don't have enough coins!");
|
|||||||
throw new SystemError("Database connection failed");
|
throw new SystemError("Database connection failed");
|
||||||
```
|
```
|
||||||
|
|
||||||
### Standard Error Pattern
|
### Recommended: `withCommandErrorHandling`
|
||||||
|
|
||||||
|
Use the `withCommandErrorHandling` utility from `@lib/commandUtils` to standardize
|
||||||
|
error handling across all commands. It handles `deferReply`, `UserError` display,
|
||||||
|
and unexpected error logging automatically.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
try {
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
const result = await service.method();
|
|
||||||
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
export const myCommand = createCommand({
|
||||||
} catch (error) {
|
data: new SlashCommandBuilder()
|
||||||
if (error instanceof UserError) {
|
.setName("mycommand")
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
.setDescription("Does something"),
|
||||||
} else {
|
execute: async (interaction) => {
|
||||||
console.error("Unexpected error:", error);
|
await withCommandErrorHandling(
|
||||||
await interaction.editReply({
|
interaction,
|
||||||
embeds: [createErrorEmbed("An unexpected error occurred.")],
|
async () => {
|
||||||
});
|
const result = await service.method();
|
||||||
}
|
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
||||||
}
|
},
|
||||||
|
{ ephemeral: true } // optional: makes the deferred reply ephemeral
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `ephemeral` — whether `deferReply` should be ephemeral
|
||||||
|
- `successMessage` — a simple string to send on success
|
||||||
|
- `onSuccess` — a callback invoked with the operation result
|
||||||
```
|
```
|
||||||
|
|
||||||
## Database Patterns
|
## Database Patterns
|
||||||
@@ -187,7 +199,7 @@ return await withTransaction(async (tx) => {
|
|||||||
|
|
||||||
- Use `bigint` mode for Discord IDs and currency amounts
|
- Use `bigint` mode for Discord IDs and currency amounts
|
||||||
- Relations defined separately from table definitions
|
- Relations defined separately from table definitions
|
||||||
- Schema location: `shared/db/schema.ts`
|
- Schema modules: `shared/db/schema/*.ts` (users, inventory, economy, quests, moderation)
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -224,9 +236,9 @@ describe("serviceName", () => {
|
|||||||
|
|
||||||
- **Runtime:** Bun 1.0+
|
- **Runtime:** Bun 1.0+
|
||||||
- **Bot:** Discord.js 14.x
|
- **Bot:** Discord.js 14.x
|
||||||
- **Web:** React 19 + Bun HTTP Server
|
- **Web:** Bun HTTP Server (REST API)
|
||||||
- **Database:** PostgreSQL 16+ with Drizzle ORM
|
- **Database:** PostgreSQL 16+ with Drizzle ORM
|
||||||
- **UI:** Tailwind CSS v4 + ShadCN/Radix
|
- **UI:** Discord embeds and components
|
||||||
- **Validation:** Zod
|
- **Validation:** Zod
|
||||||
- **Testing:** Bun Test
|
- **Testing:** Bun Test
|
||||||
- **Container:** Docker
|
- **Container:** Docker
|
||||||
@@ -242,3 +254,4 @@ describe("serviceName", () => {
|
|||||||
| Environment | `shared/lib/env.ts` |
|
| Environment | `shared/lib/env.ts` |
|
||||||
| Embed helpers | `bot/lib/embeds.ts` |
|
| Embed helpers | `bot/lib/embeds.ts` |
|
||||||
| Command utils | `shared/lib/utils.ts` |
|
| Command utils | `shared/lib/utils.ts` |
|
||||||
|
| Error handler | `bot/lib/commandUtils.ts` |
|
||||||
|
|||||||
187
CLAUDE.md
Normal file
187
CLAUDE.md
Normal 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` |
|
||||||
51
Dockerfile
51
Dockerfile
@@ -16,11 +16,10 @@ FROM base AS deps
|
|||||||
|
|
||||||
# Copy only package files first (better layer caching)
|
# Copy only package files first (better layer caching)
|
||||||
COPY package.json bun.lock ./
|
COPY package.json bun.lock ./
|
||||||
COPY web/package.json web/bun.lock ./web/
|
COPY panel/package.json panel/
|
||||||
|
|
||||||
# Install all dependencies in one layer
|
# Install dependencies
|
||||||
RUN bun install --frozen-lockfile && \
|
RUN bun install --frozen-lockfile
|
||||||
cd web && bun install --frozen-lockfile
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Development stage - for local dev with volume mounts
|
# Development stage - for local dev with volume mounts
|
||||||
@@ -29,7 +28,6 @@ FROM base AS development
|
|||||||
|
|
||||||
# Copy dependencies from deps stage
|
# Copy dependencies from deps stage
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY --from=deps /app/web/node_modules ./web/node_modules
|
|
||||||
|
|
||||||
# Expose ports
|
# Expose ports
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
@@ -38,19 +36,42 @@ EXPOSE 3000
|
|||||||
CMD ["bun", "run", "dev"]
|
CMD ["bun", "run", "dev"]
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Production stage - full app with source code
|
# Builder stage - copies source for production
|
||||||
# ============================================
|
# ============================================
|
||||||
FROM base AS production
|
FROM base AS builder
|
||||||
|
|
||||||
# Copy dependencies from deps stage
|
# Copy source code first, then deps on top (so node_modules aren't overwritten)
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY --from=deps /app/web/node_modules ./web/node_modules
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
|
||||||
# Expose ports
|
# 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
|
EXPOSE 3000
|
||||||
|
|
||||||
# Default command
|
# Health check
|
||||||
CMD ["bun", "run", "dev"]
|
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"]
|
||||||
|
|||||||
@@ -1,57 +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
|
|
||||||
|
|
||||||
# Install web project dependencies
|
|
||||||
COPY web/package.json web/bun.lock ./web/
|
|
||||||
RUN cd web && bun install --frozen-lockfile
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build web assets for production
|
|
||||||
RUN cd web && bun run build
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 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/node_modules ./web/node_modules
|
|
||||||
COPY --from=builder --chown=bun:bun /app/web/dist ./web/dist
|
|
||||||
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"]
|
|
||||||
31
README.md
31
README.md
@@ -7,11 +7,10 @@
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||

|
|
||||||
|
|
||||||
Aurora is a powerful Discord bot designed to facilitate RPG-like elements within a Discord server. It features a robust economy, class system, inventory management, quests, and more, all built on top of a high-performance stack using Bun and Drizzle ORM.
|
Aurora is a powerful Discord bot designed to facilitate RPG-like elements within a Discord server. It features a robust economy, class system, inventory management, quests, and more, all built on top of a high-performance stack using Bun and Drizzle ORM.
|
||||||
|
|
||||||
**New in v1.0:** Aurora now includes a fully integrated **Web Dashboard** for managing the bot, viewing statistics, and configuring settings, running alongside the bot in a single process.
|
**New in v1.0:** Aurora now includes a fully integrated **REST API** for accessing bot data, statistics, and configuration, running alongside the bot in a single process.
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
@@ -25,26 +24,26 @@ Aurora is a powerful Discord bot designed to facilitate RPG-like elements within
|
|||||||
* **Lootdrops**: Random loot drops in channels to engage users.
|
* **Lootdrops**: Random loot drops in channels to engage users.
|
||||||
* **Admin Tools**: Administrative commands for server management.
|
* **Admin Tools**: Administrative commands for server management.
|
||||||
|
|
||||||
### Web Dashboard
|
### REST API
|
||||||
* **Live Analytics**: View real-time activity charts (commands, transactions).
|
* **Live Analytics**: Real-time statistics endpoint (commands, transactions).
|
||||||
* **Configuration Management**: Update bot settings without restarting.
|
* **Configuration Management**: Update bot settings via API.
|
||||||
* **Database Inspection**: Integrated Drizzle Studio access.
|
* **Database Inspection**: Integrated Drizzle Studio access.
|
||||||
* **State Monitoring**: View internal bot state (Lootdrops, etc.).
|
* **WebSocket Support**: Real-time event streaming for live updates.
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
|
|
||||||
Aurora uses a **Single Process Monolith** architecture to maximize performance and simplify resource sharing.
|
Aurora uses a **Single Process Monolith** architecture to maximize performance and simplify resource sharing.
|
||||||
|
|
||||||
* **Unified Runtime**: Both the Discord Client and the Web Dashboard run within the same Bun process.
|
* **Unified Runtime**: Both the Discord Client and the REST API run within the same Bun process.
|
||||||
* **Shared State**: This allows the Dashboard to access live bot memory (caches, gateways) directly without complex inter-process communication (IPC).
|
* **Shared State**: This allows the API to access live bot memory (caches, gateways) directly without complex inter-process communication (IPC).
|
||||||
* **Simplified Deployment**: You only need to deploy a single Docker container.
|
* **Simplified Deployment**: You only need to deploy a single Docker container.
|
||||||
|
|
||||||
## 🛠️ Tech Stack
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
* **Runtime**: [Bun](https://bun.sh/)
|
* **Runtime**: [Bun](https://bun.sh/)
|
||||||
* **Bot Framework**: [Discord.js](https://discord.js.org/)
|
* **Bot Framework**: [Discord.js](https://discord.js.org/)
|
||||||
* **Web Framework**: [React 19](https://react.dev/) + [Vite](https://vitejs.dev/) (served via Bun)
|
* **API Framework**: Bun HTTP Server (REST API)
|
||||||
* **Styling**: [Tailwind CSS v4](https://tailwindcss.com/) + [Radix UI](https://www.radix-ui.com/)
|
* **UI**: Discord embeds and components
|
||||||
* **Database**: [PostgreSQL](https://www.postgresql.org/)
|
* **Database**: [PostgreSQL](https://www.postgresql.org/)
|
||||||
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
||||||
* **Validation**: [Zod](https://zod.dev/)
|
* **Validation**: [Zod](https://zod.dev/)
|
||||||
@@ -94,14 +93,14 @@ Aurora uses a **Single Process Monolith** architecture to maximize performance a
|
|||||||
bun run db:push
|
bun run db:push
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running the Bot & Dashboard
|
### Running the Bot & API
|
||||||
|
|
||||||
**Development Mode** (with hot reload):
|
**Development Mode** (with hot reload):
|
||||||
```bash
|
```bash
|
||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
* Bot: Online in Discord
|
* Bot: Online in Discord
|
||||||
* Dashboard: http://localhost:3000
|
* API: http://localhost:3000
|
||||||
|
|
||||||
**Production Mode**:
|
**Production Mode**:
|
||||||
Build and run with Docker (recommended):
|
Build and run with Docker (recommended):
|
||||||
@@ -111,7 +110,7 @@ docker compose up -d app
|
|||||||
|
|
||||||
### 🔐 Accessing Production Services (SSH Tunnel)
|
### 🔐 Accessing Production Services (SSH Tunnel)
|
||||||
|
|
||||||
For security, the Production Database and Dashboard are **not exposed** to the public internet by default. They are only accessible via localhost on the server.
|
For security, the Production Database and API are **not exposed** to the public internet by default. They are only accessible via localhost on the server.
|
||||||
|
|
||||||
To access them from your local machine, use the included SSH tunnel script.
|
To access them from your local machine, use the included SSH tunnel script.
|
||||||
|
|
||||||
@@ -127,12 +126,12 @@ To access them from your local machine, use the included SSH tunnel script.
|
|||||||
```
|
```
|
||||||
|
|
||||||
This will establish secure tunnels for:
|
This will establish secure tunnels for:
|
||||||
* **Dashboard**: http://localhost:3000
|
* **API**: http://localhost:3000
|
||||||
* **Drizzle Studio**: http://localhost:4983
|
* **Drizzle Studio**: http://localhost:4983
|
||||||
|
|
||||||
## 📜 Scripts
|
## 📜 Scripts
|
||||||
|
|
||||||
* `bun run dev`: Start the bot and dashboard in watch mode.
|
* `bun run dev`: Start the bot and API server in watch mode.
|
||||||
* `bun run remote`: Open SSH tunnel to production services.
|
* `bun run remote`: Open SSH tunnel to production services.
|
||||||
* `bun run generate`: Generate Drizzle migrations.
|
* `bun run generate`: Generate Drizzle migrations.
|
||||||
* `bun run migrate`: Apply migrations (via Docker).
|
* `bun run migrate`: Apply migrations (via Docker).
|
||||||
@@ -143,7 +142,7 @@ This will establish secure tunnels for:
|
|||||||
|
|
||||||
```
|
```
|
||||||
├── bot # Discord Bot logic & entry point
|
├── bot # Discord Bot logic & entry point
|
||||||
├── web # React Web Dashboard (Frontend + Server)
|
├── web # REST API Server
|
||||||
├── shared # Shared code (Database, Config, Types)
|
├── shared # Shared code (Database, Config, Types)
|
||||||
├── drizzle # Drizzle migration files
|
├── drizzle # Drizzle migration files
|
||||||
├── scripts # Utility scripts
|
├── scripts # Utility scripts
|
||||||
|
|||||||
0
web/.gitignore → api/.gitignore
vendored
0
web/.gitignore → api/.gitignore
vendored
30
api/README.md
Normal file
30
api/README.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Aurora Web API
|
||||||
|
|
||||||
|
The web API provides a REST interface and WebSocket support for accessing Aurora bot data and configuration.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
- `GET /api/stats` - Real-time bot statistics
|
||||||
|
- `GET /api/settings` - Bot configuration
|
||||||
|
- `GET /api/users` - User data
|
||||||
|
- `GET /api/items` - Item catalog
|
||||||
|
- `GET /api/quests` - Quest information
|
||||||
|
- `GET /api/transactions` - Economy data
|
||||||
|
- `GET /api/health` - Health check
|
||||||
|
|
||||||
|
## WebSocket
|
||||||
|
|
||||||
|
Connect to `/ws` for real-time updates:
|
||||||
|
- Stats broadcasts every 5 seconds
|
||||||
|
- Event notifications via system bus
|
||||||
|
- PING/PONG heartbeat support
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
The API runs automatically when you start the bot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will be available at `http://localhost:3000`
|
||||||
0
web/bun-env.d.ts → api/bun-env.d.ts
vendored
0
web/bun-env.d.ts → api/bun-env.d.ts
vendored
106
api/src/routes/actions.routes.ts
Normal file
106
api/src/routes/actions.routes.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Administrative action endpoints for Aurora API.
|
||||||
|
* Provides endpoints for system administration tasks like cache clearing
|
||||||
|
* and maintenance mode toggling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import { jsonResponse, errorResponse, parseBody, withErrorHandling } from "./utils";
|
||||||
|
import { MaintenanceModeSchema } from "./schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin actions routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - POST /api/actions/reload-commands - Reload bot slash commands
|
||||||
|
* - POST /api/actions/clear-cache - Clear internal caches
|
||||||
|
* - POST /api/actions/maintenance-mode - Toggle maintenance mode
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method, req } = ctx;
|
||||||
|
|
||||||
|
// Only handle POST requests to /api/actions/*
|
||||||
|
if (!pathname.startsWith("/api/actions/") || method !== "POST") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { actionService } = await import("@shared/modules/admin/action.service");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/actions/reload-commands
|
||||||
|
* @description Triggers a reload of all Discord slash commands.
|
||||||
|
* Useful after modifying command configurations.
|
||||||
|
* @response 200 - `{ success: true, message: string }`
|
||||||
|
* @response 500 - Error reloading commands
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* POST /api/actions/reload-commands
|
||||||
|
*
|
||||||
|
* // Response
|
||||||
|
* { "success": true, "message": "Commands reloaded" }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/actions/reload-commands") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const result = await actionService.reloadCommands();
|
||||||
|
return jsonResponse(result);
|
||||||
|
}, "reload commands");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/actions/clear-cache
|
||||||
|
* @description Clears all internal application caches.
|
||||||
|
* Useful for forcing fresh data fetches.
|
||||||
|
* @response 200 - `{ success: true, message: string }`
|
||||||
|
* @response 500 - Error clearing cache
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* POST /api/actions/clear-cache
|
||||||
|
*
|
||||||
|
* // Response
|
||||||
|
* { "success": true, "message": "Cache cleared" }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/actions/clear-cache") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const result = await actionService.clearCache();
|
||||||
|
return jsonResponse(result);
|
||||||
|
}, "clear cache");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/actions/maintenance-mode
|
||||||
|
* @description Toggles bot maintenance mode on or off.
|
||||||
|
* When enabled, the bot will respond with a maintenance message.
|
||||||
|
*
|
||||||
|
* @body { enabled: boolean, reason?: string }
|
||||||
|
* @response 200 - `{ success: true, enabled: boolean }`
|
||||||
|
* @response 400 - Invalid payload with validation errors
|
||||||
|
* @response 500 - Error toggling maintenance mode
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* POST /api/actions/maintenance-mode
|
||||||
|
* Content-Type: application/json
|
||||||
|
* { "enabled": true, "reason": "Deploying updates..." }
|
||||||
|
*
|
||||||
|
* // Response
|
||||||
|
* { "success": true, "enabled": true }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/actions/maintenance-mode") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const data = await parseBody(req, MaintenanceModeSchema);
|
||||||
|
if (data instanceof Response) return data;
|
||||||
|
|
||||||
|
const result = await actionService.toggleMaintenanceMode(data.enabled, data.reason);
|
||||||
|
return jsonResponse(result);
|
||||||
|
}, "toggle maintenance mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionsRoutes: RouteModule = {
|
||||||
|
name: "actions",
|
||||||
|
handler
|
||||||
|
};
|
||||||
83
api/src/routes/assets.routes.ts
Normal file
83
api/src/routes/assets.routes.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Static asset serving for Aurora API.
|
||||||
|
* Serves item images and other assets from the local filesystem.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { join, resolve, dirname } from "path";
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
|
||||||
|
// Resolve assets root directory
|
||||||
|
const currentDir = dirname(new URL(import.meta.url).pathname);
|
||||||
|
const assetsRoot = resolve(currentDir, "../../../bot/assets/graphics");
|
||||||
|
|
||||||
|
/** MIME types for supported image formats */
|
||||||
|
const MIME_TYPES: Record<string, string> = {
|
||||||
|
"png": "image/png",
|
||||||
|
"jpg": "image/jpeg",
|
||||||
|
"jpeg": "image/jpeg",
|
||||||
|
"webp": "image/webp",
|
||||||
|
"gif": "image/gif",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assets routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /assets/* - Serve static files from the assets directory
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method } = ctx;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /assets/*
|
||||||
|
* @description Serves static asset files (images) with caching headers.
|
||||||
|
* Assets are served from the bot's graphics directory.
|
||||||
|
*
|
||||||
|
* Path security: Path traversal attacks are prevented by validating
|
||||||
|
* that the resolved path stays within the assets root.
|
||||||
|
*
|
||||||
|
* @response 200 - File content with appropriate MIME type
|
||||||
|
* @response 403 - Forbidden (path traversal attempt)
|
||||||
|
* @response 404 - File not found
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* GET /assets/items/1.png
|
||||||
|
*
|
||||||
|
* // Response Headers
|
||||||
|
* Content-Type: image/png
|
||||||
|
* Cache-Control: public, max-age=86400
|
||||||
|
*/
|
||||||
|
if (pathname.startsWith("/assets/") && method === "GET") {
|
||||||
|
const assetPath = pathname.replace("/assets/", "");
|
||||||
|
|
||||||
|
// Security: prevent path traversal attacks
|
||||||
|
const safePath = join(assetsRoot, assetPath);
|
||||||
|
if (!safePath.startsWith(assetsRoot)) {
|
||||||
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = Bun.file(safePath);
|
||||||
|
if (await file.exists()) {
|
||||||
|
// Determine MIME type based on extension
|
||||||
|
const ext = safePath.split(".").pop()?.toLowerCase();
|
||||||
|
const contentType = MIME_TYPES[ext || ""] || "application/octet-stream";
|
||||||
|
|
||||||
|
return new Response(file, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const assetsRoutes: RouteModule = {
|
||||||
|
name: "assets",
|
||||||
|
handler
|
||||||
|
};
|
||||||
233
api/src/routes/auth.routes.ts
Normal file
233
api/src/routes/auth.routes.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Discord OAuth2 authentication routes for the admin panel.
|
||||||
|
* Handles login flow, callback, logout, and session management.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import { jsonResponse, errorResponse } from "./utils";
|
||||||
|
import { logger } from "@shared/lib/logger";
|
||||||
|
|
||||||
|
// In-memory session store: token → { discordId, username, avatar, expiresAt }
|
||||||
|
export interface Session {
|
||||||
|
discordId: string;
|
||||||
|
username: string;
|
||||||
|
avatar: string | null;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = new Map<string, Session>();
|
||||||
|
const redirects = new Map<string, string>(); // redirect token -> return_to URL
|
||||||
|
|
||||||
|
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
|
||||||
|
function getEnv(key: string): string {
|
||||||
|
const val = process.env[key];
|
||||||
|
if (!val) throw new Error(`Missing env: ${key}`);
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAdminIds(): string[] {
|
||||||
|
const raw = process.env.ADMIN_USER_IDS ?? "";
|
||||||
|
return raw.split(",").map(s => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateToken(): string {
|
||||||
|
const bytes = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBaseUrl(): string {
|
||||||
|
return process.env.PANEL_BASE_URL ?? `http://localhost:3000`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookies(header: string | null): Record<string, string> {
|
||||||
|
if (!header) return {};
|
||||||
|
const cookies: Record<string, string> = {};
|
||||||
|
for (const pair of header.split(";")) {
|
||||||
|
const [key, ...rest] = pair.trim().split("=");
|
||||||
|
if (key) cookies[key] = rest.join("=");
|
||||||
|
}
|
||||||
|
return cookies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get session from request cookie */
|
||||||
|
export function getSession(req: Request): Session | null {
|
||||||
|
const cookies = parseCookies(req.headers.get("cookie"));
|
||||||
|
const token = cookies["aurora_session"];
|
||||||
|
if (!token) return null;
|
||||||
|
const session = sessions.get(token);
|
||||||
|
if (!session) return null;
|
||||||
|
if (Date.now() > session.expiresAt) {
|
||||||
|
sessions.delete(token);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if request is authenticated as admin */
|
||||||
|
export function isAuthenticated(req: Request): boolean {
|
||||||
|
return getSession(req) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method } = ctx;
|
||||||
|
|
||||||
|
// GET /auth/discord — redirect to Discord OAuth
|
||||||
|
if (pathname === "/auth/discord" && method === "GET") {
|
||||||
|
try {
|
||||||
|
const clientId = getEnv("DISCORD_CLIENT_ID");
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
const redirectUri = encodeURIComponent(`${baseUrl}/auth/callback`);
|
||||||
|
const scope = "identify+email";
|
||||||
|
|
||||||
|
// Store return_to URL if provided
|
||||||
|
const returnTo = ctx.url.searchParams.get("return_to") || "/";
|
||||||
|
const redirectToken = generateToken();
|
||||||
|
redirects.set(redirectToken, returnTo);
|
||||||
|
|
||||||
|
const url = `https://discord.com/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}`;
|
||||||
|
|
||||||
|
// Set a temporary cookie with the redirect token
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
Location: url,
|
||||||
|
"Set-Cookie": `aurora_redirect=${redirectToken}; Path=/; Max-Age=600; SameSite=Lax`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("auth", "Failed to initiate OAuth", e);
|
||||||
|
return errorResponse("OAuth not configured", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /auth/callback — handle Discord OAuth callback
|
||||||
|
if (pathname === "/auth/callback" && method === "GET") {
|
||||||
|
const code = ctx.url.searchParams.get("code");
|
||||||
|
if (!code) return errorResponse("Missing code parameter", 400);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const clientId = getEnv("DISCORD_CLIENT_ID");
|
||||||
|
const clientSecret = getEnv("DISCORD_CLIENT_SECRET");
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
const redirectUri = `${baseUrl}/auth/callback`;
|
||||||
|
|
||||||
|
// Exchange code for token
|
||||||
|
const tokenRes = await fetch("https://discord.com/api/oauth2/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenRes.ok) {
|
||||||
|
logger.error("auth", `Token exchange failed: ${tokenRes.status}`);
|
||||||
|
return errorResponse("OAuth token exchange failed", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await tokenRes.json() as { access_token: string };
|
||||||
|
|
||||||
|
// Fetch user info
|
||||||
|
const userRes = await fetch("https://discord.com/api/users/@me", {
|
||||||
|
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userRes.ok) {
|
||||||
|
return errorResponse("Failed to fetch Discord user", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await userRes.json() as { id: string; username: string; avatar: string | null };
|
||||||
|
|
||||||
|
// Check allowlist
|
||||||
|
const adminIds = getAdminIds();
|
||||||
|
if (adminIds.length > 0 && !adminIds.includes(user.id)) {
|
||||||
|
logger.warn("auth", `Unauthorized login attempt by ${user.username} (${user.id})`);
|
||||||
|
return new Response(
|
||||||
|
`<html><body><h1>Access Denied</h1><p>Your Discord account is not authorized.</p></body></html>`,
|
||||||
|
{ status: 403, headers: { "Content-Type": "text/html" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const token = generateToken();
|
||||||
|
sessions.set(token, {
|
||||||
|
discordId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
avatar: user.avatar,
|
||||||
|
expiresAt: Date.now() + SESSION_MAX_AGE,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("auth", `Admin login: ${user.username} (${user.id})`);
|
||||||
|
|
||||||
|
// Get return_to URL from redirect token cookie
|
||||||
|
const cookies = parseCookies(ctx.req.headers.get("cookie"));
|
||||||
|
const redirectToken = cookies["aurora_redirect"];
|
||||||
|
let returnTo = redirectToken && redirects.get(redirectToken) ? redirects.get(redirectToken)! : "/";
|
||||||
|
if (redirectToken) redirects.delete(redirectToken);
|
||||||
|
|
||||||
|
// Only allow redirects to localhost or relative paths (prevent open redirect)
|
||||||
|
try {
|
||||||
|
const parsed = new URL(returnTo, baseUrl);
|
||||||
|
if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") {
|
||||||
|
returnTo = "/";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
returnTo = "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to panel with session cookie
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
Location: returnTo,
|
||||||
|
"Set-Cookie": `aurora_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE / 1000}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("auth", "OAuth callback error", e);
|
||||||
|
return errorResponse("Authentication failed", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /auth/logout — clear session
|
||||||
|
if (pathname === "/auth/logout" && method === "POST") {
|
||||||
|
const cookies = parseCookies(ctx.req.headers.get("cookie"));
|
||||||
|
const token = cookies["aurora_session"];
|
||||||
|
if (token) sessions.delete(token);
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Set-Cookie": "aurora_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /auth/me — return current session info
|
||||||
|
if (pathname === "/auth/me" && method === "GET") {
|
||||||
|
const session = getSession(ctx.req);
|
||||||
|
if (!session) return jsonResponse({ authenticated: false }, 401);
|
||||||
|
return jsonResponse({
|
||||||
|
authenticated: true,
|
||||||
|
user: {
|
||||||
|
discordId: session.discordId,
|
||||||
|
username: session.username,
|
||||||
|
avatar: session.avatar,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authRoutes: RouteModule = {
|
||||||
|
name: "auth",
|
||||||
|
handler,
|
||||||
|
};
|
||||||
155
api/src/routes/classes.routes.ts
Normal file
155
api/src/routes/classes.routes.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Class management endpoints for Aurora API.
|
||||||
|
* Provides CRUD operations for player classes/guilds.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import {
|
||||||
|
jsonResponse,
|
||||||
|
errorResponse,
|
||||||
|
parseBody,
|
||||||
|
parseStringIdFromPath,
|
||||||
|
withErrorHandling
|
||||||
|
} from "./utils";
|
||||||
|
import { CreateClassSchema, UpdateClassSchema } from "./schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classes routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/classes - List all classes
|
||||||
|
* - POST /api/classes - Create a new class
|
||||||
|
* - PUT /api/classes/:id - Update a class
|
||||||
|
* - DELETE /api/classes/:id - Delete a class
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method, req } = ctx;
|
||||||
|
|
||||||
|
// Only handle requests to /api/classes*
|
||||||
|
if (!pathname.startsWith("/api/classes")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { classService } = await import("@shared/modules/class/class.service");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/classes
|
||||||
|
* @description Returns all classes/guilds in the system.
|
||||||
|
*
|
||||||
|
* @response 200 - `{ classes: Class[] }`
|
||||||
|
* @response 500 - Error fetching classes
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Response
|
||||||
|
* {
|
||||||
|
* "classes": [
|
||||||
|
* { "id": "1", "name": "Warrior", "balance": "5000", "roleId": "123456789" }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/classes" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const classes = await classService.getAllClasses();
|
||||||
|
return jsonResponse({ classes });
|
||||||
|
}, "fetch classes");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/classes
|
||||||
|
* @description Creates a new class/guild.
|
||||||
|
*
|
||||||
|
* @body {
|
||||||
|
* id: string | number (required) - Unique class identifier,
|
||||||
|
* name: string (required) - Class display name,
|
||||||
|
* balance?: string | number - Initial class balance (default: 0),
|
||||||
|
* roleId?: string - Associated Discord role ID
|
||||||
|
* }
|
||||||
|
* @response 201 - `{ success: true, class: Class }`
|
||||||
|
* @response 400 - Missing required fields
|
||||||
|
* @response 500 - Error creating class
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* POST /api/classes
|
||||||
|
* { "id": "2", "name": "Mage", "balance": "0", "roleId": "987654321" }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/classes" && method === "POST") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const data = await req.json() as Record<string, any>;
|
||||||
|
|
||||||
|
if (!data.id || !data.name || typeof data.name !== 'string') {
|
||||||
|
return errorResponse("Missing required fields: id and name are required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newClass = await classService.createClass({
|
||||||
|
id: BigInt(data.id),
|
||||||
|
name: data.name,
|
||||||
|
balance: data.balance ? BigInt(data.balance) : 0n,
|
||||||
|
roleId: data.roleId || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, class: newClass }, 201);
|
||||||
|
}, "create class");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route PUT /api/classes/:id
|
||||||
|
* @description Updates an existing class.
|
||||||
|
*
|
||||||
|
* @param id - Class ID
|
||||||
|
* @body {
|
||||||
|
* name?: string - Updated class name,
|
||||||
|
* balance?: string | number - Updated balance,
|
||||||
|
* roleId?: string - Updated Discord role ID
|
||||||
|
* }
|
||||||
|
* @response 200 - `{ success: true, class: Class }`
|
||||||
|
* @response 404 - Class not found
|
||||||
|
* @response 500 - Error updating class
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/classes\/\d+$/) && method === "PUT") {
|
||||||
|
const id = parseStringIdFromPath(pathname);
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const data = await req.json() as Record<string, any>;
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
if (data.name !== undefined) updateData.name = data.name;
|
||||||
|
if (data.balance !== undefined) updateData.balance = BigInt(data.balance);
|
||||||
|
if (data.roleId !== undefined) updateData.roleId = data.roleId;
|
||||||
|
|
||||||
|
const updatedClass = await classService.updateClass(BigInt(id), updateData);
|
||||||
|
|
||||||
|
if (!updatedClass) {
|
||||||
|
return errorResponse("Class not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, class: updatedClass });
|
||||||
|
}, "update class");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route DELETE /api/classes/:id
|
||||||
|
* @description Deletes a class. Users assigned to this class will need to be reassigned.
|
||||||
|
*
|
||||||
|
* @param id - Class ID
|
||||||
|
* @response 204 - Class deleted (no content)
|
||||||
|
* @response 500 - Error deleting class
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/classes\/\d+$/) && method === "DELETE") {
|
||||||
|
const id = parseStringIdFromPath(pathname);
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
await classService.deleteClass(BigInt(id));
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}, "delete class");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const classesRoutes: RouteModule = {
|
||||||
|
name: "classes",
|
||||||
|
handler
|
||||||
|
};
|
||||||
64
api/src/routes/guild-settings.routes.ts
Normal file
64
api/src/routes/guild-settings.routes.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Guild settings endpoints for Aurora API.
|
||||||
|
* Provides endpoints for reading and updating per-guild configuration
|
||||||
|
* stored in the database.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
|
||||||
|
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
||||||
|
import { invalidateGuildConfigCache } from "@shared/lib/config";
|
||||||
|
|
||||||
|
const GUILD_SETTINGS_PATTERN = /^\/api\/guilds\/(\d+)\/settings$/;
|
||||||
|
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method, req } = ctx;
|
||||||
|
|
||||||
|
const match = pathname.match(GUILD_SETTINGS_PATTERN);
|
||||||
|
if (!match || !match[1]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guildId = match[1];
|
||||||
|
|
||||||
|
if (method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const settings = await guildSettingsService.getSettings(guildId);
|
||||||
|
if (!settings) {
|
||||||
|
return jsonResponse({ guildId, configured: false });
|
||||||
|
}
|
||||||
|
return jsonResponse({ ...settings, guildId, configured: true });
|
||||||
|
}, "fetch guild settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "PUT" || method === "PATCH") {
|
||||||
|
try {
|
||||||
|
const body = await req.json() as Record<string, unknown>;
|
||||||
|
const { guildId: _, ...settings } = body;
|
||||||
|
const result = await guildSettingsService.upsertSettings({
|
||||||
|
guildId,
|
||||||
|
...settings,
|
||||||
|
} as Parameters<typeof guildSettingsService.upsertSettings>[0]);
|
||||||
|
invalidateGuildConfigCache(guildId);
|
||||||
|
return jsonResponse(result);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return errorResponse("Failed to save guild settings", 400, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "DELETE") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
await guildSettingsService.deleteSettings(guildId);
|
||||||
|
invalidateGuildConfigCache(guildId);
|
||||||
|
return jsonResponse({ success: true });
|
||||||
|
}, "delete guild settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const guildSettingsRoutes: RouteModule = {
|
||||||
|
name: "guild-settings",
|
||||||
|
handler
|
||||||
|
};
|
||||||
36
api/src/routes/health.routes.ts
Normal file
36
api/src/routes/health.routes.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Health check endpoint for Aurora API.
|
||||||
|
* Provides a simple health status endpoint for monitoring and load balancers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health routes handler.
|
||||||
|
*
|
||||||
|
* @route GET /api/health
|
||||||
|
* @description Returns server health status with timestamp.
|
||||||
|
* @response 200 - `{ status: "ok", timestamp: number }`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* GET /api/health
|
||||||
|
*
|
||||||
|
* // Response
|
||||||
|
* { "status": "ok", "timestamp": 1707408000000 }
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
if (ctx.pathname === "/api/health" && ctx.method === "GET") {
|
||||||
|
return Response.json({
|
||||||
|
status: "ok",
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const healthRoutes: RouteModule = {
|
||||||
|
name: "health",
|
||||||
|
handler
|
||||||
|
};
|
||||||
93
api/src/routes/index.ts
Normal file
93
api/src/routes/index.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Route registration module for Aurora API.
|
||||||
|
* Aggregates all route handlers and provides a unified request handler.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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";
|
||||||
|
import { questsRoutes } from "./quests.routes";
|
||||||
|
import { settingsRoutes } from "./settings.routes";
|
||||||
|
import { guildSettingsRoutes } from "./guild-settings.routes";
|
||||||
|
import { itemsRoutes } from "./items.routes";
|
||||||
|
import { usersRoutes } from "./users.routes";
|
||||||
|
import { classesRoutes } from "./classes.routes";
|
||||||
|
import { moderationRoutes } from "./moderation.routes";
|
||||||
|
import { transactionsRoutes } from "./transactions.routes";
|
||||||
|
import { lootdropsRoutes } from "./lootdrops.routes";
|
||||||
|
import { assetsRoutes } from "./assets.routes";
|
||||||
|
import { errorResponse } from "./utils";
|
||||||
|
|
||||||
|
/** Routes that do NOT require authentication */
|
||||||
|
const publicRoutes: RouteModule[] = [
|
||||||
|
authRoutes,
|
||||||
|
healthRoutes,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Routes that require an authenticated admin session */
|
||||||
|
const protectedRoutes: RouteModule[] = [
|
||||||
|
statsRoutes,
|
||||||
|
actionsRoutes,
|
||||||
|
questsRoutes,
|
||||||
|
settingsRoutes,
|
||||||
|
guildSettingsRoutes,
|
||||||
|
itemsRoutes,
|
||||||
|
usersRoutes,
|
||||||
|
classesRoutes,
|
||||||
|
moderationRoutes,
|
||||||
|
transactionsRoutes,
|
||||||
|
lootdropsRoutes,
|
||||||
|
assetsRoutes,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main request handler that routes requests to appropriate handlers.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request
|
||||||
|
* @param url - Parsed URL object
|
||||||
|
* @returns Response from matching route handler, or null if no match
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const response = await handleRequest(req, url);
|
||||||
|
* if (response) return response;
|
||||||
|
* return new Response("Not Found", { status: 404 });
|
||||||
|
*/
|
||||||
|
export async function handleRequest(req: Request, url: URL): Promise<Response | null> {
|
||||||
|
const ctx: RouteContext = {
|
||||||
|
req,
|
||||||
|
url,
|
||||||
|
method: req.method,
|
||||||
|
pathname: url.pathname,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try public routes first (auth, health)
|
||||||
|
for (const module of publicRoutes) {
|
||||||
|
const response = await module.handler(ctx);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of all registered route module names.
|
||||||
|
* Useful for debugging and documentation.
|
||||||
|
*/
|
||||||
|
export function getRegisteredRoutes(): string[] {
|
||||||
|
return [...publicRoutes, ...protectedRoutes].map(m => m.name);
|
||||||
|
}
|
||||||
371
api/src/routes/items.routes.ts
Normal file
371
api/src/routes/items.routes.ts
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Items management endpoints for Aurora API.
|
||||||
|
* Provides CRUD operations for game items with image upload support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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,
|
||||||
|
parseBody,
|
||||||
|
parseIdFromPath,
|
||||||
|
parseQuery,
|
||||||
|
withErrorHandling
|
||||||
|
} from "./utils";
|
||||||
|
import { CreateItemSchema, UpdateItemSchema, ItemQuerySchema } from "./schemas";
|
||||||
|
|
||||||
|
// Resolve assets directory path
|
||||||
|
const currentDir = dirname(new URL(import.meta.url).pathname);
|
||||||
|
const assetsDir = resolve(currentDir, "../../../bot/assets/graphics/items");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates image file by checking magic bytes.
|
||||||
|
* Supports PNG, JPEG, WebP, and GIF formats.
|
||||||
|
*/
|
||||||
|
function validateImageFormat(bytes: Uint8Array): boolean {
|
||||||
|
const isPNG = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47;
|
||||||
|
const isJPEG = bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF;
|
||||||
|
const isWebP = bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
|
||||||
|
const isGIF = bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46;
|
||||||
|
|
||||||
|
return isPNG || isJPEG || isWebP || isGIF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maximum image file size: 15MB */
|
||||||
|
const MAX_IMAGE_SIZE = 15 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Items routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/items - List items with filters
|
||||||
|
* - POST /api/items - Create item (JSON or multipart with image)
|
||||||
|
* - GET /api/items/:id - Get single item
|
||||||
|
* - PUT /api/items/:id - Update item
|
||||||
|
* - DELETE /api/items/:id - Delete item and asset
|
||||||
|
* - POST /api/items/:id/icon - Upload/replace item icon
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method, req, url } = ctx;
|
||||||
|
|
||||||
|
// Only handle requests to /api/items*
|
||||||
|
if (!pathname.startsWith("/api/items")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { itemsService } = await import("@shared/modules/items/items.service");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/items
|
||||||
|
* @description Returns a paginated list of items with optional filtering.
|
||||||
|
*
|
||||||
|
* @query search - Filter by name/description (partial match)
|
||||||
|
* @query type - Filter by item type (CONSUMABLE, EQUIPMENT, etc.)
|
||||||
|
* @query rarity - Filter by rarity (C, R, SR, SSR)
|
||||||
|
* @query limit - Max results per page (default: 100, max: 100)
|
||||||
|
* @query offset - Pagination offset (default: 0)
|
||||||
|
*
|
||||||
|
* @response 200 - `{ items: Item[], total: number }`
|
||||||
|
* @response 500 - Error fetching items
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* GET /api/items?type=CONSUMABLE&rarity=R&limit=10
|
||||||
|
*
|
||||||
|
* // Response
|
||||||
|
* {
|
||||||
|
* "items": [{ "id": 1, "name": "Health Potion", ... }],
|
||||||
|
* "total": 25
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/items" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const filters = {
|
||||||
|
search: url.searchParams.get("search") || undefined,
|
||||||
|
type: url.searchParams.get("type") || undefined,
|
||||||
|
rarity: url.searchParams.get("rarity") || undefined,
|
||||||
|
limit: url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 100,
|
||||||
|
offset: url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await itemsService.getAllItems(filters);
|
||||||
|
return jsonResponse(result);
|
||||||
|
}, "fetch items");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/items
|
||||||
|
* @description Creates a new item. Supports JSON or multipart/form-data with image.
|
||||||
|
*
|
||||||
|
* @body (JSON) {
|
||||||
|
* name: string (required),
|
||||||
|
* type: string (required),
|
||||||
|
* description?: string,
|
||||||
|
* rarity?: "C" | "R" | "SR" | "SSR",
|
||||||
|
* price?: string | number,
|
||||||
|
* usageData?: object
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @body (Multipart) {
|
||||||
|
* data: JSON string with item fields,
|
||||||
|
* image?: File (PNG, JPEG, WebP, GIF - max 15MB)
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @response 201 - `{ success: true, item: Item }`
|
||||||
|
* @response 400 - Missing required fields or invalid image
|
||||||
|
* @response 409 - Item name already exists
|
||||||
|
* @response 500 - Error creating item
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/items" && method === "POST") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const contentType = req.headers.get("content-type") || "";
|
||||||
|
|
||||||
|
let itemData: CreateItemDTO | null = null;
|
||||||
|
let imageFile: File | null = null;
|
||||||
|
|
||||||
|
if (contentType.includes("multipart/form-data")) {
|
||||||
|
const formData = await req.formData();
|
||||||
|
const jsonData = formData.get("data");
|
||||||
|
imageFile = formData.get("image") as File | null;
|
||||||
|
|
||||||
|
if (typeof jsonData === "string") {
|
||||||
|
itemData = JSON.parse(jsonData) as CreateItemDTO;
|
||||||
|
} else {
|
||||||
|
return errorResponse("Missing item data", 400);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
itemData = await req.json() as CreateItemDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!itemData) {
|
||||||
|
return errorResponse("Missing item data", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!itemData.name || !itemData.type) {
|
||||||
|
return errorResponse("Missing required fields: name and type are required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate name
|
||||||
|
if (await itemsService.isNameTaken(itemData.name)) {
|
||||||
|
return errorResponse("An item with this name already exists", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set placeholder URLs if image will be uploaded
|
||||||
|
const placeholderUrl = "/assets/items/placeholder.png";
|
||||||
|
const createData = {
|
||||||
|
name: itemData.name,
|
||||||
|
description: itemData.description || null,
|
||||||
|
rarity: itemData.rarity || "C",
|
||||||
|
type: itemData.type,
|
||||||
|
price: itemData.price ? BigInt(itemData.price) : null,
|
||||||
|
iconUrl: itemData.iconUrl || placeholderUrl,
|
||||||
|
imageUrl: itemData.imageUrl || placeholderUrl,
|
||||||
|
usageData: itemData.usageData || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the item
|
||||||
|
const item = await itemsService.createItem(createData);
|
||||||
|
|
||||||
|
// If image was provided, save it and update the item
|
||||||
|
if (imageFile && item) {
|
||||||
|
const buffer = await imageFile.arrayBuffer();
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
|
||||||
|
if (!validateImageFormat(bytes)) {
|
||||||
|
await itemsService.deleteItem(item.id);
|
||||||
|
return errorResponse("Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.byteLength > MAX_IMAGE_SIZE) {
|
||||||
|
await itemsService.deleteItem(item.id);
|
||||||
|
return errorResponse("Image file too large. Maximum size is 15MB.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = `${item.id}.png`;
|
||||||
|
const filePath = join(assetsDir, fileName);
|
||||||
|
await Bun.write(filePath, buffer);
|
||||||
|
|
||||||
|
const assetUrl = `/assets/items/${fileName}?v=${Date.now()}`;
|
||||||
|
await itemsService.updateItem(item.id, {
|
||||||
|
iconUrl: assetUrl,
|
||||||
|
imageUrl: assetUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedItem = await itemsService.getItemById(item.id);
|
||||||
|
return jsonResponse({ success: true, item: updatedItem }, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, item }, 201);
|
||||||
|
}, "create item");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/items/:id
|
||||||
|
* @description Returns a single item by ID.
|
||||||
|
*
|
||||||
|
* @param id - Item ID (numeric)
|
||||||
|
* @response 200 - Full item object
|
||||||
|
* @response 404 - Item not found
|
||||||
|
* @response 500 - Error fetching item
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/items\/\d+$/) && method === "GET") {
|
||||||
|
const id = parseIdFromPath(pathname);
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const item = await itemsService.getItemById(id);
|
||||||
|
if (!item) {
|
||||||
|
return errorResponse("Item not found", 404);
|
||||||
|
}
|
||||||
|
return jsonResponse(item);
|
||||||
|
}, "fetch item");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route PUT /api/items/:id
|
||||||
|
* @description Updates an existing item.
|
||||||
|
*
|
||||||
|
* @param id - Item ID (numeric)
|
||||||
|
* @body Partial item fields to update
|
||||||
|
* @response 200 - `{ success: true, item: Item }`
|
||||||
|
* @response 404 - Item not found
|
||||||
|
* @response 409 - Name already taken by another item
|
||||||
|
* @response 500 - Error updating item
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/items\/\d+$/) && method === "PUT") {
|
||||||
|
const id = parseIdFromPath(pathname);
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const data = await req.json() as Partial<UpdateItemDTO>;
|
||||||
|
|
||||||
|
const existing = await itemsService.getItemById(id);
|
||||||
|
if (!existing) {
|
||||||
|
return errorResponse("Item not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate name (if name is being changed)
|
||||||
|
if (data.name && data.name !== existing.name) {
|
||||||
|
if (await itemsService.isNameTaken(data.name, id)) {
|
||||||
|
return errorResponse("An item with this name already exists", 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update data
|
||||||
|
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;
|
||||||
|
if (data.type !== undefined) updateData.type = data.type;
|
||||||
|
if (data.price !== undefined) updateData.price = data.price ? BigInt(data.price) : null;
|
||||||
|
if (data.iconUrl !== undefined) updateData.iconUrl = data.iconUrl;
|
||||||
|
if (data.imageUrl !== undefined) updateData.imageUrl = data.imageUrl;
|
||||||
|
if (data.usageData !== undefined) updateData.usageData = data.usageData;
|
||||||
|
|
||||||
|
const updatedItem = await itemsService.updateItem(id, updateData);
|
||||||
|
return jsonResponse({ success: true, item: updatedItem });
|
||||||
|
}, "update item");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route DELETE /api/items/:id
|
||||||
|
* @description Deletes an item and its associated asset file.
|
||||||
|
*
|
||||||
|
* @param id - Item ID (numeric)
|
||||||
|
* @response 204 - Item deleted (no content)
|
||||||
|
* @response 404 - Item not found
|
||||||
|
* @response 500 - Error deleting item
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/items\/\d+$/) && method === "DELETE") {
|
||||||
|
const id = parseIdFromPath(pathname);
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const existing = await itemsService.getItemById(id);
|
||||||
|
if (!existing) {
|
||||||
|
return errorResponse("Item not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await itemsService.deleteItem(id);
|
||||||
|
|
||||||
|
// Try to delete associated asset file
|
||||||
|
const assetPath = join(assetsDir, `${id}.png`);
|
||||||
|
try {
|
||||||
|
const assetFile = Bun.file(assetPath);
|
||||||
|
if (await assetFile.exists()) {
|
||||||
|
const { unlink } = await import("node:fs/promises");
|
||||||
|
await unlink(assetPath);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Non-critical: log but don't fail
|
||||||
|
const { logger } = await import("@shared/lib/logger");
|
||||||
|
logger.warn("web", `Could not delete asset file for item ${id}`, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}, "delete item");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/items/:id/icon
|
||||||
|
* @description Uploads or replaces an item's icon image.
|
||||||
|
*
|
||||||
|
* @param id - Item ID (numeric)
|
||||||
|
* @body (Multipart) { image: File }
|
||||||
|
* @response 200 - `{ success: true, item: Item }`
|
||||||
|
* @response 400 - No image file or invalid format
|
||||||
|
* @response 404 - Item not found
|
||||||
|
* @response 500 - Error uploading icon
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/items\/\d+\/icon$/) && method === "POST") {
|
||||||
|
const id = parseInt(pathname.split("/")[3] || "0");
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const existing = await itemsService.getItemById(id);
|
||||||
|
if (!existing) {
|
||||||
|
return errorResponse("Item not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await req.formData();
|
||||||
|
const imageFile = formData.get("image") as File | null;
|
||||||
|
|
||||||
|
if (!imageFile) {
|
||||||
|
return errorResponse("No image file provided", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await imageFile.arrayBuffer();
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
|
||||||
|
if (!validateImageFormat(bytes)) {
|
||||||
|
return errorResponse("Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.byteLength > MAX_IMAGE_SIZE) {
|
||||||
|
return errorResponse("Image file too large. Maximum size is 15MB.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = `${id}.png`;
|
||||||
|
const filePath = join(assetsDir, fileName);
|
||||||
|
await Bun.write(filePath, buffer);
|
||||||
|
|
||||||
|
const assetUrl = `/assets/items/${fileName}?v=${Date.now()}`;
|
||||||
|
const updatedItem = await itemsService.updateItem(id, {
|
||||||
|
iconUrl: assetUrl,
|
||||||
|
imageUrl: assetUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, item: updatedItem });
|
||||||
|
}, "upload item icon");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const itemsRoutes: RouteModule = {
|
||||||
|
name: "items",
|
||||||
|
handler
|
||||||
|
};
|
||||||
130
api/src/routes/lootdrops.routes.ts
Normal file
130
api/src/routes/lootdrops.routes.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Lootdrop management endpoints for Aurora API.
|
||||||
|
* Provides endpoints for viewing, spawning, and canceling lootdrops.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import {
|
||||||
|
jsonResponse,
|
||||||
|
errorResponse,
|
||||||
|
parseStringIdFromPath,
|
||||||
|
withErrorHandling
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lootdrops routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/lootdrops - List lootdrops
|
||||||
|
* - POST /api/lootdrops - Spawn a lootdrop
|
||||||
|
* - DELETE /api/lootdrops/:messageId - Cancel/delete a lootdrop
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method, req, url } = ctx;
|
||||||
|
|
||||||
|
// Only handle requests to /api/lootdrops*
|
||||||
|
if (!pathname.startsWith("/api/lootdrops")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/lootdrops
|
||||||
|
* @description Returns recent lootdrops, sorted by newest first.
|
||||||
|
*
|
||||||
|
* @query limit - Max results (default: 50)
|
||||||
|
* @response 200 - `{ lootdrops: Lootdrop[] }`
|
||||||
|
* @response 500 - Error fetching lootdrops
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/lootdrops" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { lootdrops } = await import("@shared/db/schema");
|
||||||
|
const { DrizzleClient } = await import("@shared/db/DrizzleClient");
|
||||||
|
const { desc } = await import("drizzle-orm");
|
||||||
|
|
||||||
|
const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
|
||||||
|
|
||||||
|
const result = await DrizzleClient.select()
|
||||||
|
.from(lootdrops)
|
||||||
|
.orderBy(desc(lootdrops.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
return jsonResponse({ lootdrops: result });
|
||||||
|
}, "fetch lootdrops");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/lootdrops
|
||||||
|
* @description Spawns a new lootdrop in a Discord channel.
|
||||||
|
* Requires a valid text channel ID where the bot has permissions.
|
||||||
|
*
|
||||||
|
* @body {
|
||||||
|
* channelId: string (required) - Discord channel ID to spawn in,
|
||||||
|
* amount?: number - Reward amount (random if not specified),
|
||||||
|
* currency?: string - Currency type
|
||||||
|
* }
|
||||||
|
* @response 201 - `{ success: true }`
|
||||||
|
* @response 400 - Invalid channel or missing channelId
|
||||||
|
* @response 500 - Error spawning lootdrop
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* POST /api/lootdrops
|
||||||
|
* { "channelId": "1234567890", "amount": 100, "currency": "Gold" }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/lootdrops" && method === "POST") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||||
|
const { AuroraClient } = await import("../../../bot/lib/BotClient");
|
||||||
|
const { TextChannel } = await import("discord.js");
|
||||||
|
|
||||||
|
const data = await req.json() as Record<string, any>;
|
||||||
|
|
||||||
|
if (!data.channelId) {
|
||||||
|
return errorResponse("Missing required field: channelId", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await AuroraClient.channels.fetch(data.channelId);
|
||||||
|
|
||||||
|
if (!channel || !(channel instanceof TextChannel)) {
|
||||||
|
return errorResponse("Invalid channel. Must be a TextChannel.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await lootdropService.spawnLootdrop(channel, data.amount, data.currency);
|
||||||
|
|
||||||
|
return jsonResponse({ success: true }, 201);
|
||||||
|
}, "spawn lootdrop");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route DELETE /api/lootdrops/:messageId
|
||||||
|
* @description Cancels and deletes an active lootdrop.
|
||||||
|
* The lootdrop is identified by its Discord message ID.
|
||||||
|
*
|
||||||
|
* @param messageId - Discord message ID of the lootdrop
|
||||||
|
* @response 204 - Lootdrop deleted (no content)
|
||||||
|
* @response 404 - Lootdrop not found
|
||||||
|
* @response 500 - Error deleting lootdrop
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/lootdrops\/[^\/]+$/) && method === "DELETE") {
|
||||||
|
const messageId = parseStringIdFromPath(pathname);
|
||||||
|
if (!messageId) return null;
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||||
|
const success = await lootdropService.deleteLootdrop(messageId);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return errorResponse("Lootdrop not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}, "delete lootdrop");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lootdropsRoutes: RouteModule = {
|
||||||
|
name: "lootdrops",
|
||||||
|
handler
|
||||||
|
};
|
||||||
217
api/src/routes/moderation.routes.ts
Normal file
217
api/src/routes/moderation.routes.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Moderation case management endpoints for Aurora API.
|
||||||
|
* Provides endpoints for viewing, creating, and resolving moderation cases.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import {
|
||||||
|
jsonResponse,
|
||||||
|
errorResponse,
|
||||||
|
parseBody,
|
||||||
|
withErrorHandling
|
||||||
|
} from "./utils";
|
||||||
|
import { CreateCaseSchema, ClearCaseSchema, CaseIdPattern } from "./schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moderation routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/moderation - List cases with filters
|
||||||
|
* - GET /api/moderation/:caseId - Get single case
|
||||||
|
* - POST /api/moderation - Create new case
|
||||||
|
* - PUT /api/moderation/:caseId/clear - Clear/resolve case
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method, req, url } = ctx;
|
||||||
|
|
||||||
|
// Only handle requests to /api/moderation*
|
||||||
|
if (!pathname.startsWith("/api/moderation")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { moderationService } = await import("@shared/modules/moderation/moderation.service");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/moderation
|
||||||
|
* @description Returns moderation cases with optional filtering.
|
||||||
|
*
|
||||||
|
* @query userId - Filter by target user ID
|
||||||
|
* @query moderatorId - Filter by moderator ID
|
||||||
|
* @query type - Filter by case type (warn, timeout, kick, ban, note, prune)
|
||||||
|
* @query active - Filter by active status (true/false)
|
||||||
|
* @query limit - Max results (default: 50)
|
||||||
|
* @query offset - Pagination offset (default: 0)
|
||||||
|
*
|
||||||
|
* @response 200 - `{ cases: ModerationCase[] }`
|
||||||
|
* @response 500 - Error fetching cases
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* GET /api/moderation?type=warn&active=true&limit=10
|
||||||
|
*
|
||||||
|
* // Response
|
||||||
|
* {
|
||||||
|
* "cases": [
|
||||||
|
* {
|
||||||
|
* "id": "1",
|
||||||
|
* "caseId": "CASE-0001",
|
||||||
|
* "type": "warn",
|
||||||
|
* "userId": "123456789",
|
||||||
|
* "username": "User1",
|
||||||
|
* "moderatorId": "987654321",
|
||||||
|
* "moderatorName": "Mod1",
|
||||||
|
* "reason": "Spam",
|
||||||
|
* "active": true,
|
||||||
|
* "createdAt": "2024-01-15T12:00:00Z"
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/moderation" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const filter: any = {};
|
||||||
|
if (url.searchParams.get("userId")) filter.userId = url.searchParams.get("userId");
|
||||||
|
if (url.searchParams.get("moderatorId")) filter.moderatorId = url.searchParams.get("moderatorId");
|
||||||
|
if (url.searchParams.get("type")) filter.type = url.searchParams.get("type");
|
||||||
|
const activeParam = url.searchParams.get("active");
|
||||||
|
if (activeParam !== null) filter.active = activeParam === "true";
|
||||||
|
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);
|
||||||
|
return jsonResponse({ cases });
|
||||||
|
}, "fetch moderation cases");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/moderation/:caseId
|
||||||
|
* @description Returns a single moderation case by case ID.
|
||||||
|
* Case IDs follow the format CASE-XXXX (e.g., CASE-0001).
|
||||||
|
*
|
||||||
|
* @param caseId - Case ID in CASE-XXXX format
|
||||||
|
* @response 200 - Full case object
|
||||||
|
* @response 404 - Case not found
|
||||||
|
* @response 500 - Error fetching case
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/moderation\/CASE-\d+$/i) && method === "GET") {
|
||||||
|
const caseId = pathname.split("/").pop()!.toUpperCase();
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const moderationCase = await moderationService.getCaseById(caseId);
|
||||||
|
|
||||||
|
if (!moderationCase) {
|
||||||
|
return errorResponse("Case not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(moderationCase);
|
||||||
|
}, "fetch moderation case");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/moderation
|
||||||
|
* @description Creates a new moderation case.
|
||||||
|
*
|
||||||
|
* @body {
|
||||||
|
* type: "warn" | "timeout" | "kick" | "ban" | "note" | "prune" (required),
|
||||||
|
* userId: string (required) - Target user's Discord ID,
|
||||||
|
* username: string (required) - Target user's username,
|
||||||
|
* moderatorId: string (required) - Moderator's Discord ID,
|
||||||
|
* moderatorName: string (required) - Moderator's username,
|
||||||
|
* reason: string (required) - Reason for the action,
|
||||||
|
* metadata?: object - Additional case metadata (e.g., duration)
|
||||||
|
* }
|
||||||
|
* @response 201 - `{ success: true, case: ModerationCase }`
|
||||||
|
* @response 400 - Missing required fields
|
||||||
|
* @response 500 - Error creating case
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* POST /api/moderation
|
||||||
|
* {
|
||||||
|
* "type": "warn",
|
||||||
|
* "userId": "123456789",
|
||||||
|
* "username": "User1",
|
||||||
|
* "moderatorId": "987654321",
|
||||||
|
* "moderatorName": "Mod1",
|
||||||
|
* "reason": "Rule violation",
|
||||||
|
* "metadata": { "duration": "24h" }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/moderation" && method === "POST") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const data = await req.json() as Record<string, any>;
|
||||||
|
|
||||||
|
if (!data.type || !data.userId || !data.username || !data.moderatorId || !data.moderatorName || !data.reason) {
|
||||||
|
return errorResponse(
|
||||||
|
"Missing required fields: type, userId, username, moderatorId, moderatorName, reason",
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCase = await moderationService.createCase({
|
||||||
|
type: data.type,
|
||||||
|
userId: data.userId,
|
||||||
|
username: data.username,
|
||||||
|
moderatorId: data.moderatorId,
|
||||||
|
moderatorName: data.moderatorName,
|
||||||
|
reason: data.reason,
|
||||||
|
metadata: data.metadata || {},
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, case: newCase }, 201);
|
||||||
|
}, "create moderation case");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route PUT /api/moderation/:caseId/clear
|
||||||
|
* @description Clears/resolves a moderation case.
|
||||||
|
* Sets the case as inactive and records who cleared it.
|
||||||
|
*
|
||||||
|
* @param caseId - Case ID in CASE-XXXX format
|
||||||
|
* @body {
|
||||||
|
* clearedBy: string (required) - Discord ID of user clearing the case,
|
||||||
|
* clearedByName: string (required) - Username of user clearing the case,
|
||||||
|
* reason?: string - Reason for clearing (default: "Cleared via API")
|
||||||
|
* }
|
||||||
|
* @response 200 - `{ success: true, case: ModerationCase }`
|
||||||
|
* @response 400 - Missing required fields
|
||||||
|
* @response 404 - Case not found
|
||||||
|
* @response 500 - Error clearing case
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* PUT /api/moderation/CASE-0001/clear
|
||||||
|
* { "clearedBy": "987654321", "clearedByName": "Admin1", "reason": "Appeal accepted" }
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/moderation\/CASE-\d+\/clear$/i) && method === "PUT") {
|
||||||
|
const caseId = (pathname.split("/")[3] || "").toUpperCase();
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const data = await req.json() as Record<string, any>;
|
||||||
|
|
||||||
|
if (!data.clearedBy || !data.clearedByName) {
|
||||||
|
return errorResponse("Missing required fields: clearedBy, clearedByName", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedCase = await moderationService.clearCase({
|
||||||
|
caseId,
|
||||||
|
clearedBy: data.clearedBy,
|
||||||
|
clearedByName: data.clearedByName,
|
||||||
|
reason: data.reason || "Cleared via API",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatedCase) {
|
||||||
|
return errorResponse("Case not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, case: updatedCase });
|
||||||
|
}, "clear moderation case");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const moderationRoutes: RouteModule = {
|
||||||
|
name: "moderation",
|
||||||
|
handler
|
||||||
|
};
|
||||||
207
api/src/routes/quests.routes.ts
Normal file
207
api/src/routes/quests.routes.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Quest management endpoints for Aurora API.
|
||||||
|
* Provides CRUD operations for game quests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import { jsonResponse, errorResponse, parseIdFromPath, withErrorHandling } from "./utils";
|
||||||
|
import { CreateQuestSchema, UpdateQuestSchema } from "@shared/modules/quest/quest.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quest routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/quests - List all quests
|
||||||
|
* - POST /api/quests - Create a new quest
|
||||||
|
* - PUT /api/quests/:id - Update an existing quest
|
||||||
|
* - DELETE /api/quests/:id - Delete a quest
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method, req } = ctx;
|
||||||
|
|
||||||
|
// Only handle requests to /api/quests*
|
||||||
|
if (!pathname.startsWith("/api/quests")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { questService } = await import("@shared/modules/quest/quest.service");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/quests
|
||||||
|
* @description Returns all quests in the system.
|
||||||
|
* @response 200 - `{ success: true, data: Quest[] }`
|
||||||
|
* @response 500 - Error fetching quests
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Response
|
||||||
|
* {
|
||||||
|
* "success": true,
|
||||||
|
* "data": [
|
||||||
|
* {
|
||||||
|
* "id": 1,
|
||||||
|
* "name": "Daily Login",
|
||||||
|
* "description": "Login once to claim",
|
||||||
|
* "triggerEvent": "login",
|
||||||
|
* "requirements": { "target": 1 },
|
||||||
|
* "rewards": { "xp": 50, "balance": 100 }
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/quests" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const quests = await questService.getAllQuests();
|
||||||
|
return jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: quests.map(q => ({
|
||||||
|
id: q.id,
|
||||||
|
name: q.name,
|
||||||
|
description: q.description,
|
||||||
|
triggerEvent: q.triggerEvent,
|
||||||
|
requirements: q.requirements,
|
||||||
|
rewards: q.rewards,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}, "fetch quests");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/quests
|
||||||
|
* @description Creates a new quest.
|
||||||
|
*
|
||||||
|
* @body {
|
||||||
|
* name: string,
|
||||||
|
* description?: string,
|
||||||
|
* triggerEvent: string,
|
||||||
|
* target: number,
|
||||||
|
* xpReward: number,
|
||||||
|
* balanceReward: number
|
||||||
|
* }
|
||||||
|
* @response 200 - `{ success: true, quest: Quest }`
|
||||||
|
* @response 400 - Validation error
|
||||||
|
* @response 500 - Error creating quest
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* POST /api/quests
|
||||||
|
* {
|
||||||
|
* "name": "Win 5 Battles",
|
||||||
|
* "description": "Defeat 5 enemies in combat",
|
||||||
|
* "triggerEvent": "battle_win",
|
||||||
|
* "target": 5,
|
||||||
|
* "xpReward": 200,
|
||||||
|
* "balanceReward": 500
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/quests" && method === "POST") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const rawData = await req.json();
|
||||||
|
const parseResult = CreateQuestSchema.safeParse(rawData);
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
return Response.json({
|
||||||
|
error: "Invalid payload",
|
||||||
|
issues: parseResult.error.issues.map(i => ({ path: i.path, message: i.message }))
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parseResult.data;
|
||||||
|
const result = await questService.createQuest({
|
||||||
|
name: data.name,
|
||||||
|
description: data.description || "",
|
||||||
|
triggerEvent: data.triggerEvent,
|
||||||
|
requirements: { target: data.target },
|
||||||
|
rewards: {
|
||||||
|
xp: data.xpReward,
|
||||||
|
balance: data.balanceReward
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, quest: result[0] });
|
||||||
|
}, "create quest");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route PUT /api/quests/:id
|
||||||
|
* @description Updates an existing quest by ID.
|
||||||
|
*
|
||||||
|
* @param id - Quest ID (numeric)
|
||||||
|
* @body Partial quest fields to update
|
||||||
|
* @response 200 - `{ success: true, quest: Quest }`
|
||||||
|
* @response 400 - Invalid quest ID or validation error
|
||||||
|
* @response 404 - Quest not found
|
||||||
|
* @response 500 - Error updating quest
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/quests\/\d+$/) && method === "PUT") {
|
||||||
|
const id = parseIdFromPath(pathname);
|
||||||
|
if (!id) {
|
||||||
|
return errorResponse("Invalid quest ID", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const rawData = await req.json();
|
||||||
|
const parseResult = UpdateQuestSchema.safeParse(rawData);
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
return Response.json({
|
||||||
|
error: "Invalid payload",
|
||||||
|
issues: parseResult.error.issues.map(i => ({ path: i.path, message: i.message }))
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parseResult.data;
|
||||||
|
const result = await questService.updateQuest(id, {
|
||||||
|
...(data.name !== undefined && { name: data.name }),
|
||||||
|
...(data.description !== undefined && { description: data.description }),
|
||||||
|
...(data.triggerEvent !== undefined && { triggerEvent: data.triggerEvent }),
|
||||||
|
...(data.target !== undefined && { requirements: { target: data.target } }),
|
||||||
|
...((data.xpReward !== undefined || data.balanceReward !== undefined) && {
|
||||||
|
rewards: {
|
||||||
|
xp: data.xpReward ?? 0,
|
||||||
|
balance: data.balanceReward ?? 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result || result.length === 0) {
|
||||||
|
return errorResponse("Quest not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, quest: result[0] });
|
||||||
|
}, "update quest");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route DELETE /api/quests/:id
|
||||||
|
* @description Deletes a quest by ID.
|
||||||
|
*
|
||||||
|
* @param id - Quest ID (numeric)
|
||||||
|
* @response 200 - `{ success: true, deleted: number }`
|
||||||
|
* @response 400 - Invalid quest ID
|
||||||
|
* @response 404 - Quest not found
|
||||||
|
* @response 500 - Error deleting quest
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/quests\/\d+$/) && method === "DELETE") {
|
||||||
|
const id = parseIdFromPath(pathname);
|
||||||
|
if (!id) {
|
||||||
|
return errorResponse("Invalid quest ID", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const result = await questService.deleteQuest(id);
|
||||||
|
|
||||||
|
if (!result || result.length === 0) {
|
||||||
|
return errorResponse("Quest not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, deleted: (result[0] as { id: number }).id });
|
||||||
|
}, "delete quest");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const questsRoutes: RouteModule = {
|
||||||
|
name: "quests",
|
||||||
|
handler
|
||||||
|
};
|
||||||
274
api/src/routes/schemas.ts
Normal file
274
api/src/routes/schemas.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Centralized Zod validation schemas for all Aurora API endpoints.
|
||||||
|
* Provides type-safe request/response validation for every entity in the system.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Common Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard pagination query parameters.
|
||||||
|
*/
|
||||||
|
export const PaginationSchema = z.object({
|
||||||
|
limit: z.coerce.number().min(1).max(100).optional().default(50),
|
||||||
|
offset: z.coerce.number().min(0).optional().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Numeric ID parameter validation.
|
||||||
|
*/
|
||||||
|
export const NumericIdSchema = z.coerce.number().int().positive();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discord snowflake ID validation (string of digits).
|
||||||
|
*/
|
||||||
|
export const SnowflakeIdSchema = z.string().regex(/^\d{17,20}$/, "Invalid Discord ID format");
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Items Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid item types in the system.
|
||||||
|
*/
|
||||||
|
export const ItemTypeEnum = z.enum([
|
||||||
|
"CONSUMABLE",
|
||||||
|
"EQUIPMENT",
|
||||||
|
"MATERIAL",
|
||||||
|
"LOOTBOX",
|
||||||
|
"COLLECTIBLE",
|
||||||
|
"KEY",
|
||||||
|
"TOOL"
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid item rarities.
|
||||||
|
*/
|
||||||
|
export const ItemRarityEnum = z.enum(["C", "R", "SR", "SSR"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query parameters for listing items.
|
||||||
|
*/
|
||||||
|
export const ItemQuerySchema = PaginationSchema.extend({
|
||||||
|
search: z.string().optional(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
rarity: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for creating a new item.
|
||||||
|
*/
|
||||||
|
export const CreateItemSchema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required").max(100),
|
||||||
|
description: z.string().max(500).nullable().optional(),
|
||||||
|
rarity: ItemRarityEnum.optional().default("C"),
|
||||||
|
type: ItemTypeEnum,
|
||||||
|
price: z.union([z.string(), z.number()]).nullable().optional(),
|
||||||
|
iconUrl: z.string().optional(),
|
||||||
|
imageUrl: z.string().optional(),
|
||||||
|
usageData: z.any().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for updating an existing item.
|
||||||
|
*/
|
||||||
|
export const UpdateItemSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
description: z.string().max(500).nullable().optional(),
|
||||||
|
rarity: ItemRarityEnum.optional(),
|
||||||
|
type: ItemTypeEnum.optional(),
|
||||||
|
price: z.union([z.string(), z.number()]).nullable().optional(),
|
||||||
|
iconUrl: z.string().optional(),
|
||||||
|
imageUrl: z.string().optional(),
|
||||||
|
usageData: z.any().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Users Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query parameters for listing users.
|
||||||
|
*/
|
||||||
|
export const UserQuerySchema = PaginationSchema.extend({
|
||||||
|
search: z.string().optional(),
|
||||||
|
sortBy: z.enum(["balance", "level", "xp", "username"]).optional().default("balance"),
|
||||||
|
sortOrder: z.enum(["asc", "desc"]).optional().default("desc"),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for updating a user.
|
||||||
|
*/
|
||||||
|
export const UpdateUserSchema = z.object({
|
||||||
|
username: z.string().min(1).max(32).optional(),
|
||||||
|
balance: z.union([z.string(), z.number()]).optional(),
|
||||||
|
xp: z.union([z.string(), z.number()]).optional(),
|
||||||
|
level: z.coerce.number().int().min(0).optional(),
|
||||||
|
dailyStreak: z.coerce.number().int().min(0).optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
settings: z.record(z.string(), z.any()).optional(),
|
||||||
|
classId: z.union([z.string(), z.number()]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for adding an item to user inventory.
|
||||||
|
*/
|
||||||
|
export const InventoryAddSchema = z.object({
|
||||||
|
itemId: z.coerce.number().int().positive("Item ID is required"),
|
||||||
|
quantity: z.union([z.string(), z.number()]).refine(
|
||||||
|
(val) => BigInt(val) > 0n,
|
||||||
|
"Quantity must be positive"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query params for removing inventory items.
|
||||||
|
*/
|
||||||
|
export const InventoryRemoveQuerySchema = z.object({
|
||||||
|
amount: z.coerce.number().int().min(1).optional().default(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Classes Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for creating a new class.
|
||||||
|
*/
|
||||||
|
export const CreateClassSchema = z.object({
|
||||||
|
id: z.union([z.string(), z.number()]),
|
||||||
|
name: z.string().min(1, "Name is required").max(50),
|
||||||
|
balance: z.union([z.string(), z.number()]).optional().default("0"),
|
||||||
|
roleId: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for updating a class.
|
||||||
|
*/
|
||||||
|
export const UpdateClassSchema = z.object({
|
||||||
|
name: z.string().min(1).max(50).optional(),
|
||||||
|
balance: z.union([z.string(), z.number()]).optional(),
|
||||||
|
roleId: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Moderation Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid moderation case types.
|
||||||
|
*/
|
||||||
|
export const ModerationTypeEnum = z.enum([
|
||||||
|
"warn",
|
||||||
|
"timeout",
|
||||||
|
"kick",
|
||||||
|
"ban",
|
||||||
|
"note",
|
||||||
|
"prune"
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query parameters for searching moderation cases.
|
||||||
|
*/
|
||||||
|
export const CaseQuerySchema = PaginationSchema.extend({
|
||||||
|
userId: z.string().optional(),
|
||||||
|
moderatorId: z.string().optional(),
|
||||||
|
type: ModerationTypeEnum.optional(),
|
||||||
|
active: z.preprocess(
|
||||||
|
(val) => val === "true" ? true : val === "false" ? false : undefined,
|
||||||
|
z.boolean().optional()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for creating a moderation case.
|
||||||
|
*/
|
||||||
|
export const CreateCaseSchema = z.object({
|
||||||
|
type: ModerationTypeEnum,
|
||||||
|
userId: z.string().min(1, "User ID is required"),
|
||||||
|
username: z.string().min(1, "Username is required"),
|
||||||
|
moderatorId: z.string().min(1, "Moderator ID is required"),
|
||||||
|
moderatorName: z.string().min(1, "Moderator name is required"),
|
||||||
|
reason: z.string().min(1, "Reason is required").max(1000),
|
||||||
|
metadata: z.record(z.string(), z.any()).optional().default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for clearing/resolving a moderation case.
|
||||||
|
*/
|
||||||
|
export const ClearCaseSchema = z.object({
|
||||||
|
clearedBy: z.string().min(1, "Cleared by ID is required"),
|
||||||
|
clearedByName: z.string().min(1, "Cleared by name is required"),
|
||||||
|
reason: z.string().max(500).optional().default("Cleared via API"),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Case ID pattern validation (CASE-XXXX format).
|
||||||
|
*/
|
||||||
|
export const CaseIdPattern = /^CASE-\d+$/i;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Transactions Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query parameters for listing transactions.
|
||||||
|
*/
|
||||||
|
export const TransactionQuerySchema = PaginationSchema.extend({
|
||||||
|
userId: z.string().optional(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Lootdrops Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query parameters for listing lootdrops.
|
||||||
|
*/
|
||||||
|
export const LootdropQuerySchema = z.object({
|
||||||
|
limit: z.coerce.number().min(1).max(100).optional().default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for spawning a lootdrop.
|
||||||
|
*/
|
||||||
|
export const CreateLootdropSchema = z.object({
|
||||||
|
channelId: z.string().min(1, "Channel ID is required"),
|
||||||
|
amount: z.coerce.number().int().positive().optional(),
|
||||||
|
currency: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Admin Actions Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for toggling maintenance mode.
|
||||||
|
*/
|
||||||
|
export const MaintenanceModeSchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
reason: z.string().max(200).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Type Exports
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ItemQuery = z.infer<typeof ItemQuerySchema>;
|
||||||
|
export type CreateItem = z.infer<typeof CreateItemSchema>;
|
||||||
|
export type UpdateItem = z.infer<typeof UpdateItemSchema>;
|
||||||
|
export type UserQuery = z.infer<typeof UserQuerySchema>;
|
||||||
|
export type UpdateUser = z.infer<typeof UpdateUserSchema>;
|
||||||
|
export type InventoryAdd = z.infer<typeof InventoryAddSchema>;
|
||||||
|
export type CreateClass = z.infer<typeof CreateClassSchema>;
|
||||||
|
export type UpdateClass = z.infer<typeof UpdateClassSchema>;
|
||||||
|
export type CaseQuery = z.infer<typeof CaseQuerySchema>;
|
||||||
|
export type CreateCase = z.infer<typeof CreateCaseSchema>;
|
||||||
|
export type ClearCase = z.infer<typeof ClearCaseSchema>;
|
||||||
|
export type TransactionQuery = z.infer<typeof TransactionQuerySchema>;
|
||||||
|
export type CreateLootdrop = z.infer<typeof CreateLootdropSchema>;
|
||||||
|
export type MaintenanceMode = z.infer<typeof MaintenanceModeSchema>;
|
||||||
152
api/src/routes/settings.routes.ts
Normal file
152
api/src/routes/settings.routes.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Bot settings endpoints for Aurora API.
|
||||||
|
* Provides endpoints for reading and updating bot configuration,
|
||||||
|
* as well as fetching Discord metadata.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/settings - Get current bot configuration
|
||||||
|
* - POST /api/settings - Update bot configuration (partial merge)
|
||||||
|
* - GET /api/settings/meta - Get Discord metadata (roles, channels, commands)
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method, req } = ctx;
|
||||||
|
|
||||||
|
// Only handle requests to /api/settings*
|
||||||
|
if (!pathname.startsWith("/api/settings")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/settings
|
||||||
|
* @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 (DB format with strings for BigInts)
|
||||||
|
* @response 500 - Error fetching settings
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Response
|
||||||
|
* {
|
||||||
|
* "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 { 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/settings
|
||||||
|
* @description Updates bot configuration with partial merge.
|
||||||
|
* Only the provided fields will be updated; other settings remain unchanged.
|
||||||
|
* After updating, commands are automatically reloaded.
|
||||||
|
*
|
||||||
|
* @body Partial configuration object (DB format with strings for BigInts)
|
||||||
|
* @response 200 - `{ success: true }`
|
||||||
|
* @response 400 - Validation error
|
||||||
|
* @response 500 - Error saving settings
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request - Only update economy daily reward
|
||||||
|
* POST /api/settings
|
||||||
|
* { "economy": { "daily": { "amount": "150" } } }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/settings" && method === "POST") {
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
|
||||||
|
return jsonResponse({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
// Return 400 for validation errors
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return errorResponse("Failed to save settings", 400, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/settings/meta
|
||||||
|
* @description Returns Discord server metadata for settings UI.
|
||||||
|
* Provides lists of roles, channels, and registered commands.
|
||||||
|
*
|
||||||
|
* @response 200 - `{ roles: Role[], channels: Channel[], commands: Command[] }`
|
||||||
|
* @response 500 - Error fetching metadata
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Response
|
||||||
|
* {
|
||||||
|
* "roles": [
|
||||||
|
* { "id": "123456789", "name": "Admin", "color": "#FF0000" }
|
||||||
|
* ],
|
||||||
|
* "channels": [
|
||||||
|
* { "id": "987654321", "name": "general", "type": 0 }
|
||||||
|
* ],
|
||||||
|
* "commands": [
|
||||||
|
* { "name": "daily", "category": "economy" }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/settings/meta" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { AuroraClient } = await import("../../../bot/lib/BotClient");
|
||||||
|
const { env } = await import("@shared/lib/env");
|
||||||
|
|
||||||
|
if (!env.DISCORD_GUILD_ID) {
|
||||||
|
return jsonResponse({ roles: [], channels: [], commands: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = AuroraClient.guilds.cache.get(env.DISCORD_GUILD_ID);
|
||||||
|
if (!guild) {
|
||||||
|
return jsonResponse({ roles: [], channels: [], commands: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map roles and channels to a simplified format
|
||||||
|
const roles = guild.roles.cache
|
||||||
|
.sort((a, b) => b.position - a.position)
|
||||||
|
.map(r => ({ id: r.id, name: r.name, color: r.hexColor }));
|
||||||
|
|
||||||
|
const channels = guild.channels.cache
|
||||||
|
.map(c => ({ id: c.id, name: c.name, type: c.type }));
|
||||||
|
|
||||||
|
const commands = Array.from(AuroraClient.knownCommands.entries())
|
||||||
|
.map(([name, category]) => ({ name, category }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.category !== b.category) return a.category.localeCompare(b.category);
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse({ guildId: env.DISCORD_GUILD_ID, roles, channels, commands });
|
||||||
|
}, "fetch settings meta");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const settingsRoutes: RouteModule = {
|
||||||
|
name: "settings",
|
||||||
|
handler
|
||||||
|
};
|
||||||
94
api/src/routes/stats.helper.ts
Normal file
94
api/src/routes/stats.helper.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Dashboard stats helper for Aurora API.
|
||||||
|
* Provides the getFullDashboardStats function used by stats routes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from "@shared/lib/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches comprehensive dashboard statistics.
|
||||||
|
* Aggregates data from multiple services with error isolation.
|
||||||
|
*
|
||||||
|
* @returns Full dashboard stats object including bot info, user counts,
|
||||||
|
* economy data, leaderboards, and system status.
|
||||||
|
*/
|
||||||
|
export async function getFullDashboardStats() {
|
||||||
|
// Import services (dynamic to avoid circular deps)
|
||||||
|
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||||
|
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||||
|
const { getClientStats } = await import("../../../bot/lib/clientStats");
|
||||||
|
|
||||||
|
// Fetch all data in parallel with error isolation
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
Promise.resolve(getClientStats()),
|
||||||
|
dashboardService.getActiveUserCount(),
|
||||||
|
dashboardService.getTotalUserCount(),
|
||||||
|
dashboardService.getEconomyStats(),
|
||||||
|
dashboardService.getRecentEvents(10),
|
||||||
|
dashboardService.getTotalItems(),
|
||||||
|
dashboardService.getActiveLootdrops(),
|
||||||
|
dashboardService.getLeaderboards(),
|
||||||
|
Promise.resolve(lootdropService.getLootdropState()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Helper to unwrap result or return default
|
||||||
|
const unwrap = <T>(result: PromiseSettledResult<T>, defaultValue: T, name: string): T => {
|
||||||
|
if (result.status === 'fulfilled') return result.value;
|
||||||
|
logger.error("web", `Failed to fetch ${name}`, result.reason);
|
||||||
|
return defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientStats = unwrap(results[0], {
|
||||||
|
bot: { name: 'Aurora', avatarUrl: null, status: null },
|
||||||
|
guilds: 0,
|
||||||
|
commandsRegistered: 0,
|
||||||
|
commandsKnown: 0,
|
||||||
|
cachedUsers: 0,
|
||||||
|
ping: 0,
|
||||||
|
uptime: 0,
|
||||||
|
lastCommandTimestamp: null
|
||||||
|
}, 'clientStats');
|
||||||
|
|
||||||
|
const activeUsers = unwrap(results[1], 0, 'activeUsers');
|
||||||
|
const totalUsers = unwrap(results[2], 0, 'totalUsers');
|
||||||
|
const economyStats = unwrap(results[3], { totalWealth: 0n, avgLevel: 0, topStreak: 0 }, 'economyStats');
|
||||||
|
const recentEvents = unwrap(results[4], [], 'recentEvents');
|
||||||
|
const totalItems = unwrap(results[5], 0, 'totalItems');
|
||||||
|
const activeLootdrops = unwrap(results[6], [], 'activeLootdrops');
|
||||||
|
const leaderboards = unwrap(results[7], { topLevels: [], topWealth: [], topNetWorth: [] }, 'leaderboards');
|
||||||
|
const lootdropState = unwrap(results[8], undefined, 'lootdropState');
|
||||||
|
|
||||||
|
return {
|
||||||
|
bot: clientStats.bot,
|
||||||
|
guilds: { count: clientStats.guilds },
|
||||||
|
users: { active: activeUsers, total: totalUsers },
|
||||||
|
commands: {
|
||||||
|
total: clientStats.commandsKnown,
|
||||||
|
active: clientStats.commandsRegistered,
|
||||||
|
disabled: clientStats.commandsKnown - clientStats.commandsRegistered
|
||||||
|
},
|
||||||
|
ping: { avg: clientStats.ping },
|
||||||
|
economy: {
|
||||||
|
totalWealth: economyStats.totalWealth.toString(),
|
||||||
|
avgLevel: economyStats.avgLevel,
|
||||||
|
topStreak: economyStats.topStreak,
|
||||||
|
totalItems,
|
||||||
|
},
|
||||||
|
recentEvents: recentEvents.map(event => ({
|
||||||
|
...event,
|
||||||
|
timestamp: event.timestamp instanceof Date ? event.timestamp.toISOString() : event.timestamp,
|
||||||
|
})),
|
||||||
|
activeLootdrops: activeLootdrops.map(drop => ({
|
||||||
|
rewardAmount: drop.rewardAmount,
|
||||||
|
currency: drop.currency,
|
||||||
|
createdAt: drop.createdAt.toISOString(),
|
||||||
|
expiresAt: drop.expiresAt ? drop.expiresAt.toISOString() : null,
|
||||||
|
// Explicitly excluding channelId/messageId to prevent sniping
|
||||||
|
})),
|
||||||
|
lootdropState,
|
||||||
|
leaderboards,
|
||||||
|
uptime: clientStats.uptime,
|
||||||
|
lastCommandTimestamp: clientStats.lastCommandTimestamp,
|
||||||
|
maintenanceMode: (await import("../../../bot/lib/BotClient")).AuroraClient.maintenanceMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
85
api/src/routes/stats.routes.ts
Normal file
85
api/src/routes/stats.routes.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Statistics endpoints for Aurora API.
|
||||||
|
* Provides dashboard statistics and activity aggregation data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
|
||||||
|
|
||||||
|
// Cache for activity stats (heavy aggregation)
|
||||||
|
let activityPromise: Promise<import("@shared/modules/dashboard/dashboard.types").ActivityData[]> | null = null;
|
||||||
|
let lastActivityFetch: number = 0;
|
||||||
|
const ACTIVITY_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stats routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/stats - Full dashboard statistics
|
||||||
|
* - GET /api/stats/activity - Activity aggregation with caching
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method } = ctx;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/stats
|
||||||
|
* @description Returns comprehensive dashboard statistics including
|
||||||
|
* bot info, user counts, economy data, and leaderboards.
|
||||||
|
* @response 200 - Full dashboard stats object
|
||||||
|
* @response 500 - Error fetching statistics
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/stats" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
// Import the stats function from wherever it's defined
|
||||||
|
// This will be passed in during initialization
|
||||||
|
const { getFullDashboardStats } = await import("./stats.helper.ts");
|
||||||
|
const stats = await getFullDashboardStats();
|
||||||
|
return jsonResponse(stats);
|
||||||
|
}, "fetch dashboard stats");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/stats/activity
|
||||||
|
* @description Returns activity aggregation data with 5-minute caching.
|
||||||
|
* Heavy query, results are cached to reduce database load.
|
||||||
|
* @response 200 - Array of activity data points
|
||||||
|
* @response 500 - Error fetching activity statistics
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Response
|
||||||
|
* [
|
||||||
|
* { "date": "2024-02-08", "commands": 150, "users": 25 },
|
||||||
|
* { "date": "2024-02-07", "commands": 200, "users": 30 }
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/stats/activity" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// If we have a valid cache, return it
|
||||||
|
if (activityPromise && (now - lastActivityFetch < ACTIVITY_CACHE_TTL)) {
|
||||||
|
const data = await activityPromise;
|
||||||
|
return jsonResponse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, trigger a new fetch (deduplicated by the promise)
|
||||||
|
if (!activityPromise || (now - lastActivityFetch >= ACTIVITY_CACHE_TTL)) {
|
||||||
|
activityPromise = (async () => {
|
||||||
|
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||||
|
return await dashboardService.getActivityAggregation();
|
||||||
|
})();
|
||||||
|
lastActivityFetch = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activity = await activityPromise;
|
||||||
|
return jsonResponse(activity);
|
||||||
|
}, "fetch activity stats");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const statsRoutes: RouteModule = {
|
||||||
|
name: "stats",
|
||||||
|
handler
|
||||||
|
};
|
||||||
91
api/src/routes/transactions.routes.ts
Normal file
91
api/src/routes/transactions.routes.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Transaction listing endpoints for Aurora API.
|
||||||
|
* Provides read access to economy transaction history.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import { jsonResponse, withErrorHandling } from "./utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transactions routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/transactions - List transactions with filters
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method, url } = ctx;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/transactions
|
||||||
|
* @description Returns economy transactions with optional filtering.
|
||||||
|
*
|
||||||
|
* @query userId - Filter by user ID (Discord snowflake)
|
||||||
|
* @query type - Filter by transaction type
|
||||||
|
* @query limit - Max results (default: 50)
|
||||||
|
* @query offset - Pagination offset (default: 0)
|
||||||
|
*
|
||||||
|
* @response 200 - `{ transactions: Transaction[] }`
|
||||||
|
* @response 500 - Error fetching transactions
|
||||||
|
*
|
||||||
|
* Transaction Types:
|
||||||
|
* - DAILY_REWARD - Daily claim reward
|
||||||
|
* - TRANSFER_IN - Received from another user
|
||||||
|
* - TRANSFER_OUT - Sent to another user
|
||||||
|
* - LOOTDROP_CLAIM - Claimed lootdrop
|
||||||
|
* - SHOP_BUY - Item purchase
|
||||||
|
* - QUEST_REWARD - Quest completion reward
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* GET /api/transactions?userId=123456789&type=DAILY_REWARD&limit=10
|
||||||
|
*
|
||||||
|
* // Response
|
||||||
|
* {
|
||||||
|
* "transactions": [
|
||||||
|
* {
|
||||||
|
* "id": "1",
|
||||||
|
* "userId": "123456789",
|
||||||
|
* "amount": "100",
|
||||||
|
* "type": "DAILY_REWARD",
|
||||||
|
* "description": "Daily reward (Streak: 3)",
|
||||||
|
* "createdAt": "2024-01-15T12:00:00Z"
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/transactions" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { transactions } = await import("@shared/db/schema");
|
||||||
|
const { DrizzleClient } = await import("@shared/db/DrizzleClient");
|
||||||
|
const { eq, desc } = await import("drizzle-orm");
|
||||||
|
|
||||||
|
const userId = url.searchParams.get("userId");
|
||||||
|
const type = url.searchParams.get("type");
|
||||||
|
const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
|
||||||
|
const offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
|
||||||
|
|
||||||
|
let query = DrizzleClient.select().from(transactions);
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
query = query.where(eq(transactions.userId, BigInt(userId))) as typeof query;
|
||||||
|
}
|
||||||
|
if (type) {
|
||||||
|
query = query.where(eq(transactions.type, type)) as typeof query;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query
|
||||||
|
.orderBy(desc(transactions.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return jsonResponse({ transactions: result });
|
||||||
|
}, "fetch transactions");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const transactionsRoutes: RouteModule = {
|
||||||
|
name: "transactions",
|
||||||
|
handler
|
||||||
|
};
|
||||||
94
api/src/routes/types.ts
Normal file
94
api/src/routes/types.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Shared types for the Aurora API routing system.
|
||||||
|
* Provides type definitions for route handlers, responses, and errors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard API error response structure.
|
||||||
|
*/
|
||||||
|
export interface ApiErrorResponse {
|
||||||
|
error: string;
|
||||||
|
details?: string;
|
||||||
|
issues?: Array<{ path: (string | number)[]; message: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard API success response with optional data wrapper.
|
||||||
|
*/
|
||||||
|
export interface ApiSuccessResponse<T = unknown> {
|
||||||
|
success: true;
|
||||||
|
[key: string]: T | true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route context passed to all route handlers.
|
||||||
|
* Contains parsed URL information and the original request.
|
||||||
|
*/
|
||||||
|
export interface RouteContext {
|
||||||
|
/** The original HTTP request */
|
||||||
|
req: Request;
|
||||||
|
/** Parsed URL object */
|
||||||
|
url: URL;
|
||||||
|
/** HTTP method (GET, POST, PUT, DELETE, etc.) */
|
||||||
|
method: string;
|
||||||
|
/** URL pathname without query string */
|
||||||
|
pathname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A route handler function that processes a request and returns a response.
|
||||||
|
* Returns null if the route doesn't match, allowing the next handler to try.
|
||||||
|
*/
|
||||||
|
export type RouteHandler = (ctx: RouteContext) => Promise<Response | null> | Response | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A route module that exports a handler function.
|
||||||
|
*/
|
||||||
|
export interface RouteModule {
|
||||||
|
/** Human-readable name for debugging */
|
||||||
|
name: string;
|
||||||
|
/** The route handler function */
|
||||||
|
handler: RouteHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom API error class with HTTP status code support.
|
||||||
|
*/
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status: number = 500,
|
||||||
|
public readonly details?: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a 400 Bad Request error.
|
||||||
|
*/
|
||||||
|
static badRequest(message: string, details?: string): ApiError {
|
||||||
|
return new ApiError(message, 400, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a 404 Not Found error.
|
||||||
|
*/
|
||||||
|
static notFound(resource: string): ApiError {
|
||||||
|
return new ApiError(`${resource} not found`, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a 409 Conflict error.
|
||||||
|
*/
|
||||||
|
static conflict(message: string): ApiError {
|
||||||
|
return new ApiError(message, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a 500 Internal Server Error.
|
||||||
|
*/
|
||||||
|
static internal(message: string, details?: string): ApiError {
|
||||||
|
return new ApiError(message, 500, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
263
api/src/routes/users.routes.ts
Normal file
263
api/src/routes/users.routes.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview User management endpoints for Aurora API.
|
||||||
|
* Provides CRUD operations for users and user inventory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import {
|
||||||
|
jsonResponse,
|
||||||
|
errorResponse,
|
||||||
|
parseBody,
|
||||||
|
parseIdFromPath,
|
||||||
|
parseStringIdFromPath,
|
||||||
|
withErrorHandling
|
||||||
|
} from "./utils";
|
||||||
|
import { UpdateUserSchema, InventoryAddSchema } from "./schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Users routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/users - List users with filters
|
||||||
|
* - GET /api/users/:id - Get single user
|
||||||
|
* - PUT /api/users/:id - Update user
|
||||||
|
* - GET /api/users/:id/inventory - Get user inventory
|
||||||
|
* - POST /api/users/:id/inventory - Add item to inventory
|
||||||
|
* - DELETE /api/users/:id/inventory/:itemId - Remove item from inventory
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method, req, url } = ctx;
|
||||||
|
|
||||||
|
// Only handle requests to /api/users*
|
||||||
|
if (!pathname.startsWith("/api/users")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/users
|
||||||
|
* @description Returns a paginated list of users with optional filtering and sorting.
|
||||||
|
*
|
||||||
|
* @query search - Filter by username (partial match)
|
||||||
|
* @query sortBy - Sort field: balance, level, xp, username (default: balance)
|
||||||
|
* @query sortOrder - Sort direction: asc, desc (default: desc)
|
||||||
|
* @query limit - Max results (default: 50)
|
||||||
|
* @query offset - Pagination offset (default: 0)
|
||||||
|
*
|
||||||
|
* @response 200 - `{ users: User[], total: number }`
|
||||||
|
* @response 500 - Error fetching users
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* GET /api/users?sortBy=level&sortOrder=desc&limit=10
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/users" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { users } = await import("@shared/db/schema");
|
||||||
|
const { DrizzleClient } = await import("@shared/db/DrizzleClient");
|
||||||
|
const { ilike, desc, asc, sql } = await import("drizzle-orm");
|
||||||
|
|
||||||
|
const search = url.searchParams.get("search") || undefined;
|
||||||
|
const sortBy = url.searchParams.get("sortBy") || "balance";
|
||||||
|
const sortOrder = url.searchParams.get("sortOrder") || "desc";
|
||||||
|
const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
|
||||||
|
const offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
|
||||||
|
|
||||||
|
let query = DrizzleClient.select().from(users);
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
query = query.where(ilike(users.username, `%${search}%`)) as typeof query;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortColumn = sortBy === "level" ? users.level :
|
||||||
|
sortBy === "xp" ? users.xp :
|
||||||
|
sortBy === "username" ? users.username : users.balance;
|
||||||
|
const orderFn = sortOrder === "asc" ? asc : desc;
|
||||||
|
|
||||||
|
const result = await query
|
||||||
|
.orderBy(orderFn(sortColumn))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
const countResult = await DrizzleClient.select({ count: sql<number>`count(*)` }).from(users);
|
||||||
|
const total = Number(countResult[0]?.count || 0);
|
||||||
|
|
||||||
|
return jsonResponse({ users: result, total });
|
||||||
|
}, "fetch users");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/users/:id
|
||||||
|
* @description Returns a single user by Discord ID.
|
||||||
|
* Includes related class information if the user has a class assigned.
|
||||||
|
*
|
||||||
|
* @param id - Discord User ID (snowflake)
|
||||||
|
* @response 200 - Full user object with class relation
|
||||||
|
* @response 404 - User not found
|
||||||
|
* @response 500 - Error fetching user
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Response
|
||||||
|
* {
|
||||||
|
* "id": "123456789012345678",
|
||||||
|
* "username": "Player1",
|
||||||
|
* "balance": "1000",
|
||||||
|
* "level": 5,
|
||||||
|
* "class": { "id": "1", "name": "Warrior" }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/users\/\d+$/) && method === "GET") {
|
||||||
|
const id = parseStringIdFromPath(pathname);
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { userService } = await import("@shared/modules/user/user.service");
|
||||||
|
const user = await userService.getUserById(id);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return errorResponse("User not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(user);
|
||||||
|
}, "fetch user");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route PUT /api/users/:id
|
||||||
|
* @description Updates user fields. Only provided fields will be updated.
|
||||||
|
*
|
||||||
|
* @param id - Discord User ID (snowflake)
|
||||||
|
* @body {
|
||||||
|
* username?: string,
|
||||||
|
* balance?: string | number,
|
||||||
|
* xp?: string | number,
|
||||||
|
* level?: number,
|
||||||
|
* dailyStreak?: number,
|
||||||
|
* isActive?: boolean,
|
||||||
|
* settings?: object,
|
||||||
|
* classId?: string | number
|
||||||
|
* }
|
||||||
|
* @response 200 - `{ success: true, user: User }`
|
||||||
|
* @response 404 - User not found
|
||||||
|
* @response 500 - Error updating user
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/users\/\d+$/) && method === "PUT") {
|
||||||
|
const id = parseStringIdFromPath(pathname);
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { userService } = await import("@shared/modules/user/user.service");
|
||||||
|
const data = await req.json() as Record<string, any>;
|
||||||
|
|
||||||
|
const existing = await userService.getUserById(id);
|
||||||
|
if (!existing) {
|
||||||
|
return errorResponse("User not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update data (only allow safe fields)
|
||||||
|
const updateData: any = {};
|
||||||
|
if (data.username !== undefined) updateData.username = data.username;
|
||||||
|
if (data.balance !== undefined) updateData.balance = BigInt(data.balance);
|
||||||
|
if (data.xp !== undefined) updateData.xp = BigInt(data.xp);
|
||||||
|
if (data.level !== undefined) updateData.level = parseInt(data.level);
|
||||||
|
if (data.dailyStreak !== undefined) updateData.dailyStreak = parseInt(data.dailyStreak);
|
||||||
|
if (data.isActive !== undefined) updateData.isActive = Boolean(data.isActive);
|
||||||
|
if (data.settings !== undefined) updateData.settings = data.settings;
|
||||||
|
if (data.classId !== undefined) updateData.classId = BigInt(data.classId);
|
||||||
|
|
||||||
|
const updatedUser = await userService.updateUser(id, updateData);
|
||||||
|
return jsonResponse({ success: true, user: updatedUser });
|
||||||
|
}, "update user");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/users/:id/inventory
|
||||||
|
* @description Returns user's inventory with item details.
|
||||||
|
*
|
||||||
|
* @param id - Discord User ID (snowflake)
|
||||||
|
* @response 200 - `{ inventory: InventoryEntry[] }`
|
||||||
|
* @response 500 - Error fetching inventory
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Response
|
||||||
|
* {
|
||||||
|
* "inventory": [
|
||||||
|
* {
|
||||||
|
* "userId": "123456789",
|
||||||
|
* "itemId": 1,
|
||||||
|
* "quantity": "5",
|
||||||
|
* "item": { "id": 1, "name": "Health Potion", ... }
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/users\/\d+\/inventory$/) && method === "GET") {
|
||||||
|
const id = pathname.split("/")[3] || "0";
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
|
||||||
|
const inventory = await inventoryService.getInventory(id);
|
||||||
|
return jsonResponse({ inventory });
|
||||||
|
}, "fetch inventory");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/users/:id/inventory
|
||||||
|
* @description Adds an item to user's inventory.
|
||||||
|
*
|
||||||
|
* @param id - Discord User ID (snowflake)
|
||||||
|
* @body { itemId: number, quantity: string | number }
|
||||||
|
* @response 201 - `{ success: true, entry: InventoryEntry }`
|
||||||
|
* @response 400 - Missing required fields
|
||||||
|
* @response 500 - Error adding item
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/users\/\d+\/inventory$/) && method === "POST") {
|
||||||
|
const id = pathname.split("/")[3] || "0";
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
|
||||||
|
const data = await req.json() as Record<string, any>;
|
||||||
|
|
||||||
|
if (!data.itemId || !data.quantity) {
|
||||||
|
return errorResponse("Missing required fields: itemId, quantity", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await inventoryService.addItem(id, data.itemId, BigInt(data.quantity));
|
||||||
|
return jsonResponse({ success: true, entry }, 201);
|
||||||
|
}, "add item to inventory");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route DELETE /api/users/:id/inventory/:itemId
|
||||||
|
* @description Removes an item from user's inventory.
|
||||||
|
*
|
||||||
|
* @param id - Discord User ID (snowflake)
|
||||||
|
* @param itemId - Item ID to remove
|
||||||
|
* @query amount - Quantity to remove (default: 1)
|
||||||
|
* @response 204 - Item removed (no content)
|
||||||
|
* @response 500 - Error removing item
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/users\/\d+\/inventory\/\d+$/) && method === "DELETE") {
|
||||||
|
const parts = pathname.split("/");
|
||||||
|
const userId = parts[3] || "";
|
||||||
|
const itemId = parseInt(parts[5] || "0");
|
||||||
|
|
||||||
|
if (!userId) return null;
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
|
||||||
|
|
||||||
|
const amount = url.searchParams.get("amount");
|
||||||
|
const quantity = amount ? BigInt(amount) : 1n;
|
||||||
|
|
||||||
|
await inventoryService.removeItem(userId, itemId, quantity);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}, "remove item from inventory");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usersRoutes: RouteModule = {
|
||||||
|
name: "users",
|
||||||
|
handler
|
||||||
|
};
|
||||||
213
api/src/routes/utils.ts
Normal file
213
api/src/routes/utils.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Utility functions for Aurora API route handlers.
|
||||||
|
* Provides helpers for response formatting, parameter parsing, and validation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z, ZodError, type ZodSchema } from "zod";
|
||||||
|
import type { ApiErrorResponse } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON replacer function that handles BigInt serialization.
|
||||||
|
* Converts BigInt values to strings for JSON compatibility.
|
||||||
|
*/
|
||||||
|
export function jsonReplacer(_key: string, value: unknown): unknown {
|
||||||
|
return typeof value === "bigint" ? value.toString() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a JSON response with proper content-type header and BigInt handling.
|
||||||
|
*
|
||||||
|
* @param data - The data to serialize as JSON
|
||||||
|
* @param status - HTTP status code (default: 200)
|
||||||
|
* @returns A Response object with JSON content
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* return jsonResponse({ items: [...], total: 10 });
|
||||||
|
* return jsonResponse({ success: true, item }, 201);
|
||||||
|
*/
|
||||||
|
export function jsonResponse<T>(data: T, status: number = 200): Response {
|
||||||
|
return new Response(JSON.stringify(data, jsonReplacer), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a standardized error response.
|
||||||
|
*
|
||||||
|
* @param error - Error message
|
||||||
|
* @param status - HTTP status code (default: 500)
|
||||||
|
* @param details - Optional additional error details
|
||||||
|
* @returns A Response object with error JSON
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* return errorResponse("Item not found", 404);
|
||||||
|
* return errorResponse("Validation failed", 400, "Name is required");
|
||||||
|
*/
|
||||||
|
export function errorResponse(
|
||||||
|
error: string,
|
||||||
|
status: number = 500,
|
||||||
|
details?: string
|
||||||
|
): Response {
|
||||||
|
const body: ApiErrorResponse = { error };
|
||||||
|
if (details) body.details = details;
|
||||||
|
|
||||||
|
return Response.json(body, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a validation error response from a ZodError.
|
||||||
|
*
|
||||||
|
* @param zodError - The ZodError from a failed parse
|
||||||
|
* @returns A 400 Response with validation issue details
|
||||||
|
*/
|
||||||
|
export function validationErrorResponse(zodError: ZodError): Response {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: "Invalid payload",
|
||||||
|
issues: zodError.issues.map(issue => ({
|
||||||
|
path: issue.path,
|
||||||
|
message: issue.message
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses and validates a request body against a Zod schema.
|
||||||
|
*
|
||||||
|
* @param req - The HTTP request
|
||||||
|
* @param schema - Zod schema to validate against
|
||||||
|
* @returns Validated data or an error Response
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const result = await parseBody(req, CreateItemSchema);
|
||||||
|
* if (result instanceof Response) return result; // Validation failed
|
||||||
|
* const data = result; // Type-safe validated data
|
||||||
|
*/
|
||||||
|
export async function parseBody<T extends ZodSchema>(
|
||||||
|
req: Request,
|
||||||
|
schema: T
|
||||||
|
): Promise<z.infer<T> | Response> {
|
||||||
|
try {
|
||||||
|
const rawBody = await req.json();
|
||||||
|
const parsed = schema.safeParse(rawBody);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return validationErrorResponse(parsed.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.data;
|
||||||
|
} catch (e) {
|
||||||
|
return errorResponse("Invalid JSON body", 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses query parameters against a Zod schema.
|
||||||
|
*
|
||||||
|
* @param url - The URL containing query parameters
|
||||||
|
* @param schema - Zod schema to validate against
|
||||||
|
* @returns Validated query params or an error Response
|
||||||
|
*/
|
||||||
|
export function parseQuery<T extends ZodSchema>(
|
||||||
|
url: URL,
|
||||||
|
schema: T
|
||||||
|
): z.infer<T> | Response {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
url.searchParams.forEach((value, key) => {
|
||||||
|
params[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(params);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return validationErrorResponse(parsed.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a numeric ID from a URL path segment.
|
||||||
|
*
|
||||||
|
* @param pathname - The URL pathname
|
||||||
|
* @param position - Position from the end (0 = last segment, 1 = second-to-last, etc.)
|
||||||
|
* @returns The parsed integer ID or null if invalid
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* parseIdFromPath("/api/items/123") // returns 123
|
||||||
|
* parseIdFromPath("/api/items/abc") // returns null
|
||||||
|
* parseIdFromPath("/api/users/456/inventory", 1) // returns 456
|
||||||
|
*/
|
||||||
|
export function parseIdFromPath(pathname: string, position: number = 0): number | null {
|
||||||
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
|
const segment = segments[segments.length - 1 - position];
|
||||||
|
|
||||||
|
if (!segment) return null;
|
||||||
|
|
||||||
|
const id = parseInt(segment, 10);
|
||||||
|
return isNaN(id) ? null : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a string ID (like Discord snowflake) from a URL path segment.
|
||||||
|
*
|
||||||
|
* @param pathname - The URL pathname
|
||||||
|
* @param position - Position from the end (0 = last segment)
|
||||||
|
* @returns The string ID or null if segment doesn't exist
|
||||||
|
*/
|
||||||
|
export function parseStringIdFromPath(pathname: string, position: number = 0): string | null {
|
||||||
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
|
const segment = segments[segments.length - 1 - position];
|
||||||
|
return segment || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a pathname matches a pattern with optional parameter placeholders.
|
||||||
|
*
|
||||||
|
* @param pathname - The actual URL pathname
|
||||||
|
* @param pattern - The pattern to match (use :id for numeric params, :param for string params)
|
||||||
|
* @returns True if the pattern matches
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* matchPath("/api/items/123", "/api/items/:id") // true
|
||||||
|
* matchPath("/api/items", "/api/items/:id") // false
|
||||||
|
*/
|
||||||
|
export function matchPath(pathname: string, pattern: string): boolean {
|
||||||
|
const pathParts = pathname.split("/").filter(Boolean);
|
||||||
|
const patternParts = pattern.split("/").filter(Boolean);
|
||||||
|
|
||||||
|
if (pathParts.length !== patternParts.length) return false;
|
||||||
|
|
||||||
|
return patternParts.every((part, i) => {
|
||||||
|
if (part.startsWith(":")) return true; // Matches any value
|
||||||
|
return part === pathParts[i];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps an async route handler with consistent error handling.
|
||||||
|
* Catches all errors and returns appropriate error responses.
|
||||||
|
*
|
||||||
|
* @param handler - The async handler function
|
||||||
|
* @param logContext - Context string for error logging
|
||||||
|
* @returns A wrapped handler with error handling
|
||||||
|
*/
|
||||||
|
export function withErrorHandling(
|
||||||
|
handler: () => Promise<Response>,
|
||||||
|
logContext: string
|
||||||
|
): Promise<Response> {
|
||||||
|
return handler().catch((error: unknown) => {
|
||||||
|
// Dynamic import to avoid circular dependencies
|
||||||
|
return import("@shared/lib/logger").then(({ logger }) => {
|
||||||
|
logger.error("web", `Error in ${logContext}`, error);
|
||||||
|
return errorResponse(
|
||||||
|
`Failed to ${logContext.toLowerCase()}`,
|
||||||
|
500,
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -127,10 +127,18 @@ mock.module("@shared/modules/items/items.service", () => ({
|
|||||||
|
|
||||||
// --- Mock Utilities ---
|
// --- Mock Utilities ---
|
||||||
mock.module("@shared/lib/utils", () => ({
|
mock.module("@shared/lib/utils", () => ({
|
||||||
|
deepMerge: (target: any, source: any) => ({ ...target, ...source }),
|
||||||
jsonReplacer: (key: string, value: any) =>
|
jsonReplacer: (key: string, value: any) =>
|
||||||
typeof value === "bigint" ? value.toString() : value,
|
typeof value === "bigint" ? value.toString() : value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// --- Mock Auth (bypass authentication) ---
|
||||||
|
mock.module("./routes/auth.routes", () => ({
|
||||||
|
authRoutes: { name: "auth", handler: () => null },
|
||||||
|
isAuthenticated: () => true,
|
||||||
|
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
|
||||||
|
}));
|
||||||
|
|
||||||
// --- Mock Logger ---
|
// --- Mock Logger ---
|
||||||
mock.module("@shared/lib/logger", () => ({
|
mock.module("@shared/lib/logger", () => ({
|
||||||
logger: {
|
logger: {
|
||||||
@@ -402,8 +410,11 @@ describe("Items API", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should prevent path traversal attacks", async () => {
|
test("should prevent path traversal attacks", async () => {
|
||||||
const response = await fetch(`${baseUrl}/assets/../../../etc/passwd`);
|
// Note: fetch() and HTTP servers normalize ".." segments before the handler sees them,
|
||||||
// Should either return 403 (Forbidden) or 404 (Not found after sanitization)
|
// so we can't send raw traversal paths over HTTP. Instead, test that a suspicious
|
||||||
|
// asset path (with encoded sequences) doesn't serve sensitive file content.
|
||||||
|
const response = await fetch(`${baseUrl}/assets/..%2f..%2f..%2fetc%2fpasswd`);
|
||||||
|
// Should not serve actual file content — expect 403 or 404
|
||||||
expect([403, 404]).toContain(response.status);
|
expect([403, 404]).toContain(response.status);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,40 +1,64 @@
|
|||||||
import { describe, expect, it, mock, beforeEach, afterEach, jest } from "bun:test";
|
import { describe, expect, it, mock, beforeEach, afterEach, jest } from "bun:test";
|
||||||
import { type WebServerInstance } from "./server";
|
import { type WebServerInstance } from "./server";
|
||||||
|
|
||||||
// Mock the dependencies
|
// Mock gameSettingsService — the route now uses this instead of config/saveConfig
|
||||||
const mockConfig = {
|
const mockSettings = {
|
||||||
leveling: {
|
leveling: {
|
||||||
base: 100,
|
base: 100,
|
||||||
exponent: 1.5,
|
exponent: 1.5,
|
||||||
chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 }
|
chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 }
|
||||||
},
|
},
|
||||||
economy: {
|
economy: {
|
||||||
daily: { amount: 100n, streakBonus: 10n, weeklyBonus: 50n, cooldownMs: 86400000 },
|
daily: { amount: "100", streakBonus: "10", weeklyBonus: "50", cooldownMs: 86400000 },
|
||||||
transfers: { allowSelfTransfer: false, minAmount: 50n },
|
transfers: { allowSelfTransfer: false, minAmount: "1" },
|
||||||
exam: { multMin: 1.5, multMax: 2.5 }
|
exam: { multMin: 1.5, multMax: 2.5 }
|
||||||
},
|
},
|
||||||
inventory: { maxStackSize: 99n, maxSlots: 20 },
|
inventory: { maxStackSize: "99", maxSlots: 20 },
|
||||||
lootdrop: {
|
lootdrop: {
|
||||||
spawnChance: 0.1,
|
spawnChance: 0.1,
|
||||||
cooldownMs: 3600000,
|
cooldownMs: 3600000,
|
||||||
minMessages: 10,
|
minMessages: 10,
|
||||||
|
activityWindowMs: 300000,
|
||||||
reward: { min: 100, max: 500, currency: "gold" }
|
reward: { min: 100, max: 500, currency: "gold" }
|
||||||
},
|
},
|
||||||
commands: { "help": true },
|
commands: { "help": true },
|
||||||
system: {},
|
system: {},
|
||||||
moderation: {
|
moderation: {
|
||||||
prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 },
|
prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 },
|
||||||
cases: { dmOnWarn: true }
|
},
|
||||||
|
trivia: {
|
||||||
|
entryFee: "50",
|
||||||
|
rewardMultiplier: 1.5,
|
||||||
|
timeoutSeconds: 30,
|
||||||
|
cooldownMs: 60000,
|
||||||
|
categories: [],
|
||||||
|
difficulty: "random"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockSaveConfig = jest.fn();
|
const mockGetSettings = jest.fn(() => Promise.resolve(mockSettings));
|
||||||
|
const mockUpsertSettings = jest.fn(() => Promise.resolve(mockSettings));
|
||||||
|
const mockGetDefaults = jest.fn(() => mockSettings);
|
||||||
|
|
||||||
// Mock @shared/lib/config using mock.module
|
mock.module("@shared/modules/game-settings/game-settings.service", () => ({
|
||||||
mock.module("@shared/lib/config", () => ({
|
gameSettingsService: {
|
||||||
config: mockConfig,
|
getSettings: mockGetSettings,
|
||||||
saveConfig: mockSaveConfig,
|
upsertSettings: mockUpsertSettings,
|
||||||
GameConfigType: {}
|
getDefaults: mockGetDefaults,
|
||||||
|
invalidateCache: jest.fn(),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock DrizzleClient (dependency potentially imported transitively)
|
||||||
|
mock.module("@shared/db/DrizzleClient", () => ({
|
||||||
|
DrizzleClient: {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock @shared/lib/utils (deepMerge is used by settings API)
|
||||||
|
mock.module("@shared/lib/utils", () => ({
|
||||||
|
deepMerge: (target: any, source: any) => ({ ...target, ...source }),
|
||||||
|
jsonReplacer: (key: string, value: any) =>
|
||||||
|
typeof value === "bigint" ? value.toString() : value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock BotClient
|
// Mock BotClient
|
||||||
@@ -86,6 +110,13 @@ mock.module("bun", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mock auth (bypass authentication)
|
||||||
|
mock.module("./routes/auth.routes", () => ({
|
||||||
|
authRoutes: { name: "auth", handler: () => null },
|
||||||
|
isAuthenticated: () => true,
|
||||||
|
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
|
||||||
|
}));
|
||||||
|
|
||||||
// Import createWebServer after mocks
|
// Import createWebServer after mocks
|
||||||
import { createWebServer } from "./server";
|
import { createWebServer } from "./server";
|
||||||
|
|
||||||
@@ -97,6 +128,8 @@ describe("Settings API", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
mockGetSettings.mockImplementation(() => Promise.resolve(mockSettings));
|
||||||
|
mockUpsertSettings.mockImplementation(() => Promise.resolve(mockSettings));
|
||||||
serverInstance = await createWebServer({ port: PORT, hostname: HOSTNAME });
|
serverInstance = await createWebServer({ port: PORT, hostname: HOSTNAME });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,18 +143,14 @@ describe("Settings API", () => {
|
|||||||
const res = await fetch(`${BASE_URL}/api/settings`);
|
const res = await fetch(`${BASE_URL}/api/settings`);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json() as any;
|
||||||
// Check if BigInts are converted to strings
|
// Check values come through correctly
|
||||||
expect(data.economy.daily.amount).toBe("100");
|
expect(data.economy.daily.amount).toBe("100");
|
||||||
expect(data.leveling.base).toBe(100);
|
expect(data.leveling.base).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("POST /api/settings should save valid configuration via merge", async () => {
|
it("POST /api/settings should save valid configuration via merge", async () => {
|
||||||
// We only send a partial update, expecting the server to merge it
|
const partialConfig = { economy: { daily: { amount: "200" } } };
|
||||||
// Note: For now the server implementation might still default to overwrite if we haven't updated it yet.
|
|
||||||
// But the user requested "partial vs full" fix.
|
|
||||||
// Let's assume we implement the merge logic.
|
|
||||||
const partialConfig = { studentRole: "new-role-partial" };
|
|
||||||
|
|
||||||
const res = await fetch(`${BASE_URL}/api/settings`, {
|
const res = await fetch(`${BASE_URL}/api/settings`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -130,26 +159,27 @@ describe("Settings API", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
// Expect saveConfig to be called with the MERGED result
|
// upsertSettings should be called with the partial config
|
||||||
expect(mockSaveConfig).toHaveBeenCalledWith(expect.objectContaining({
|
expect(mockUpsertSettings).toHaveBeenCalledWith(
|
||||||
studentRole: "new-role-partial",
|
expect.objectContaining({
|
||||||
leveling: mockConfig.leveling // Should keep existing values
|
economy: { daily: { amount: "200" } }
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("POST /api/settings should return 400 when save fails", async () => {
|
it("POST /api/settings should return 400 when save fails", async () => {
|
||||||
mockSaveConfig.mockImplementationOnce(() => {
|
mockUpsertSettings.mockImplementationOnce(() => {
|
||||||
throw new Error("Validation failed");
|
throw new Error("Validation failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await fetch(`${BASE_URL}/api/settings`, {
|
const res = await fetch(`${BASE_URL}/api/settings`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({}) // Empty might be valid partial, but mocks throw
|
body: JSON.stringify({})
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
const data = await res.json();
|
const data = await res.json() as any;
|
||||||
expect(data.details).toBe("Validation failed");
|
expect(data.details).toBe("Validation failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -157,7 +187,7 @@ describe("Settings API", () => {
|
|||||||
const res = await fetch(`${BASE_URL}/api/settings/meta`);
|
const res = await fetch(`${BASE_URL}/api/settings/meta`);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json() as any;
|
||||||
expect(data.roles).toHaveLength(2);
|
expect(data.roles).toHaveLength(2);
|
||||||
expect(data.roles[0]).toEqual({ id: "role1", name: "Admin", color: "#ffffff" });
|
expect(data.roles[0]).toEqual({ id: "role1", name: "Admin", color: "#ffffff" });
|
||||||
expect(data.channels[0]).toEqual({ id: "chan1", name: "general", type: 0 });
|
expect(data.channels[0]).toEqual({ id: "chan1", name: "general", type: 0 });
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, test, expect, afterAll, mock } from "bun:test";
|
import { describe, test, expect, afterAll, mock } from "bun:test";
|
||||||
import type { WebServerInstance } from "./server";
|
import type { WebServerInstance } from "./server";
|
||||||
import { createWebServer } from "./server";
|
|
||||||
|
|
||||||
interface MockBotStats {
|
interface MockBotStats {
|
||||||
bot: { name: string; avatarUrl: string | null };
|
bot: { name: string; avatarUrl: string | null };
|
||||||
@@ -13,21 +12,21 @@ interface MockBotStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Mock DrizzleClient (dependency of dashboardService)
|
// 1. Mock DrizzleClient (dependency of dashboardService)
|
||||||
|
// Must provide full chainable builder for select().from().leftJoin().groupBy().orderBy().limit()
|
||||||
mock.module("@shared/db/DrizzleClient", () => {
|
mock.module("@shared/db/DrizzleClient", () => {
|
||||||
const mockBuilder = {
|
const mockBuilder: Record<string, any> = {};
|
||||||
where: mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }])),
|
// Every chainable method returns mock builder; terminal calls return resolved promise
|
||||||
then: (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]),
|
mockBuilder.where = mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]));
|
||||||
orderBy: mock(() => mockBuilder), // Chainable
|
mockBuilder.then = (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]);
|
||||||
limit: mock(() => Promise.resolve([])), // Terminal
|
mockBuilder.orderBy = mock(() => mockBuilder);
|
||||||
};
|
mockBuilder.limit = mock(() => Promise.resolve([]));
|
||||||
|
mockBuilder.leftJoin = mock(() => mockBuilder);
|
||||||
const mockFrom = {
|
mockBuilder.groupBy = mock(() => mockBuilder);
|
||||||
from: mock(() => mockBuilder),
|
mockBuilder.from = mock(() => mockBuilder);
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
DrizzleClient: {
|
DrizzleClient: {
|
||||||
select: mock(() => mockFrom),
|
select: mock(() => mockBuilder),
|
||||||
query: {
|
query: {
|
||||||
transactions: { findMany: mock(() => Promise.resolve([])) },
|
transactions: { findMany: mock(() => Promise.resolve([])) },
|
||||||
moderationCases: { findMany: mock(() => Promise.resolve([])) },
|
moderationCases: { findMany: mock(() => Promise.resolve([])) },
|
||||||
@@ -54,7 +53,38 @@ mock.module("../../bot/lib/clientStats", () => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 3. System Events (No mock needed, use real events)
|
// 3. Mock config (used by lootdrop.service.getLootdropState)
|
||||||
|
mock.module("@shared/lib/config", () => ({
|
||||||
|
config: {
|
||||||
|
lootdrop: {
|
||||||
|
activityWindowMs: 120000,
|
||||||
|
minMessages: 1,
|
||||||
|
spawnChance: 1,
|
||||||
|
cooldownMs: 3000,
|
||||||
|
reward: { min: 40, max: 150, currency: "Astral Units" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 4. Mock auth (bypass authentication for testing)
|
||||||
|
mock.module("./routes/auth.routes", () => ({
|
||||||
|
authRoutes: { name: "auth", handler: () => null },
|
||||||
|
isAuthenticated: () => true,
|
||||||
|
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 5. Mock BotClient (used by stats helper for maintenanceMode)
|
||||||
|
mock.module("../../bot/lib/BotClient", () => ({
|
||||||
|
AuroraClient: {
|
||||||
|
maintenanceMode: false,
|
||||||
|
guilds: { cache: { get: () => null } },
|
||||||
|
commands: [],
|
||||||
|
knownCommands: new Map(),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after all mocks are set up
|
||||||
|
import { createWebServer } from "./server";
|
||||||
|
|
||||||
describe("WebServer Security & Limits", () => {
|
describe("WebServer Security & Limits", () => {
|
||||||
const port = 3001;
|
const port = 3001;
|
||||||
243
api/src/server.ts
Normal file
243
api/src/server.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview API server factory module.
|
||||||
|
* Exports a function to create and start the API server.
|
||||||
|
* This allows the server to be started in-process from the main application.
|
||||||
|
*
|
||||||
|
* Routes are organized into modular files in the ./routes directory.
|
||||||
|
* Each route module handles its own validation, business logic, and responses.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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;
|
||||||
|
hostname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebServerInstance {
|
||||||
|
server: ReturnType<typeof serve>;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and starts the API server.
|
||||||
|
*
|
||||||
|
* @param config - Server configuration options
|
||||||
|
* @param config.port - Port to listen on (default: 3000)
|
||||||
|
* @param config.hostname - Hostname to bind to (default: "localhost")
|
||||||
|
* @returns Promise resolving to server instance with stop() method
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const server = await createWebServer({ port: 3000, hostname: "0.0.0.0" });
|
||||||
|
* console.log(`Server running at ${server.url}`);
|
||||||
|
*
|
||||||
|
* // 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;
|
||||||
|
|
||||||
|
// Configuration constants
|
||||||
|
const MAX_CONNECTIONS = 10;
|
||||||
|
const MAX_PAYLOAD_BYTES = 16384; // 16KB
|
||||||
|
const IDLE_TIMEOUT_SECONDS = 60;
|
||||||
|
|
||||||
|
// Interval for broadcasting stats to all connected WS clients
|
||||||
|
let statsBroadcastInterval: Timer | undefined;
|
||||||
|
|
||||||
|
const server = serve({
|
||||||
|
port,
|
||||||
|
hostname,
|
||||||
|
async fetch(req, server) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
|
||||||
|
// WebSocket upgrade handling
|
||||||
|
if (url.pathname === "/ws") {
|
||||||
|
const currentConnections = server.pendingWebSockets;
|
||||||
|
if (currentConnections >= MAX_CONNECTIONS) {
|
||||||
|
logger.warn("web", `Connection rejected: limit reached (${currentConnections}/${MAX_CONNECTIONS})`);
|
||||||
|
return new Response("Connection limit reached", { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = server.upgrade(req);
|
||||||
|
if (success) return undefined;
|
||||||
|
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate to modular route handlers
|
||||||
|
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 });
|
||||||
|
},
|
||||||
|
|
||||||
|
websocket: {
|
||||||
|
/**
|
||||||
|
* Called when a WebSocket client connects.
|
||||||
|
* Subscribes the client to the dashboard channel and sends initial stats.
|
||||||
|
*/
|
||||||
|
open(ws) {
|
||||||
|
ws.subscribe("dashboard");
|
||||||
|
logger.debug("web", `Client connected. Total: ${server.pendingWebSockets}`);
|
||||||
|
|
||||||
|
// Send initial stats
|
||||||
|
getFullDashboardStats().then(stats => {
|
||||||
|
ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start broadcast interval if this is the first client
|
||||||
|
if (!statsBroadcastInterval) {
|
||||||
|
statsBroadcastInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const stats = await getFullDashboardStats();
|
||||||
|
server.publish("dashboard", JSON.stringify({ type: "STATS_UPDATE", data: stats }));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("web", "Error in stats broadcast", error);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a WebSocket message is received.
|
||||||
|
* Handles PING/PONG heartbeat messages.
|
||||||
|
*/
|
||||||
|
async message(ws, message) {
|
||||||
|
try {
|
||||||
|
const messageStr = message.toString();
|
||||||
|
|
||||||
|
// Defense-in-depth: redundant length check before parsing
|
||||||
|
if (messageStr.length > MAX_PAYLOAD_BYTES) {
|
||||||
|
logger.error("web", "Payload exceeded maximum limit");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawData = JSON.parse(messageStr);
|
||||||
|
const { WsMessageSchema } = await import("@shared/modules/dashboard/dashboard.types");
|
||||||
|
const parsed = WsMessageSchema.safeParse(rawData);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
logger.error("web", "Invalid message format", parsed.error.issues);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.type === "PING") {
|
||||||
|
ws.send(JSON.stringify({ type: "PONG" }));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("web", "Failed to handle message", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a WebSocket client disconnects.
|
||||||
|
* Stops the broadcast interval if no clients remain.
|
||||||
|
*/
|
||||||
|
close(ws) {
|
||||||
|
ws.unsubscribe("dashboard");
|
||||||
|
logger.debug("web", `Client disconnected. Total remaining: ${server.pendingWebSockets}`);
|
||||||
|
|
||||||
|
// Stop broadcast interval if no clients left
|
||||||
|
if (server.pendingWebSockets === 0 && statsBroadcastInterval) {
|
||||||
|
clearInterval(statsBroadcastInterval);
|
||||||
|
statsBroadcastInterval = undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
maxPayloadLength: MAX_PAYLOAD_BYTES,
|
||||||
|
idleTimeout: IDLE_TIMEOUT_SECONDS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for real-time events from the system bus
|
||||||
|
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
||||||
|
systemEvents.on(EVENTS.DASHBOARD.NEW_EVENT, (event) => {
|
||||||
|
server.publish("dashboard", JSON.stringify({ type: "NEW_EVENT", data: event }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `http://${hostname}:${port}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
url,
|
||||||
|
stop: async () => {
|
||||||
|
if (statsBroadcastInterval) {
|
||||||
|
clearInterval(statsBroadcastInterval);
|
||||||
|
}
|
||||||
|
server.stop(true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the web server from the main application root.
|
||||||
|
* Kept for backward compatibility.
|
||||||
|
*
|
||||||
|
* @param webProjectPath - Deprecated, no longer used
|
||||||
|
* @param config - Server configuration options
|
||||||
|
* @returns Promise resolving to server instance
|
||||||
|
*/
|
||||||
|
export async function startWebServerFromRoot(
|
||||||
|
webProjectPath: string,
|
||||||
|
config: WebServerConfig = {}
|
||||||
|
): Promise<WebServerInstance> {
|
||||||
|
return createWebServer(config);
|
||||||
|
}
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// Environment setup & latest features
|
// Environment setup & latest features
|
||||||
"lib": [
|
"lib": ["ESNext"],
|
||||||
"ESNext",
|
|
||||||
"DOM"
|
|
||||||
],
|
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "Preserve",
|
"module": "Preserve",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"jsx": "react-jsx",
|
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
// Bundler mode
|
// Bundler mode
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
@@ -38,8 +34,5 @@
|
|||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false
|
"noPropertyAccessFromIndexSignature": false
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": ["node_modules"]
|
||||||
"dist",
|
|
||||||
"node_modules"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const moderationCase = createCommand({
|
export const moderationCase = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -16,39 +17,35 @@ export const moderationCase = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||||
|
|
||||||
try {
|
// Validate case ID format
|
||||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
if (!caseId.match(/^CASE-\d+$/)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate case ID format
|
// Get the case
|
||||||
if (!caseId.match(/^CASE-\d+$/)) {
|
const moderationCase = await moderationService.getCaseById(caseId);
|
||||||
|
|
||||||
|
if (!moderationCase) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display the case
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
embeds: [getCaseEmbed(moderationCase)]
|
||||||
});
|
});
|
||||||
return;
|
},
|
||||||
}
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
// Get the case
|
|
||||||
const moderationCase = await ModerationService.getCaseById(caseId);
|
|
||||||
|
|
||||||
if (!moderationCase) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display the case
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getCaseEmbed(moderationCase)]
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Case command error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while fetching the case.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const cases = createCommand({
|
export const cases = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -22,33 +23,29 @@ export const cases = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
||||||
|
|
||||||
try {
|
// Get cases for the user
|
||||||
const targetUser = interaction.options.getUser("user", true);
|
const userCases = await moderationService.getUserCases(targetUser.id, activeOnly);
|
||||||
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
|
||||||
|
|
||||||
// Get cases for the user
|
const title = activeOnly
|
||||||
const userCases = await ModerationService.getUserCases(targetUser.id, activeOnly);
|
? `⚠️ Active Cases for ${targetUser.username}`
|
||||||
|
: `📋 All Cases for ${targetUser.username}`;
|
||||||
|
|
||||||
const title = activeOnly
|
const description = userCases.length === 0
|
||||||
? `⚠️ Active Cases for ${targetUser.username}`
|
? undefined
|
||||||
: `📋 All Cases for ${targetUser.username}`;
|
: `Total cases: **${userCases.length}**`;
|
||||||
|
|
||||||
const description = userCases.length === 0
|
// Display the cases
|
||||||
? undefined
|
await interaction.editReply({
|
||||||
: `Total cases: **${userCases.length}**`;
|
embeds: [getCasesListEmbed(userCases, title, description)]
|
||||||
|
});
|
||||||
// Display the cases
|
},
|
||||||
await interaction.editReply({
|
{ ephemeral: true }
|
||||||
embeds: [getCasesListEmbed(userCases, title, description)]
|
);
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Cases command error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while fetching cases.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const clearwarning = createCommand({
|
export const clearwarning = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -23,62 +24,58 @@ export const clearwarning = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||||
|
const reason = interaction.options.getString("reason") || "Cleared by moderator";
|
||||||
|
|
||||||
try {
|
// Validate case ID format
|
||||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
if (!caseId.match(/^CASE-\d+$/)) {
|
||||||
const reason = interaction.options.getString("reason") || "Cleared by moderator";
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate case ID format
|
// Check if case exists and is active
|
||||||
if (!caseId.match(/^CASE-\d+$/)) {
|
const existingCase = await moderationService.getCaseById(caseId);
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
if (!existingCase) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingCase.active) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed(`Case **${caseId}** is already resolved.`)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingCase.type !== 'warn') {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed(`Case **${caseId}** is not a warning. Only warnings can be cleared.`)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the warning
|
||||||
|
await moderationService.clearCase({
|
||||||
|
caseId,
|
||||||
|
clearedBy: interaction.user.id,
|
||||||
|
clearedByName: interaction.user.username,
|
||||||
|
reason
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if case exists and is active
|
// Send success message
|
||||||
const existingCase = await ModerationService.getCaseById(caseId);
|
|
||||||
|
|
||||||
if (!existingCase) {
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
embeds: [getClearSuccessEmbed(caseId)]
|
||||||
});
|
});
|
||||||
return;
|
},
|
||||||
}
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
if (!existingCase.active) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** is already resolved.`)]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingCase.type !== 'warn') {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** is not a warning. Only warnings can be cleared.`)]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear the warning
|
|
||||||
await ModerationService.clearCase({
|
|
||||||
caseId,
|
|
||||||
clearedBy: interaction.user.id,
|
|
||||||
clearedByName: interaction.user.username,
|
|
||||||
reason
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send success message
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getClearSuccessEmbed(caseId)]
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Clear warning command error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while clearing the warning.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { config, saveConfig } from "@shared/lib/config";
|
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
|
||||||
|
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
||||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import { items } from "@db/schema";
|
import { items } from "@db/schema";
|
||||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const createColor = createCommand({
|
export const createColor = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -31,64 +33,60 @@ export const createColor = createCommand({
|
|||||||
)
|
)
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
const colorInput = interaction.options.getString("color", true);
|
||||||
|
const price = interaction.options.getNumber("price") || 500;
|
||||||
|
const imageUrl = interaction.options.getString("image") || "https://cdn.discordapp.com/attachments/1450061247365124199/1453122950822760559/Main_Chip_1.png";
|
||||||
|
|
||||||
const name = interaction.options.getString("name", true);
|
// 1. Validate Color
|
||||||
const colorInput = interaction.options.getString("color", true);
|
const colorRegex = /^#([0-9A-F]{3}){1,2}$/i;
|
||||||
const price = interaction.options.getNumber("price") || 500;
|
if (!colorRegex.test(colorInput)) {
|
||||||
const imageUrl = interaction.options.getString("image") || "https://cdn.discordapp.com/attachments/1450061247365124199/1453122950822760559/Main_Chip_1.png";
|
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Validate Color
|
// 2. Create Role
|
||||||
const colorRegex = /^#([0-9A-F]{3}){1,2}$/i;
|
const role = await interaction.guild?.roles.create({
|
||||||
if (!colorRegex.test(colorInput)) {
|
name: name,
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] });
|
color: colorInput as any,
|
||||||
return;
|
reason: `Created via /createcolor by ${interaction.user.tag}`
|
||||||
}
|
});
|
||||||
|
|
||||||
try {
|
if (!role) {
|
||||||
// 2. Create Role
|
throw new Error("Failed to create role.");
|
||||||
const role = await interaction.guild?.roles.create({
|
}
|
||||||
name: name,
|
|
||||||
color: colorInput as any, // Discord.js types are a bit strict on ColorResolvable, but string generally works or needs parsing
|
|
||||||
reason: `Created via /createcolor by ${interaction.user.tag}`
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!role) {
|
// 3. Add to guild settings
|
||||||
throw new Error("Failed to create role.");
|
await guildSettingsService.addColorRole(interaction.guildId!, role.id);
|
||||||
}
|
invalidateGuildConfigCache(interaction.guildId!);
|
||||||
|
|
||||||
// 3. Update Config
|
// 4. Create Item
|
||||||
if (!config.colorRoles.includes(role.id)) {
|
await DrizzleClient.insert(items).values({
|
||||||
config.colorRoles.push(role.id);
|
name: `Color Role - ${name}`,
|
||||||
saveConfig(config);
|
description: `Use this item to apply the ${name} color to your name.`,
|
||||||
}
|
type: "CONSUMABLE",
|
||||||
|
rarity: "Common",
|
||||||
|
price: BigInt(price),
|
||||||
|
iconUrl: "",
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
usageData: {
|
||||||
|
consume: false,
|
||||||
|
effects: [{ type: "COLOR_ROLE", roleId: role.id }]
|
||||||
|
} as any
|
||||||
|
});
|
||||||
|
|
||||||
// 4. Create Item
|
// 5. Success
|
||||||
await DrizzleClient.insert(items).values({
|
await interaction.editReply({
|
||||||
name: `Color Role - ${name}`,
|
embeds: [createSuccessEmbed(
|
||||||
description: `Use this item to apply the ${name} color to your name.`,
|
`**Role:** <@&${role.id}> (${colorInput})\n**Item:** Color Role - ${name}\n**Price:** ${price} 🪙`,
|
||||||
type: "CONSUMABLE",
|
"✅ Color Role & Item Created"
|
||||||
rarity: "Common",
|
)]
|
||||||
price: BigInt(price),
|
});
|
||||||
iconUrl: "",
|
},
|
||||||
imageUrl: imageUrl,
|
{ ephemeral: true }
|
||||||
usageData: {
|
);
|
||||||
consume: false,
|
|
||||||
effects: [{ type: "COLOR_ROLE", roleId: role.id }]
|
|
||||||
} as any
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Success
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createSuccessEmbed(
|
|
||||||
`**Role:** <@&${role.id}> (${colorInput})\n**Item:** Color Role - ${name}\n**Price:** ${price} 🪙`,
|
|
||||||
"✅ Color Role & Item Created"
|
|
||||||
)]
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error in createcolor:", error);
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(`Failed to create color role: ${error.message}`)] });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
293
bot/commands/admin/featureflags.ts
Normal file
293
bot/commands/admin/featureflags.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
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 { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
|
export const featureflags = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("featureflags")
|
||||||
|
.setDescription("Manage feature flags for beta testing")
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("list")
|
||||||
|
.setDescription("List all feature flags")
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("create")
|
||||||
|
.setDescription("Create a new feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("description")
|
||||||
|
.setDescription("Description of the feature flag")
|
||||||
|
.setRequired(false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("delete")
|
||||||
|
.setDescription("Delete a feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("enable")
|
||||||
|
.setDescription("Enable a feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("disable")
|
||||||
|
.setDescription("Disable a feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("grant")
|
||||||
|
.setDescription("Grant access to a feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
|
.addUserOption(opt =>
|
||||||
|
opt.setName("user")
|
||||||
|
.setDescription("User to grant access to")
|
||||||
|
.setRequired(false)
|
||||||
|
)
|
||||||
|
.addRoleOption(opt =>
|
||||||
|
opt.setName("role")
|
||||||
|
.setDescription("Role to grant access to")
|
||||||
|
.setRequired(false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("revoke")
|
||||||
|
.setDescription("Revoke access from a feature flag")
|
||||||
|
.addIntegerOption(opt =>
|
||||||
|
opt.setName("id")
|
||||||
|
.setDescription("Access record ID to revoke")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("access")
|
||||||
|
.setDescription("List access records for a feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
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 withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleList(interaction: ChatInputCommandInteraction) {
|
||||||
|
const flags = await featureFlagsService.listFlags();
|
||||||
|
|
||||||
|
if (flags.length === 0) {
|
||||||
|
await interaction.editReply({ embeds: [createBaseEmbed("Feature Flags", "No feature flags have been created yet.", Colors.Blue)] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = createBaseEmbed("Feature Flags", undefined, Colors.Blue)
|
||||||
|
.addFields(
|
||||||
|
flags.map(f => ({
|
||||||
|
name: f.name,
|
||||||
|
value: `${f.enabled ? "✅ Enabled" : "❌ Disabled"}\n${f.description || "*No description*"}`,
|
||||||
|
inline: false,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(interaction: ChatInputCommandInteraction) {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
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.`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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.`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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.`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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.`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGrant(interaction: ChatInputCommandInteraction) {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
const user = interaction.options.getUser("user");
|
||||||
|
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.")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await featureFlagsService.grantAccess(name, {
|
||||||
|
userId: user?.id,
|
||||||
|
roleId: role?.id,
|
||||||
|
guildId: interaction.guildId!,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!access) {
|
||||||
|
await interaction.editReply({ embeds: [createErrorEmbed("Failed to grant access.")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target: string;
|
||||||
|
if (user) {
|
||||||
|
target = userMention(user.id);
|
||||||
|
} else if (role) {
|
||||||
|
target = roleMention(role.id);
|
||||||
|
} else {
|
||||||
|
target = "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
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.`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = accessRecords.map(a => {
|
||||||
|
let target = "Unknown";
|
||||||
|
if (a.userId) target = `User: ${userMention(a.userId.toString())}`;
|
||||||
|
else if (a.roleId) target = `Role: ${roleMention(a.roleId.toString())}`;
|
||||||
|
else if (a.guildId) target = `Guild: ${a.guildId.toString()}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: `ID: ${a.id}`,
|
||||||
|
value: target,
|
||||||
|
inline: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const embed = createBaseEmbed(`Feature Flag Access: ${name}`, undefined, Colors.Blue)
|
||||||
|
.addFields(fields);
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
}
|
||||||
@@ -7,12 +7,12 @@ import {
|
|||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@shared/lib/errors";
|
|
||||||
import { items } from "@db/schema";
|
import { items } from "@db/schema";
|
||||||
import { ilike, isNotNull, and, inArray } from "drizzle-orm";
|
import { ilike, isNotNull, and, inArray } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import { getShopListingMessage } from "@/modules/economy/shop.view";
|
import { getShopListingMessage } from "@/modules/economy/shop.view";
|
||||||
import { EffectType, LootType } from "@shared/lib/constants";
|
import { EffectType, LootType } from "@shared/lib/constants";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const listing = createCommand({
|
export const listing = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -31,72 +31,67 @@ export const listing = createCommand({
|
|||||||
)
|
)
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const itemId = interaction.options.getNumber("item", true);
|
||||||
|
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
|
||||||
|
|
||||||
const itemId = interaction.options.getNumber("item", true);
|
if (!targetChannel || !targetChannel.isSendable()) {
|
||||||
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
|
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
|
||||||
|
return;
|
||||||
if (!targetChannel || !targetChannel.isSendable()) {
|
|
||||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = await inventoryService.getItem(itemId);
|
|
||||||
if (!item) {
|
|
||||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item.price) {
|
|
||||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare context for lootboxes
|
|
||||||
const context: { referencedItems: Map<number, { name: string; rarity: string }> } = { referencedItems: new Map() };
|
|
||||||
|
|
||||||
const usageData = item.usageData as any;
|
|
||||||
const lootboxEffect = usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
|
|
||||||
|
|
||||||
if (lootboxEffect && lootboxEffect.pool) {
|
|
||||||
const itemIds = lootboxEffect.pool
|
|
||||||
.filter((drop: any) => drop.type === LootType.ITEM && drop.itemId)
|
|
||||||
.map((drop: any) => drop.itemId);
|
|
||||||
|
|
||||||
if (itemIds.length > 0) {
|
|
||||||
// Remove duplicates
|
|
||||||
const uniqueIds = [...new Set(itemIds)] as number[];
|
|
||||||
|
|
||||||
const referencedItems = await DrizzleClient.select({
|
|
||||||
id: items.id,
|
|
||||||
name: items.name,
|
|
||||||
rarity: items.rarity
|
|
||||||
}).from(items).where(inArray(items.id, uniqueIds));
|
|
||||||
|
|
||||||
for (const ref of referencedItems) {
|
|
||||||
context.referencedItems.set(ref.id, { name: ref.name, rarity: ref.rarity || 'C' });
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const listingMessage = getShopListingMessage({
|
const item = await inventoryService.getItem(itemId);
|
||||||
...item,
|
if (!item) {
|
||||||
rarity: item.rarity || undefined,
|
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
|
||||||
formattedPrice: `${item.price} 🪙`,
|
return;
|
||||||
price: item.price
|
}
|
||||||
}, context);
|
|
||||||
|
|
||||||
try {
|
if (!item.price) {
|
||||||
await targetChannel.send(listingMessage as any);
|
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
|
||||||
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
return;
|
||||||
} catch (error: any) {
|
}
|
||||||
if (error instanceof UserError) {
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
// Prepare context for lootboxes
|
||||||
} else {
|
const context: { referencedItems: Map<number, { name: string; rarity: string }> } = { referencedItems: new Map() };
|
||||||
console.error("Error creating listing:", error);
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
const usageData = item.usageData as any;
|
||||||
}
|
const lootboxEffect = usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
|
||||||
}
|
|
||||||
|
if (lootboxEffect && lootboxEffect.pool) {
|
||||||
|
const itemIds = lootboxEffect.pool
|
||||||
|
.filter((drop: any) => drop.type === LootType.ITEM && drop.itemId)
|
||||||
|
.map((drop: any) => drop.itemId);
|
||||||
|
|
||||||
|
if (itemIds.length > 0) {
|
||||||
|
// Remove duplicates
|
||||||
|
const uniqueIds = [...new Set(itemIds)] as number[];
|
||||||
|
|
||||||
|
const referencedItems = await DrizzleClient.select({
|
||||||
|
id: items.id,
|
||||||
|
name: items.name,
|
||||||
|
rarity: items.rarity
|
||||||
|
}).from(items).where(inArray(items.id, uniqueIds));
|
||||||
|
|
||||||
|
for (const ref of referencedItems) {
|
||||||
|
context.referencedItems.set(ref.id, { name: ref.name, rarity: ref.rarity || 'C' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listingMessage = getShopListingMessage({
|
||||||
|
...item,
|
||||||
|
rarity: item.rarity || undefined,
|
||||||
|
formattedPrice: `${item.price} 🪙`,
|
||||||
|
price: item.price
|
||||||
|
}, context);
|
||||||
|
|
||||||
|
await targetChannel.send(listingMessage as any);
|
||||||
|
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
||||||
|
},
|
||||||
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
},
|
},
|
||||||
autocomplete: async (interaction) => {
|
autocomplete: async (interaction) => {
|
||||||
const focusedValue = interaction.options.getFocused();
|
const focusedValue = interaction.options.getFocused();
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { CaseType } from "@shared/lib/constants";
|
import { CaseType } from "@shared/lib/constants";
|
||||||
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const note = createCommand({
|
export const note = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -24,39 +25,35 @@ export const note = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
const noteText = interaction.options.getString("note", true);
|
||||||
|
|
||||||
try {
|
// Create the note case
|
||||||
const targetUser = interaction.options.getUser("user", true);
|
const moderationCase = await moderationService.createCase({
|
||||||
const noteText = interaction.options.getString("note", true);
|
type: CaseType.NOTE,
|
||||||
|
userId: targetUser.id,
|
||||||
// Create the note case
|
username: targetUser.username,
|
||||||
const moderationCase = await ModerationService.createCase({
|
moderatorId: interaction.user.id,
|
||||||
type: CaseType.NOTE,
|
moderatorName: interaction.user.username,
|
||||||
userId: targetUser.id,
|
reason: noteText,
|
||||||
username: targetUser.username,
|
|
||||||
moderatorId: interaction.user.id,
|
|
||||||
moderatorName: interaction.user.username,
|
|
||||||
reason: noteText,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!moderationCase) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("Failed to create note.")]
|
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send success message
|
if (!moderationCase) {
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
|
embeds: [getModerationErrorEmbed("Failed to create note.")]
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
// Send success message
|
||||||
console.error("Note command error:", error);
|
await interaction.editReply({
|
||||||
await interaction.editReply({
|
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while adding the note.")]
|
});
|
||||||
});
|
},
|
||||||
}
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const notes = createCommand({
|
export const notes = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -16,28 +17,24 @@ export const notes = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
|
||||||
try {
|
// Get all notes for the user
|
||||||
const targetUser = interaction.options.getUser("user", true);
|
const userNotes = await moderationService.getUserNotes(targetUser.id);
|
||||||
|
|
||||||
// Get all notes for the user
|
// Display the notes
|
||||||
const userNotes = await ModerationService.getUserNotes(targetUser.id);
|
await interaction.editReply({
|
||||||
|
embeds: [getCasesListEmbed(
|
||||||
// Display the notes
|
userNotes,
|
||||||
await interaction.editReply({
|
`📝 Staff Notes for ${targetUser.username}`,
|
||||||
embeds: [getCasesListEmbed(
|
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
|
||||||
userNotes,
|
)]
|
||||||
`📝 Staff Notes for ${targetUser.username}`,
|
});
|
||||||
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
|
},
|
||||||
)]
|
{ ephemeral: true }
|
||||||
});
|
);
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Notes command error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while fetching notes.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||||
import { config } from "@shared/lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
import { PruneService } from "@shared/modules/moderation/prune.service";
|
import { pruneService } from "@shared/modules/moderation/prune.service";
|
||||||
import {
|
import {
|
||||||
getConfirmationMessage,
|
getConfirmationMessage,
|
||||||
getProgressEmbed,
|
getProgressEmbed,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getPruneWarningEmbed,
|
getPruneWarningEmbed,
|
||||||
getCancelledEmbed
|
getCancelledEmbed
|
||||||
} from "@/modules/moderation/prune.view";
|
} from "@/modules/moderation/prune.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const prune = createCommand({
|
export const prune = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -38,142 +39,126 @@ export const prune = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const amount = interaction.options.getInteger("amount");
|
||||||
|
const user = interaction.options.getUser("user");
|
||||||
|
const all = interaction.options.getBoolean("all") || false;
|
||||||
|
|
||||||
try {
|
// Validate inputs
|
||||||
const amount = interaction.options.getInteger("amount");
|
if (!amount && !all) {
|
||||||
const user = interaction.options.getUser("user");
|
// Default to 10 messages
|
||||||
const all = interaction.options.getBoolean("all") || false;
|
} else if (amount && all) {
|
||||||
|
|
||||||
// Validate inputs
|
|
||||||
if (!amount && !all) {
|
|
||||||
// Default to 10 messages
|
|
||||||
} else if (amount && all) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalAmount = all ? 'all' : (amount || 10);
|
|
||||||
const confirmThreshold = config.moderation.prune.confirmThreshold;
|
|
||||||
|
|
||||||
// Check if confirmation is needed
|
|
||||||
const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
|
|
||||||
|
|
||||||
if (needsConfirmation) {
|
|
||||||
// Estimate message count for confirmation
|
|
||||||
let estimatedCount: number | undefined;
|
|
||||||
if (all) {
|
|
||||||
try {
|
|
||||||
estimatedCount = await PruneService.estimateMessageCount(interaction.channel!);
|
|
||||||
} catch {
|
|
||||||
estimatedCount = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { embeds, components } = getConfirmationMessage(finalAmount, estimatedCount);
|
|
||||||
const response = await interaction.editReply({ embeds, components });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const confirmation = await response.awaitMessageComponent({
|
|
||||||
filter: (i) => i.user.id === interaction.user.id,
|
|
||||||
componentType: ComponentType.Button,
|
|
||||||
time: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
if (confirmation.customId === "cancel_prune") {
|
|
||||||
await confirmation.update({
|
|
||||||
embeds: [getCancelledEmbed()],
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// User confirmed, proceed with deletion
|
|
||||||
await confirmation.update({
|
|
||||||
embeds: [getProgressEmbed({ current: 0, total: estimatedCount || finalAmount as number })],
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
|
|
||||||
// Execute deletion with progress callback for 'all' mode
|
|
||||||
const result = await PruneService.deleteMessages(
|
|
||||||
interaction.channel!,
|
|
||||||
{
|
|
||||||
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
|
||||||
userId: user?.id,
|
|
||||||
all
|
|
||||||
},
|
|
||||||
all ? async (progress) => {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getProgressEmbed(progress)]
|
|
||||||
});
|
|
||||||
} : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Show success
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [getSuccessEmbed(result)],
|
embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
|
||||||
components: []
|
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.message.includes("time")) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getPruneWarningEmbed("Confirmation timed out. Please try again.")],
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No confirmation needed, proceed directly
|
|
||||||
const result = await PruneService.deleteMessages(
|
|
||||||
interaction.channel!,
|
|
||||||
{
|
|
||||||
amount: finalAmount as number,
|
|
||||||
userId: user?.id,
|
|
||||||
all: false
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if no messages were found
|
|
||||||
if (result.deletedCount === 0) {
|
|
||||||
if (user) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getPruneWarningEmbed(`No messages found from **${user.username}** in the last ${finalAmount} messages.`)]
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getPruneWarningEmbed("No messages found to delete.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.editReply({
|
const finalAmount = all ? 'all' : (amount || 10);
|
||||||
embeds: [getSuccessEmbed(result)]
|
const confirmThreshold = config.moderation.prune.confirmThreshold;
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
// Check if confirmation is needed
|
||||||
console.error("Prune command error:", error);
|
const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
|
||||||
|
|
||||||
let errorMessage = "An unexpected error occurred while trying to delete messages.";
|
if (needsConfirmation) {
|
||||||
if (error instanceof Error) {
|
// Estimate message count for confirmation
|
||||||
if (error.message.includes("permission")) {
|
let estimatedCount: number | undefined;
|
||||||
errorMessage = "I don't have permission to delete messages in this channel.";
|
if (all) {
|
||||||
} else if (error.message.includes("channel type")) {
|
try {
|
||||||
errorMessage = "This command cannot be used in this type of channel.";
|
estimatedCount = await pruneService.estimateMessageCount(interaction.channel!);
|
||||||
|
} catch {
|
||||||
|
estimatedCount = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { embeds, components } = getConfirmationMessage(finalAmount, estimatedCount);
|
||||||
|
const response = await interaction.editReply({ embeds, components });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const confirmation = await response.awaitMessageComponent({
|
||||||
|
filter: (i) => i.user.id === interaction.user.id,
|
||||||
|
componentType: ComponentType.Button,
|
||||||
|
time: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmation.customId === "cancel_prune") {
|
||||||
|
await confirmation.update({
|
||||||
|
embeds: [getCancelledEmbed()],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User confirmed, proceed with deletion
|
||||||
|
await confirmation.update({
|
||||||
|
embeds: [getProgressEmbed({ current: 0, total: estimatedCount || finalAmount as number })],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute deletion with progress callback for 'all' mode
|
||||||
|
const result = await pruneService.deleteMessages(
|
||||||
|
interaction.channel!,
|
||||||
|
{
|
||||||
|
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
||||||
|
userId: user?.id,
|
||||||
|
all
|
||||||
|
},
|
||||||
|
all ? async (progress) => {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getProgressEmbed(progress)]
|
||||||
|
});
|
||||||
|
} : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show success
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getSuccessEmbed(result)],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes("time")) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getPruneWarningEmbed("Confirmation timed out. Please try again.")],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
errorMessage = error.message;
|
// No confirmation needed, proceed directly
|
||||||
}
|
const result = await pruneService.deleteMessages(
|
||||||
}
|
interaction.channel!,
|
||||||
|
{
|
||||||
|
amount: finalAmount as number,
|
||||||
|
userId: user?.id,
|
||||||
|
all: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
await interaction.editReply({
|
// Check if no messages were found
|
||||||
embeds: [getPruneErrorEmbed(errorMessage)]
|
if (result.deletedCount === 0) {
|
||||||
});
|
if (user) {
|
||||||
}
|
await interaction.editReply({
|
||||||
|
embeds: [getPruneWarningEmbed(`No messages found from **${user.username}** in the last ${finalAmount} messages.`)]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getPruneWarningEmbed("No messages found to delete.")]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getSuccessEmbed(result)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createSuccessEmbed } from "@lib/embeds";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const refresh = createCommand({
|
export const refresh = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -9,25 +10,24 @@ export const refresh = createCommand({
|
|||||||
.setDescription("Reloads all commands and config without restarting")
|
.setDescription("Reloads all commands and config without restarting")
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const start = Date.now();
|
||||||
|
await AuroraClient.loadCommands(true);
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
try {
|
// Deploy commands
|
||||||
const start = Date.now();
|
await AuroraClient.deployCommands();
|
||||||
await AuroraClient.loadCommands(true);
|
|
||||||
const duration = Date.now() - start;
|
|
||||||
|
|
||||||
// Deploy commands
|
const embed = createSuccessEmbed(
|
||||||
await AuroraClient.deployCommands();
|
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
|
||||||
|
"System Refreshed"
|
||||||
|
);
|
||||||
|
|
||||||
const embed = createSuccessEmbed(
|
await interaction.editReply({ embeds: [embed] });
|
||||||
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
|
},
|
||||||
"System Refreshed"
|
{ ephemeral: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while refreshing commands. Check console for details.", "Refresh Failed")] });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
243
bot/commands/admin/settings.ts
Normal file
243
bot/commands/admin/settings.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import { createCommand } from "@shared/lib/utils";
|
||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, Colors, ChatInputCommandInteraction } from "discord.js";
|
||||||
|
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 { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
|
export const settings = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("settings")
|
||||||
|
.setDescription("Manage guild settings")
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("show")
|
||||||
|
.setDescription("Show current guild settings"))
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("set")
|
||||||
|
.setDescription("Set a guild setting")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("key")
|
||||||
|
.setDescription("Setting to change")
|
||||||
|
.setRequired(true)
|
||||||
|
.addChoices(
|
||||||
|
{ name: "Student Role", value: "studentRole" },
|
||||||
|
{ name: "Visitor Role", value: "visitorRole" },
|
||||||
|
{ name: "Welcome Channel", value: "welcomeChannel" },
|
||||||
|
{ name: "Welcome Message", value: "welcomeMessage" },
|
||||||
|
{ name: "Feedback Channel", value: "feedbackChannel" },
|
||||||
|
{ name: "Terminal Channel", value: "terminalChannel" },
|
||||||
|
{ name: "Terminal Message", value: "terminalMessage" },
|
||||||
|
{ name: "Moderation Log Channel", value: "moderationLogChannel" },
|
||||||
|
{ name: "DM on Warn", value: "moderationDmOnWarn" },
|
||||||
|
{ name: "Auto Timeout Threshold", value: "moderationAutoTimeoutThreshold" },
|
||||||
|
))
|
||||||
|
.addRoleOption(opt =>
|
||||||
|
opt.setName("role")
|
||||||
|
.setDescription("Role value"))
|
||||||
|
.addChannelOption(opt =>
|
||||||
|
opt.setName("channel")
|
||||||
|
.setDescription("Channel value"))
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("text")
|
||||||
|
.setDescription("Text value"))
|
||||||
|
.addIntegerOption(opt =>
|
||||||
|
opt.setName("number")
|
||||||
|
.setDescription("Number value"))
|
||||||
|
.addBooleanOption(opt =>
|
||||||
|
opt.setName("boolean")
|
||||||
|
.setDescription("Boolean value (true/false)")))
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("reset")
|
||||||
|
.setDescription("Reset a setting to default")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("key")
|
||||||
|
.setDescription("Setting to reset")
|
||||||
|
.setRequired(true)
|
||||||
|
.addChoices(
|
||||||
|
{ name: "Student Role", value: "studentRole" },
|
||||||
|
{ name: "Visitor Role", value: "visitorRole" },
|
||||||
|
{ name: "Welcome Channel", value: "welcomeChannel" },
|
||||||
|
{ name: "Welcome Message", value: "welcomeMessage" },
|
||||||
|
{ name: "Feedback Channel", value: "feedbackChannel" },
|
||||||
|
{ name: "Terminal Channel", value: "terminalChannel" },
|
||||||
|
{ name: "Terminal Message", value: "terminalMessage" },
|
||||||
|
{ name: "Moderation Log Channel", value: "moderationLogChannel" },
|
||||||
|
{ name: "DM on Warn", value: "moderationDmOnWarn" },
|
||||||
|
{ name: "Auto Timeout Threshold", value: "moderationAutoTimeoutThreshold" },
|
||||||
|
)))
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("colors")
|
||||||
|
.setDescription("Manage color roles")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("action")
|
||||||
|
.setDescription("Action to perform")
|
||||||
|
.setRequired(true)
|
||||||
|
.addChoices(
|
||||||
|
{ name: "List", value: "list" },
|
||||||
|
{ name: "Add", value: "add" },
|
||||||
|
{ name: "Remove", value: "remove" },
|
||||||
|
))
|
||||||
|
.addRoleOption(opt =>
|
||||||
|
opt.setName("role")
|
||||||
|
.setDescription("Role to add/remove")
|
||||||
|
.setRequired(false))),
|
||||||
|
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
const guildId = interaction.guildId!;
|
||||||
|
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleShow(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||||
|
const settings = await getGuildConfig(guildId);
|
||||||
|
|
||||||
|
const colorRolesDisplay = settings.colorRoles?.length
|
||||||
|
? settings.colorRoles.map(id => `<@&${id}>`).join(", ")
|
||||||
|
: "None";
|
||||||
|
|
||||||
|
const embed = createBaseEmbed("Guild Settings", undefined, Colors.Blue)
|
||||||
|
.addFields(
|
||||||
|
{ name: "Student Role", value: settings.studentRole ? `<@&${settings.studentRole}>` : "Not set", inline: true },
|
||||||
|
{ name: "Visitor Role", value: settings.visitorRole ? `<@&${settings.visitorRole}>` : "Not set", inline: true },
|
||||||
|
{ name: "\u200b", value: "\u200b", inline: true },
|
||||||
|
{ name: "Welcome Channel", value: settings.welcomeChannelId ? `<#${settings.welcomeChannelId}>` : "Not set", inline: true },
|
||||||
|
{ name: "Feedback Channel", value: settings.feedbackChannelId ? `<#${settings.feedbackChannelId}>` : "Not set", inline: true },
|
||||||
|
{ name: "Moderation Log", value: settings.moderation?.cases?.logChannelId ? `<#${settings.moderation.cases.logChannelId}>` : "Not set", inline: true },
|
||||||
|
{ name: "Terminal Channel", value: settings.terminal?.channelId ? `<#${settings.terminal.channelId}>` : "Not set", inline: true },
|
||||||
|
{ name: "DM on Warn", value: settings.moderation?.cases?.dmOnWarn !== false ? "Enabled" : "Disabled", inline: true },
|
||||||
|
{ name: "Auto Timeout", value: settings.moderation?.cases?.autoTimeoutThreshold ? `${settings.moderation.cases.autoTimeoutThreshold} warnings` : "Disabled", inline: true },
|
||||||
|
{ name: "Color Roles", value: colorRolesDisplay, inline: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (settings.welcomeMessage) {
|
||||||
|
embed.addFields({ name: "Welcome Message", value: settings.welcomeMessage.substring(0, 1024), inline: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSet(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||||
|
const key = interaction.options.getString("key", true);
|
||||||
|
const role = interaction.options.getRole("role");
|
||||||
|
const channel = interaction.options.getChannel("channel");
|
||||||
|
const text = interaction.options.getString("text");
|
||||||
|
const number = interaction.options.getInteger("number");
|
||||||
|
const boolean = interaction.options.getBoolean("boolean");
|
||||||
|
|
||||||
|
let value: string | number | boolean | null = null;
|
||||||
|
|
||||||
|
if (role) value = role.id;
|
||||||
|
else if (channel) value = channel.id;
|
||||||
|
else if (text) value = text;
|
||||||
|
else if (number !== null) value = number;
|
||||||
|
else if (boolean !== null) value = boolean;
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("Please provide a role, channel, text, number, or boolean value")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await guildSettingsService.updateSetting(guildId, key, value);
|
||||||
|
invalidateGuildConfigCache(guildId);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Setting "${key}" updated`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReset(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||||
|
const key = interaction.options.getString("key", true);
|
||||||
|
|
||||||
|
await guildSettingsService.updateSetting(guildId, key, null);
|
||||||
|
invalidateGuildConfigCache(guildId);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Setting "${key}" reset to default`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleColors(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||||
|
const action = interaction.options.getString("action", true);
|
||||||
|
const role = interaction.options.getRole("role");
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "list": {
|
||||||
|
const settings = await getGuildConfig(guildId);
|
||||||
|
const colorRoles = settings.colorRoles ?? [];
|
||||||
|
|
||||||
|
if (colorRoles.length === 0) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createBaseEmbed("Color Roles", "No color roles configured.", Colors.Blue)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = createBaseEmbed("Color Roles", undefined, Colors.Blue)
|
||||||
|
.addFields({
|
||||||
|
name: `Configured Roles (${colorRoles.length})`,
|
||||||
|
value: colorRoles.map(id => `<@&${id}>`).join("\n"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "add": {
|
||||||
|
if (!role) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("Please specify a role to add.")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await guildSettingsService.addColorRole(guildId, role.id);
|
||||||
|
invalidateGuildConfigCache(guildId);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Added <@&${role.id}> to color roles.`)]
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "remove": {
|
||||||
|
if (!role) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("Please specify a role to remove.")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await guildSettingsService.removeColorRole(guildId, role.id);
|
||||||
|
invalidateGuildConfigCache(guildId);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Removed <@&${role.id}> from color roles.`)]
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
||||||
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
||||||
import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds";
|
import { createErrorEmbed } from "@/lib/embeds";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const terminal = createCommand({
|
export const terminal = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -23,15 +24,14 @@ export const terminal = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.reply({ ephemeral: true, content: "Initializing terminal..." });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
try {
|
async () => {
|
||||||
await terminalService.init(channel as TextChannel);
|
await terminalService.init(channel as TextChannel);
|
||||||
await interaction.editReply({ content: "✅ Terminal initialized!" });
|
await interaction.editReply({ content: "✅ Terminal initialized!" });
|
||||||
} catch (error) {
|
},
|
||||||
console.error(error);
|
{ ephemeral: true }
|
||||||
await interaction.editReply({ content: "❌ Failed to initialize terminal." });
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import {
|
import {
|
||||||
getWarnSuccessEmbed,
|
getWarnSuccessEmbed,
|
||||||
getModerationErrorEmbed,
|
getModerationErrorEmbed,
|
||||||
getUserWarningEmbed
|
|
||||||
} from "@/modules/moderation/moderation.view";
|
} from "@/modules/moderation/moderation.view";
|
||||||
import { config } from "@shared/lib/config";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const warn = createCommand({
|
export const warn = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -28,60 +28,63 @@ export const warn = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
const reason = interaction.options.getString("reason", true);
|
||||||
|
|
||||||
try {
|
// Don't allow warning bots
|
||||||
const targetUser = interaction.options.getUser("user", true);
|
if (targetUser.bot) {
|
||||||
const reason = interaction.options.getString("reason", true);
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed("You cannot warn bots.")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Don't allow warning bots
|
// Don't allow self-warnings
|
||||||
if (targetUser.bot) {
|
if (targetUser.id === interaction.user.id) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed("You cannot warn yourself.")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch guild config for moderation settings
|
||||||
|
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||||
|
|
||||||
|
// Issue the warning via service
|
||||||
|
const { moderationCase, warningCount, autoTimeoutIssued } = await moderationService.issueWarning({
|
||||||
|
userId: targetUser.id,
|
||||||
|
username: targetUser.username,
|
||||||
|
moderatorId: interaction.user.id,
|
||||||
|
moderatorName: interaction.user.username,
|
||||||
|
reason,
|
||||||
|
guildName: interaction.guild?.name || undefined,
|
||||||
|
dmTarget: targetUser,
|
||||||
|
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id),
|
||||||
|
config: {
|
||||||
|
dmOnWarn: guildConfig.moderation?.cases?.dmOnWarn,
|
||||||
|
autoTimeoutThreshold: guildConfig.moderation?.cases?.autoTimeoutThreshold,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send success message to moderator
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [getModerationErrorEmbed("You cannot warn bots.")]
|
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't allow self-warnings
|
// Follow up if auto-timeout was issued
|
||||||
if (targetUser.id === interaction.user.id) {
|
if (autoTimeoutIssued) {
|
||||||
await interaction.editReply({
|
await interaction.followUp({
|
||||||
embeds: [getModerationErrorEmbed("You cannot warn yourself.")]
|
embeds: [getModerationErrorEmbed(
|
||||||
});
|
`⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
|
||||||
return;
|
)],
|
||||||
}
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
// Issue the warning via service
|
}
|
||||||
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
|
},
|
||||||
userId: targetUser.id,
|
{ ephemeral: true }
|
||||||
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)
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getWarningsEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const warnings = createCommand({
|
export const warnings = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -16,24 +17,20 @@ export const warnings = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
|
||||||
try {
|
// Get active warnings for the user
|
||||||
const targetUser = interaction.options.getUser("user", true);
|
const activeWarnings = await moderationService.getUserWarnings(targetUser.id);
|
||||||
|
|
||||||
// Get active warnings for the user
|
// Display the warnings
|
||||||
const activeWarnings = await ModerationService.getUserWarnings(targetUser.id);
|
await interaction.editReply({
|
||||||
|
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
|
||||||
// Display the warnings
|
});
|
||||||
await interaction.editReply({
|
},
|
||||||
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
|
{ ephemeral: true }
|
||||||
});
|
);
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Warnings command error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while fetching warnings.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils";
|
|||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { createErrorEmbed } from "@/lib/embeds";
|
import { createErrorEmbed } from "@/lib/embeds";
|
||||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const webhook = createCommand({
|
export const webhook = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -14,43 +15,40 @@ export const webhook = createCommand({
|
|||||||
.setRequired(true)
|
.setRequired(true)
|
||||||
),
|
),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const payloadString = interaction.options.getString("payload", true);
|
||||||
|
let payload;
|
||||||
|
|
||||||
const payloadString = interaction.options.getString("payload", true);
|
try {
|
||||||
let payload;
|
payload = JSON.parse(payloadString);
|
||||||
|
} catch (error) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("The provided payload is not valid JSON.", "Invalid JSON")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const channel = interaction.channel;
|
||||||
payload = JSON.parse(payloadString);
|
|
||||||
} catch (error) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createErrorEmbed("The provided payload is not valid JSON.", "Invalid JSON")]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const channel = interaction.channel;
|
if (!channel || !('createWebhook' in channel)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!channel || !('createWebhook' in channel)) {
|
await sendWebhookMessage(
|
||||||
await interaction.editReply({
|
channel,
|
||||||
embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")]
|
payload,
|
||||||
});
|
interaction.client.user,
|
||||||
return;
|
`Proxy message requested by ${interaction.user.tag}`
|
||||||
}
|
);
|
||||||
|
|
||||||
try {
|
await interaction.editReply({ content: "Message sent successfully!" });
|
||||||
await sendWebhookMessage(
|
},
|
||||||
channel,
|
{ ephemeral: true }
|
||||||
payload,
|
);
|
||||||
interaction.client.user,
|
|
||||||
`Proxy message requested by ${interaction.user.tag}`
|
|
||||||
);
|
|
||||||
|
|
||||||
await interaction.editReply({ content: "Message sent successfully!" });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Webhook error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createErrorEmbed("Failed to send message via webhook. Ensure the bot has 'Manage Webhooks' permission and the payload is valid.", "Delivery Failed")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,35 +2,29 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { economyService } from "@shared/modules/economy/economy.service";
|
import { economyService } from "@shared/modules/economy/economy.service";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createSuccessEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@shared/lib/errors";
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const daily = createCommand({
|
export const daily = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
.setName("daily")
|
.setName("daily")
|
||||||
.setDescription("Claim your daily reward"),
|
.setDescription("Claim your daily reward"),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await withCommandErrorHandling(
|
||||||
try {
|
interaction,
|
||||||
const result = await economyService.claimDaily(interaction.user.id);
|
async () => {
|
||||||
|
const result = await economyService.claimDaily(interaction.user.id);
|
||||||
|
|
||||||
const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!")
|
const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!")
|
||||||
.addFields(
|
.addFields(
|
||||||
{ name: "Streak", value: `🔥 ${result.streak} days`, inline: true },
|
{ name: "Streak", value: `🔥 ${result.streak} days`, inline: true },
|
||||||
{ name: "Weekly Progress", value: `${"🟩".repeat(result.streak % 7 || 7)}${"⬜".repeat(7 - (result.streak % 7 || 7))} (${result.streak % 7 || 7}/7)`, inline: true },
|
{ name: "Weekly Progress", value: `${"🟩".repeat(result.streak % 7 || 7)}${"⬜".repeat(7 - (result.streak % 7 || 7))} (${result.streak % 7 || 7}/7)`, inline: true },
|
||||||
{ name: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R> `, inline: true }
|
{ name: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R> `, inline: true }
|
||||||
)
|
)
|
||||||
.setColor("Gold");
|
.setColor("Gold");
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof UserError) {
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
|
||||||
} else {
|
|
||||||
console.error("Error claiming daily:", error);
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils";
|
|||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
|
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
|
|
||||||
@@ -10,66 +11,62 @@ export const exam = createCommand({
|
|||||||
.setName("exam")
|
.setName("exam")
|
||||||
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
// First, try to take the exam or check status
|
||||||
|
const result = await examService.takeExam(interaction.user.id);
|
||||||
|
|
||||||
try {
|
if (result.status === ExamStatus.NOT_REGISTERED) {
|
||||||
// First, try to take the exam or check status
|
// Register the user
|
||||||
const result = await examService.takeExam(interaction.user.id);
|
const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username);
|
||||||
|
const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000);
|
||||||
|
|
||||||
if (result.status === ExamStatus.NOT_REGISTERED) {
|
await interaction.editReply({
|
||||||
// Register the user
|
embeds: [createSuccessEmbed(
|
||||||
const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username);
|
`You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
|
||||||
const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000);
|
`Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
|
||||||
|
"Exam Registration Successful"
|
||||||
|
)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
|
||||||
|
|
||||||
|
if (result.status === ExamStatus.COOLDOWN) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed(
|
||||||
|
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
||||||
|
`Next exam available: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
|
||||||
|
)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === ExamStatus.MISSED) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed(
|
||||||
|
`You missed your exam day! Your exam day is **${DAYS[result.examDay!]}** (Server Time).\n` +
|
||||||
|
`You verify your attendance but score a **0**.\n` +
|
||||||
|
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
|
||||||
|
"Exam Failed"
|
||||||
|
)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it reached here with AVAILABLE, it means they passed
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createSuccessEmbed(
|
embeds: [createSuccessEmbed(
|
||||||
`You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
|
`**XP Gained:** ${result.xpDiff?.toString()}\n` +
|
||||||
`Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
|
`**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
|
||||||
"Exam Registration Successful"
|
`**Reward:** ${result.reward?.toString()} Currency\n\n` +
|
||||||
|
`See you next week: <t:${nextExamTimestamp}:D>`,
|
||||||
|
"Exam Passed!"
|
||||||
)]
|
)]
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
|
|
||||||
|
|
||||||
if (result.status === ExamStatus.COOLDOWN) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createErrorEmbed(
|
|
||||||
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
|
||||||
`Next exam available: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
|
|
||||||
)]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status === ExamStatus.MISSED) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createErrorEmbed(
|
|
||||||
`You missed your exam day! Your exam day is **${DAYS[result.examDay!]}** (Server Time).\n` +
|
|
||||||
`You verify your attendance but score a **0**.\n` +
|
|
||||||
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
|
|
||||||
"Exam Failed"
|
|
||||||
)]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If it reached here with AVAILABLE, it means they passed
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createSuccessEmbed(
|
|
||||||
`**XP Gained:** ${result.xpDiff?.toString()}\n` +
|
|
||||||
`**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
|
|
||||||
`**Reward:** ${result.reward?.toString()} Currency\n\n` +
|
|
||||||
`See you next week: <t:${nextExamTimestamp}:D>`,
|
|
||||||
"Exam Passed!"
|
|
||||||
)]
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error in exam command:", error);
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An unexpected error occurred.")] });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { economyService } from "@shared/modules/economy/economy.service";
|
|||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { config } from "@shared/lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@shared/lib/errors";
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const pay = createCommand({
|
export const pay = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -50,20 +50,14 @@ export const pay = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await withCommandErrorHandling(
|
||||||
await interaction.deferReply();
|
interaction,
|
||||||
await economyService.transfer(senderId, receiverId.toString(), amount);
|
async () => {
|
||||||
|
await economyService.transfer(senderId, receiverId.toString(), amount);
|
||||||
|
|
||||||
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
|
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
|
||||||
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
|
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof UserError) {
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
|
||||||
} else {
|
|
||||||
console.error("Error sending payment:", error);
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createErrorEmbed } from "@lib/embeds";
|
|||||||
import { UserError } from "@shared/lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
import { config } from "@shared/lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
import { TriviaCategory } from "@shared/lib/constants";
|
import { TriviaCategory } from "@shared/lib/constants";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const trivia = createCommand({
|
export const trivia = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -53,64 +54,54 @@ export const trivia = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// User can play - defer publicly for trivia question
|
// User can play - use standardized error handling for the main operation
|
||||||
await interaction.deferReply();
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
// Start trivia session (deducts entry fee)
|
||||||
|
const session = await triviaService.startTrivia(
|
||||||
|
interaction.user.id,
|
||||||
|
interaction.user.username,
|
||||||
|
categoryId ? parseInt(categoryId) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
// Start trivia session (deducts entry fee)
|
// Generate Components v2 message
|
||||||
const session = await triviaService.startTrivia(
|
const { components, flags } = getTriviaQuestionView(session, interaction.user.username);
|
||||||
interaction.user.id,
|
|
||||||
interaction.user.username,
|
// Reply with Components v2 question
|
||||||
categoryId ? parseInt(categoryId) : undefined
|
await interaction.editReply({
|
||||||
|
components,
|
||||||
|
flags
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up automatic timeout cleanup
|
||||||
|
setTimeout(async () => {
|
||||||
|
const stillActive = triviaService.getSession(session.sessionId);
|
||||||
|
if (stillActive) {
|
||||||
|
// User didn't answer - clean up session with no reward
|
||||||
|
try {
|
||||||
|
await triviaService.submitAnswer(session.sessionId, interaction.user.id, false);
|
||||||
|
} catch (error) {
|
||||||
|
// Session already cleaned up, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate Components v2 message
|
|
||||||
const { components, flags } = getTriviaQuestionView(session, interaction.user.username);
|
|
||||||
|
|
||||||
// Reply with Components v2 question
|
|
||||||
await interaction.editReply({
|
|
||||||
components,
|
|
||||||
flags
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up automatic timeout cleanup
|
|
||||||
setTimeout(async () => {
|
|
||||||
const stillActive = triviaService.getSession(session.sessionId);
|
|
||||||
if (stillActive) {
|
|
||||||
// User didn't answer - clean up session with no reward
|
|
||||||
try {
|
|
||||||
await triviaService.submitAnswer(session.sessionId, interaction.user.id, false);
|
|
||||||
} catch (error) {
|
|
||||||
// Session already cleaned up, ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period
|
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
// Handle errors from the pre-defer canPlayTrivia check
|
||||||
if (error instanceof UserError) {
|
if (error instanceof UserError) {
|
||||||
// Check if we've already deferred
|
await interaction.reply({
|
||||||
if (interaction.deferred) {
|
embeds: [createErrorEmbed(error.message)],
|
||||||
await interaction.editReply({
|
ephemeral: true
|
||||||
embeds: [createErrorEmbed(error.message)]
|
});
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await interaction.reply({
|
|
||||||
embeds: [createErrorEmbed(error.message)],
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.error("Error in trivia command:", error);
|
console.error("Error in trivia command:", error);
|
||||||
// Check if we've already deferred
|
await interaction.reply({
|
||||||
if (interaction.deferred) {
|
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
|
||||||
await interaction.editReply({
|
ephemeral: true
|
||||||
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")]
|
});
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await interaction.reply({
|
|
||||||
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { config } from "@shared/lib/config";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
import { createErrorEmbed } from "@/lib/embeds";
|
import { createErrorEmbed } from "@/lib/embeds";
|
||||||
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
|
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
|
||||||
|
|
||||||
@@ -9,8 +9,10 @@ export const feedback = createCommand({
|
|||||||
.setName("feedback")
|
.setName("feedback")
|
||||||
.setDescription("Submit feedback, feature requests, or bug reports"),
|
.setDescription("Submit feedback, feature requests, or bug reports"),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
|
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||||
|
|
||||||
// Check if feedback channel is configured
|
// Check if feedback channel is configured
|
||||||
if (!config.feedbackChannelId) {
|
if (!guildConfig.feedbackChannelId) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [createErrorEmbed("Feedback system is not configured. Please contact an administrator.")],
|
embeds: [createErrorEmbed("Feedback system is not configured. Please contact an administrator.")],
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
|||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
||||||
import type { ItemUsageData } from "@shared/lib/types";
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
import { UserError } from "@shared/lib/errors";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
import { config } from "@shared/lib/config";
|
|
||||||
|
|
||||||
export const use = createCommand({
|
export const use = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -19,54 +18,50 @@ export const use = createCommand({
|
|||||||
.setAutocomplete(true)
|
.setAutocomplete(true)
|
||||||
),
|
),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||||
|
const colorRoles = guildConfig.colorRoles ?? [];
|
||||||
|
|
||||||
const itemId = interaction.options.getNumber("item", true);
|
const itemId = interaction.options.getNumber("item", true);
|
||||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
|
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const result = await inventoryService.useItem(user.id.toString(), itemId);
|
||||||
const result = await inventoryService.useItem(user.id.toString(), itemId);
|
|
||||||
|
|
||||||
const usageData = result.usageData;
|
const usageData = result.usageData;
|
||||||
if (usageData) {
|
if (usageData) {
|
||||||
for (const effect of usageData.effects) {
|
for (const effect of usageData.effects) {
|
||||||
if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') {
|
if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') {
|
||||||
try {
|
try {
|
||||||
const member = await interaction.guild?.members.fetch(user.id.toString());
|
const member = await interaction.guild?.members.fetch(user.id.toString());
|
||||||
if (member) {
|
if (member) {
|
||||||
if (effect.type === 'TEMP_ROLE') {
|
if (effect.type === 'TEMP_ROLE') {
|
||||||
await member.roles.add(effect.roleId);
|
await member.roles.add(effect.roleId);
|
||||||
} else if (effect.type === 'COLOR_ROLE') {
|
} else if (effect.type === 'COLOR_ROLE') {
|
||||||
// Remove existing color roles
|
// Remove existing color roles
|
||||||
const rolesToRemove = config.colorRoles.filter(r => member.roles.cache.has(r));
|
const rolesToRemove = colorRoles.filter(r => member.roles.cache.has(r));
|
||||||
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
|
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
|
||||||
await member.roles.add(effect.roleId);
|
await member.roles.add(effect.roleId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to assign role in /use command:", e);
|
||||||
|
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to assign role in /use command:", e);
|
|
||||||
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { embed, files } = getItemUseResultEmbed(result.results, result.item);
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed], files });
|
||||||
}
|
}
|
||||||
|
);
|
||||||
const { embed, files } = getItemUseResultEmbed(result.results, result.item);
|
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed], files });
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof UserError) {
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
|
||||||
} else {
|
|
||||||
console.error("Error using item:", error);
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred while using the item.")] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
autocomplete: async (interaction) => {
|
autocomplete: async (interaction) => {
|
||||||
const focusedValue = interaction.options.getFocused();
|
const focusedValue = interaction.options.getFocused();
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import { Events } from "discord.js";
|
import { Events } from "discord.js";
|
||||||
import type { Event } from "@shared/lib/types";
|
import type { Event } from "@shared/lib/types";
|
||||||
import { config } from "@shared/lib/config";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
|
|
||||||
// Visitor role
|
|
||||||
const event: Event<Events.GuildMemberAdd> = {
|
const event: Event<Events.GuildMemberAdd> = {
|
||||||
name: Events.GuildMemberAdd,
|
name: Events.GuildMemberAdd,
|
||||||
execute: async (member) => {
|
execute: async (member) => {
|
||||||
console.log(`👤 New member joined: ${member.user.tag} (${member.id})`);
|
console.log(`👤 New member joined: ${member.user.tag} (${member.id})`);
|
||||||
|
|
||||||
|
const guildConfig = await getGuildConfig(member.guild.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await userService.getUserById(member.id);
|
const user = await userService.getUserById(member.id);
|
||||||
|
|
||||||
if (user && user.class) {
|
if (user && user.class) {
|
||||||
console.log(`🔄 Returning student detected: ${member.user.tag}`);
|
console.log(`🔄 Returning student detected: ${member.user.tag}`);
|
||||||
await member.roles.remove(config.visitorRole);
|
if (guildConfig.visitorRole) {
|
||||||
await member.roles.add(config.studentRole);
|
await member.roles.remove(guildConfig.visitorRole);
|
||||||
|
}
|
||||||
|
if (guildConfig.studentRole) {
|
||||||
|
await member.roles.add(guildConfig.studentRole);
|
||||||
|
}
|
||||||
|
|
||||||
if (user.class.roleId) {
|
if (user.class.roleId) {
|
||||||
await member.roles.add(user.class.roleId);
|
await member.roles.add(user.class.roleId);
|
||||||
@@ -22,8 +28,10 @@ const event: Event<Events.GuildMemberAdd> = {
|
|||||||
}
|
}
|
||||||
console.log(`Restored student role to ${member.user.tag}`);
|
console.log(`Restored student role to ${member.user.tag}`);
|
||||||
} else {
|
} else {
|
||||||
await member.roles.add(config.visitorRole);
|
if (guildConfig.visitorRole) {
|
||||||
console.log(`Assigned visitor role to ${member.user.tag}`);
|
await member.roles.add(guildConfig.visitorRole);
|
||||||
|
console.log(`Assigned visitor role to ${member.user.tag}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
console.log(`User Roles: ${member.roles.cache.map(role => role.name).join(", ")}`);
|
console.log(`User Roles: ${member.roles.cache.map(role => role.name).join(", ")}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { env } from "@shared/lib/env";
|
import { env } from "@shared/lib/env";
|
||||||
import { join } from "node:path";
|
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();
|
||||||
|
|
||||||
// Load commands & events
|
// Load commands & events
|
||||||
await AuroraClient.loadCommands();
|
await AuroraClient.loadCommands();
|
||||||
@@ -14,7 +18,7 @@ console.log("🌐 Starting web server...");
|
|||||||
|
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
|
|
||||||
const webProjectPath = join(import.meta.dir, "../web");
|
const webProjectPath = join(import.meta.dir, "../api");
|
||||||
const webPort = Number(process.env.WEB_PORT) || 3000;
|
const webPort = Number(process.env.WEB_PORT) || 3000;
|
||||||
const webHost = process.env.HOST || "0.0.0.0";
|
const webHost = process.env.HOST || "0.0.0.0";
|
||||||
|
|
||||||
|
|||||||
147
bot/lib/commandUtils.test.ts
Normal file
147
bot/lib/commandUtils.test.ts
Normal 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
79
bot/lib/commandUtils.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||||
|
|
||||||
// Mock DrizzleClient
|
// Mock DrizzleClient — must match the import path used in db.ts
|
||||||
mock.module("./DrizzleClient", () => ({
|
mock.module("@shared/db/DrizzleClient", () => ({
|
||||||
DrizzleClient: {
|
DrizzleClient: {
|
||||||
transaction: async (cb: any) => cb("MOCK_TX")
|
transaction: async (cb: any) => cb("MOCK_TX")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
|
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
import { logger } from "@shared/lib/logger";
|
import { logger } from "@shared/lib/logger";
|
||||||
|
|
||||||
@@ -25,6 +26,37 @@ export class CommandHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check beta feature access
|
||||||
|
if (command.beta) {
|
||||||
|
const flagName = command.featureFlag || interaction.commandName;
|
||||||
|
let memberRoles: string[] = [];
|
||||||
|
|
||||||
|
if (interaction.member && 'roles' in interaction.member) {
|
||||||
|
const roles = interaction.member.roles;
|
||||||
|
if (typeof roles === 'object' && 'cache' in roles) {
|
||||||
|
memberRoles = [...roles.cache.keys()];
|
||||||
|
} else if (Array.isArray(roles)) {
|
||||||
|
memberRoles = roles;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAccess = await featureFlagsService.hasAccess(flagName, {
|
||||||
|
guildId: interaction.guildId!,
|
||||||
|
userId: interaction.user.id,
|
||||||
|
memberRoles,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
const errorEmbed = createErrorEmbed(
|
||||||
|
"This feature is currently in beta testing and not available to all users. " +
|
||||||
|
"Stay tuned for the official release!",
|
||||||
|
"Beta Feature"
|
||||||
|
);
|
||||||
|
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure user exists in database
|
// Ensure user exists in database
|
||||||
try {
|
try {
|
||||||
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Interaction } from "discord.js";
|
import type { Interaction } from "discord.js";
|
||||||
import { TextChannel, MessageFlags } from "discord.js";
|
import { TextChannel, MessageFlags } from "discord.js";
|
||||||
import { config } from "@shared/lib/config";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
|
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
|
||||||
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
|
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
|
||||||
@@ -33,7 +33,13 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
|||||||
throw new UserError("An error occurred processing your feedback. Please try again.");
|
throw new UserError("An error occurred processing your feedback. Please try again.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!config.feedbackChannelId) {
|
if (!interaction.guildId) {
|
||||||
|
throw new UserError("This action can only be performed in a server.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const guildConfig = await getGuildConfig(interaction.guildId);
|
||||||
|
|
||||||
|
if (!guildConfig.feedbackChannelId) {
|
||||||
throw new UserError("Feedback channel is not configured. Please contact an administrator.");
|
throw new UserError("Feedback channel is not configured. Please contact an administrator.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +58,7 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Get feedback channel
|
// Get feedback channel
|
||||||
const channel = await AuroraClient.channels.fetch(config.feedbackChannelId).catch(() => null) as TextChannel | null;
|
const channel = await AuroraClient.channels.fetch(guildConfig.feedbackChannelId).catch(() => null) as TextChannel | null;
|
||||||
|
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
throw new UserError("Feedback channel not found. Please contact an administrator.");
|
throw new UserError("Feedback channel not found. Please contact an administrator.");
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
||||||
import { economyService } from "@shared/modules/economy/economy.service";
|
import { economyService } from "@shared/modules/economy/economy.service";
|
||||||
import { userTimers } from "@db/schema";
|
import { userTimers } from "@db/schema";
|
||||||
import type { EffectHandler } from "./types";
|
import type { EffectHandler, ValidatedEffectPayload } from "./effect.types";
|
||||||
|
import { EffectType } from "@shared/lib/constants";
|
||||||
import type { LootTableItem } from "@shared/lib/types";
|
import type { LootTableItem } from "@shared/lib/types";
|
||||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { inventory, items } from "@db/schema";
|
import { inventory, items } from "@db/schema";
|
||||||
@@ -15,21 +16,21 @@ const getDuration = (effect: any): number => {
|
|||||||
return effect.durationSeconds || 60; // Default to 60s if nothing provided
|
return effect.durationSeconds || 60; // Default to 60s if nothing provided
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleAddXp: EffectHandler = async (userId, effect, txFn) => {
|
export const handleAddXp: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.ADD_XP }>, txFn) => {
|
||||||
await levelingService.addXp(userId, BigInt(effect.amount), txFn);
|
await levelingService.addXp(userId, BigInt(effect.amount), txFn);
|
||||||
return `Gained ${effect.amount} XP`;
|
return `Gained ${effect.amount} XP`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => {
|
export const handleAddBalance: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.ADD_BALANCE }>, txFn) => {
|
||||||
await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
|
await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
|
||||||
return `Gained ${effect.amount} 🪙`;
|
return `Gained ${effect.amount} 🪙`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleReplyMessage: EffectHandler = async (_userId, effect, _txFn) => {
|
export const handleReplyMessage: EffectHandler = async (_userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.REPLY_MESSAGE }>, _txFn) => {
|
||||||
return effect.message;
|
return effect.message;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
|
export const handleXpBoost: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.XP_BOOST }>, txFn) => {
|
||||||
const boostDuration = getDuration(effect);
|
const boostDuration = getDuration(effect);
|
||||||
const expiresAt = new Date(Date.now() + boostDuration * 1000);
|
const expiresAt = new Date(Date.now() + boostDuration * 1000);
|
||||||
await txFn.insert(userTimers).values({
|
await txFn.insert(userTimers).values({
|
||||||
@@ -45,7 +46,7 @@ export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
return `XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`;
|
return `XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
|
export const handleTempRole: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.TEMP_ROLE }>, txFn) => {
|
||||||
const roleDuration = getDuration(effect);
|
const roleDuration = getDuration(effect);
|
||||||
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
|
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
|
||||||
await txFn.insert(userTimers).values({
|
await txFn.insert(userTimers).values({
|
||||||
@@ -62,11 +63,11 @@ export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
return `Temporary Role granted for ${Math.floor(roleDuration / 60)}m`;
|
return `Temporary Role granted for ${Math.floor(roleDuration / 60)}m`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleColorRole: EffectHandler = async (_userId, _effect, _txFn) => {
|
export const handleColorRole: EffectHandler = async (_userId, _effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.COLOR_ROLE }>, _txFn) => {
|
||||||
return "Color Role Equipped";
|
return "Color Role Equipped";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
export const handleLootbox: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.LOOTBOX }>, txFn) => {
|
||||||
const pool = effect.pool as LootTableItem[];
|
const pool = effect.pool as LootTableItem[];
|
||||||
if (!pool || pool.length === 0) return "The box is empty...";
|
if (!pool || pool.length === 0) return "The box is empty...";
|
||||||
|
|
||||||
41
bot/modules/inventory/effect.registry.ts
Normal file
41
bot/modules/inventory/effect.registry.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
handleAddXp,
|
||||||
|
handleAddBalance,
|
||||||
|
handleReplyMessage,
|
||||||
|
handleXpBoost,
|
||||||
|
handleTempRole,
|
||||||
|
handleColorRole,
|
||||||
|
handleLootbox
|
||||||
|
} from "./effect.handlers";
|
||||||
|
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,
|
||||||
|
'ADD_BALANCE': handleAddBalance,
|
||||||
|
'REPLY_MESSAGE': handleReplyMessage,
|
||||||
|
'XP_BOOST': handleXpBoost,
|
||||||
|
'TEMP_ROLE': handleTempRole,
|
||||||
|
'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);
|
||||||
|
}
|
||||||
71
bot/modules/inventory/effect.types.ts
Normal file
71
bot/modules/inventory/effect.types.ts
Normal file
@@ -0,0 +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>;
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import {
|
|
||||||
handleAddXp,
|
|
||||||
handleAddBalance,
|
|
||||||
handleReplyMessage,
|
|
||||||
handleXpBoost,
|
|
||||||
handleTempRole,
|
|
||||||
handleColorRole,
|
|
||||||
handleLootbox
|
|
||||||
} from "./handlers";
|
|
||||||
import type { EffectHandler } from "./types";
|
|
||||||
|
|
||||||
export const effectHandlers: Record<string, EffectHandler> = {
|
|
||||||
'ADD_XP': handleAddXp,
|
|
||||||
'ADD_BALANCE': handleAddBalance,
|
|
||||||
'REPLY_MESSAGE': handleReplyMessage,
|
|
||||||
'XP_BOOST': handleXpBoost,
|
|
||||||
'TEMP_ROLE': handleTempRole,
|
|
||||||
'COLOR_ROLE': handleColorRole,
|
|
||||||
'LOOTBOX': handleLootbox
|
|
||||||
};
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
|
|
||||||
import type { Transaction } from "@shared/lib/types";
|
|
||||||
|
|
||||||
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<any>;
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { temporaryRoleService } from "@shared/modules/system/temp-role.service";
|
import { temporaryRoleService } from "@shared/modules/system/temp-role.service";
|
||||||
|
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
||||||
|
|
||||||
export const schedulerService = {
|
export const schedulerService = {
|
||||||
start: () => {
|
start: () => {
|
||||||
@@ -10,7 +11,6 @@ export const schedulerService = {
|
|||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|
||||||
// 2. Terminal Update Loop (every 60s)
|
// 2. Terminal Update Loop (every 60s)
|
||||||
const { terminalService } = require("@shared/modules/terminal/terminal.service");
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
terminalService.update();
|
terminalService.update();
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ButtonInteraction, MessageFlags } from "discord.js";
|
import { ButtonInteraction, MessageFlags } from "discord.js";
|
||||||
import { config } from "@shared/lib/config";
|
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
|
||||||
import { getEnrollmentSuccessMessage } from "./enrollment.view";
|
import { getEnrollmentSuccessMessage } from "./enrollment.view";
|
||||||
import { classService } from "@shared/modules/class/class.service";
|
import { classService } from "@shared/modules/class/class.service";
|
||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
@@ -11,7 +11,8 @@ export async function handleEnrollmentInteraction(interaction: ButtonInteraction
|
|||||||
throw new UserError("This action can only be performed in a server.");
|
throw new UserError("This action can only be performed in a server.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { studentRole, visitorRole } = config;
|
const guildConfig = await getGuildConfig(interaction.guildId);
|
||||||
|
const { studentRole, visitorRole, welcomeChannelId, welcomeMessage } = guildConfig;
|
||||||
|
|
||||||
if (!studentRole || !visitorRole) {
|
if (!studentRole || !visitorRole) {
|
||||||
throw new UserError("No student or visitor role configured for enrollment.");
|
throw new UserError("No student or visitor role configured for enrollment.");
|
||||||
@@ -67,10 +68,10 @@ export async function handleEnrollmentInteraction(interaction: ButtonInteraction
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 5. Send Welcome Message (if configured)
|
// 5. Send Welcome Message (if configured)
|
||||||
if (config.welcomeChannelId) {
|
if (welcomeChannelId) {
|
||||||
const welcomeChannel = interaction.guild.channels.cache.get(config.welcomeChannelId);
|
const welcomeChannel = interaction.guild.channels.cache.get(welcomeChannelId);
|
||||||
if (welcomeChannel && welcomeChannel.isTextBased()) {
|
if (welcomeChannel && welcomeChannel.isTextBased()) {
|
||||||
const rawMessage = config.welcomeMessage || "Welcome to Aurora, {user}! You have been enrolled as a **{class}**.";
|
const rawMessage = welcomeMessage || "Welcome to Aurora, {user}! You have been enrolled as a **{class}**.";
|
||||||
|
|
||||||
const processedMessage = rawMessage
|
const processedMessage = rawMessage
|
||||||
.replace(/{user}/g, member.toString())
|
.replace(/{user}/g, member.toString())
|
||||||
|
|||||||
355
bun.lock
355
bun.lock
@@ -5,23 +5,86 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "app",
|
"name": "app",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@napi-rs/canvas": "^0.1.84",
|
"@napi-rs/canvas": "^0.1.89",
|
||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
"postgres": "^3.4.7",
|
"postgres": "^3.4.8",
|
||||||
"zod": "^4.1.13",
|
"zod": "^4.3.6",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"drizzle-kit": "^0.31.7",
|
"drizzle-kit": "^0.31.8",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5",
|
"typescript": "^5.9.3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"name": "panel",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@imgly/background-removal": "^1.7.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.564.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.10",
|
||||||
|
"@types/react": "^19.1.6",
|
||||||
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.10",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^6.3.5",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||||
|
|
||||||
|
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||||
|
|
||||||
|
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||||
|
|
||||||
|
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||||
|
|
||||||
|
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||||
|
|
||||||
|
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||||
|
|
||||||
|
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||||
|
|
||||||
|
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||||
|
|
||||||
|
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||||
|
|
||||||
|
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||||
|
|
||||||
|
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
|
||||||
|
|
||||||
|
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
|
||||||
|
|
||||||
|
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
|
||||||
|
|
||||||
|
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||||
|
|
||||||
|
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||||
|
|
||||||
|
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||||
|
|
||||||
|
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||||
|
|
||||||
"@discordjs/builders": ["@discordjs/builders@1.13.0", "", { "dependencies": { "@discordjs/formatters": "^0.6.1", "@discordjs/util": "^1.1.1", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.31", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q=="],
|
"@discordjs/builders": ["@discordjs/builders@1.13.0", "", { "dependencies": { "@discordjs/formatters": "^0.6.1", "@discordjs/util": "^1.1.1", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.31", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q=="],
|
||||||
|
|
||||||
"@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
|
"@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
|
||||||
@@ -92,6 +155,18 @@
|
|||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||||
|
|
||||||
|
"@imgly/background-removal": ["@imgly/background-removal@1.7.0", "", { "dependencies": { "lodash-es": "^4.17.21", "ndarray": "~1.0.0", "zod": "^3.23.8" }, "peerDependencies": { "onnxruntime-web": "1.21.0" } }, "sha512-/1ZryrMYg2ckIvJKoTu5Np50JfYMVffDMlVmppw/BdbN3pBTN7e6stI5/7E/LVh9DDzz6J588s7sWqul3fy5wA=="],
|
||||||
|
|
||||||
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.89", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.89", "@napi-rs/canvas-darwin-arm64": "0.1.89", "@napi-rs/canvas-darwin-x64": "0.1.89", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.89", "@napi-rs/canvas-linux-arm64-gnu": "0.1.89", "@napi-rs/canvas-linux-arm64-musl": "0.1.89", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-musl": "0.1.89", "@napi-rs/canvas-win32-arm64-msvc": "0.1.89", "@napi-rs/canvas-win32-x64-msvc": "0.1.89" } }, "sha512-7GjmkMirJHejeALCqUnZY3QwID7bbumOiLrqq2LKgxrdjdmxWQBTc6rcASa2u8wuWrH7qo4/4n/VNrOwCoKlKg=="],
|
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.89", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.89", "@napi-rs/canvas-darwin-arm64": "0.1.89", "@napi-rs/canvas-darwin-x64": "0.1.89", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.89", "@napi-rs/canvas-linux-arm64-gnu": "0.1.89", "@napi-rs/canvas-linux-arm64-musl": "0.1.89", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-musl": "0.1.89", "@napi-rs/canvas-win32-arm64-msvc": "0.1.89", "@napi-rs/canvas-win32-x64-msvc": "0.1.89" } }, "sha512-7GjmkMirJHejeALCqUnZY3QwID7bbumOiLrqq2LKgxrdjdmxWQBTc6rcASa2u8wuWrH7qo4/4n/VNrOwCoKlKg=="],
|
||||||
|
|
||||||
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.89", "", { "os": "android", "cpu": "arm64" }, "sha512-CXxQTXsjtQqKGENS8Ejv9pZOFJhOPIl2goenS+aU8dY4DygvkyagDhy/I07D1YLqrDtPvLEX5zZHt8qUdnuIpQ=="],
|
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.89", "", { "os": "android", "cpu": "arm64" }, "sha512-CXxQTXsjtQqKGENS8Ejv9pZOFJhOPIl2goenS+aU8dY4DygvkyagDhy/I07D1YLqrDtPvLEX5zZHt8qUdnuIpQ=="],
|
||||||
@@ -116,26 +191,162 @@
|
|||||||
|
|
||||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="],
|
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="],
|
||||||
|
|
||||||
|
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||||
|
|
||||||
|
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||||
|
|
||||||
|
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
|
||||||
|
|
||||||
|
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
|
||||||
|
|
||||||
|
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
|
||||||
|
|
||||||
|
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
||||||
|
|
||||||
|
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
|
||||||
|
|
||||||
|
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
||||||
|
|
||||||
|
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||||
|
|
||||||
|
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
||||||
|
|
||||||
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
|
||||||
|
|
||||||
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
|
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
|
||||||
|
|
||||||
"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
|
"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
|
||||||
|
|
||||||
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="],
|
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
|
||||||
|
|
||||||
|
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
||||||
|
|
||||||
|
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||||
|
|
||||||
|
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||||
|
|
||||||
|
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||||
|
|
||||||
|
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||||
|
|
||||||
|
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||||
|
|
||||||
|
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||||
|
|
||||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||||
|
|
||||||
|
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||||
|
|
||||||
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
|
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
|
||||||
|
|
||||||
|
"autoprefixer": ["autoprefixer@10.4.24", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001766", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw=="],
|
||||||
|
|
||||||
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
||||||
|
|
||||||
|
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||||
|
|
||||||
|
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
|
||||||
|
|
||||||
|
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||||
|
|
||||||
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
"discord-api-types": ["discord-api-types@0.38.34", "", {}, "sha512-muq7xKGznj5MSFCzuIm/2TO7DpttuomUTemVM82fRqgnMl70YRzEyY24jlbiV6R9tzOTq6A6UnZ0bsfZeKD38Q=="],
|
"discord-api-types": ["discord-api-types@0.38.34", "", {}, "sha512-muq7xKGznj5MSFCzuIm/2TO7DpttuomUTemVM82fRqgnMl70YRzEyY24jlbiV6R9tzOTq6A6UnZ0bsfZeKD38Q=="],
|
||||||
|
|
||||||
"discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="],
|
"discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="],
|
||||||
@@ -146,30 +357,144 @@
|
|||||||
|
|
||||||
"drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
|
"drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
|
||||||
|
|
||||||
|
"electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="],
|
||||||
|
|
||||||
|
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
|
||||||
|
|
||||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||||
|
|
||||||
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
||||||
|
|
||||||
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="],
|
||||||
|
|
||||||
|
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||||
|
|
||||||
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
||||||
|
|
||||||
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="],
|
||||||
|
|
||||||
|
"iota-array": ["iota-array@1.0.0", "", {}, "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA=="],
|
||||||
|
|
||||||
|
"is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="],
|
||||||
|
|
||||||
|
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||||
|
|
||||||
|
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
|
|
||||||
|
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||||
|
|
||||||
|
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||||
|
|
||||||
|
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
||||||
|
|
||||||
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
|
||||||
|
|
||||||
|
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
||||||
|
|
||||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||||
|
|
||||||
|
"lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
|
||||||
|
|
||||||
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
|
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
|
||||||
|
|
||||||
|
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||||
|
|
||||||
|
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
|
"lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="],
|
||||||
|
|
||||||
"magic-bytes.js": ["magic-bytes.js@1.12.1", "", {}, "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA=="],
|
"magic-bytes.js": ["magic-bytes.js@1.12.1", "", {}, "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA=="],
|
||||||
|
|
||||||
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"ndarray": ["ndarray@1.0.19", "", { "dependencies": { "iota-array": "^1.0.0", "is-buffer": "^1.0.2" } }, "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ=="],
|
||||||
|
|
||||||
|
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||||
|
|
||||||
|
"onnxruntime-common": ["onnxruntime-common@1.21.0", "", {}, "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ=="],
|
||||||
|
|
||||||
|
"onnxruntime-web": ["onnxruntime-web@1.21.0", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.21.0", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-adzOe+7uI7lKz6pQNbAsLMQd2Fq5Jhmoxd8LZjJr8m3KvbFyiYyRxRiC57/XXD+jb18voppjeGAjoZmskXG+7A=="],
|
||||||
|
|
||||||
|
"panel": ["panel@workspace:panel"],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|
||||||
|
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
|
|
||||||
|
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||||
|
|
||||||
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
|
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
|
||||||
|
|
||||||
|
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
||||||
|
|
||||||
|
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||||
|
|
||||||
|
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||||
|
|
||||||
|
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||||
|
|
||||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||||
|
|
||||||
|
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
|
||||||
|
|
||||||
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
|
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||||
|
|
||||||
|
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||||
|
|
||||||
|
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||||
|
|
||||||
|
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
|
||||||
|
|
||||||
|
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
||||||
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
|
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
|
||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
@@ -180,8 +505,14 @@
|
|||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
|
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||||
|
|
||||||
|
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||||
|
|
||||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||||
|
|
||||||
|
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
|
|
||||||
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||||
@@ -190,6 +521,20 @@
|
|||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||||
|
|
||||||
|
"@imgly/background-removal/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||||
|
|||||||
@@ -34,9 +34,11 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.prod
|
dockerfile: Dockerfile
|
||||||
target: production
|
target: production
|
||||||
image: aurora-app:latest
|
image: aurora-app:latest
|
||||||
|
volumes:
|
||||||
|
- ./bot/assets/graphics/items:/app/bot/assets/graphics/items
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:3000"
|
- "127.0.0.1:3000:3000"
|
||||||
|
|
||||||
@@ -53,6 +55,10 @@ services:
|
|||||||
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
|
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
|
||||||
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
||||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
||||||
|
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
|
||||||
|
- SESSION_SECRET=${SESSION_SECRET}
|
||||||
|
- ADMIN_USER_IDS=${ADMIN_USER_IDS}
|
||||||
|
- PANEL_BASE_URL=${PANEL_BASE_URL:-https://aurora.syntaxbullet.com}
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -76,6 +82,8 @@ services:
|
|||||||
studio:
|
studio:
|
||||||
container_name: aurora_studio
|
container_name: aurora_studio
|
||||||
image: aurora-app:latest
|
image: aurora-app:latest
|
||||||
|
volumes:
|
||||||
|
- ./bot/assets/graphics/items:/app/bot/assets/graphics/items
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -90,6 +98,10 @@ services:
|
|||||||
- DB_PORT=5432
|
- DB_PORT=5432
|
||||||
- DB_HOST=db
|
- DB_HOST=db
|
||||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
||||||
|
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
|
||||||
|
- SESSION_SECRET=${SESSION_SECRET}
|
||||||
|
- ADMIN_USER_IDS=${ADMIN_USER_IDS}
|
||||||
|
- PANEL_BASE_URL=${PANEL_BASE_URL:-https://aurora.syntaxbullet.com}
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
- web
|
- web
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ services:
|
|||||||
- .:/app
|
- .:/app
|
||||||
# Use named volumes for node_modules (prevents host overwrite + caches deps)
|
# Use named volumes for node_modules (prevents host overwrite + caches deps)
|
||||||
- app_node_modules:/app/node_modules
|
- app_node_modules:/app/node_modules
|
||||||
- web_node_modules:/app/web/node_modules
|
|
||||||
environment:
|
environment:
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
- DB_USER=${DB_USER}
|
- DB_USER=${DB_USER}
|
||||||
@@ -48,6 +47,10 @@ services:
|
|||||||
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
|
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
|
||||||
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
||||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
||||||
|
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
|
||||||
|
- SESSION_SECRET=${SESSION_SECRET}
|
||||||
|
- ADMIN_USER_IDS=${ADMIN_USER_IDS}
|
||||||
|
- PANEL_BASE_URL=${PANEL_BASE_URL:-http://localhost:3000}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -92,5 +95,3 @@ volumes:
|
|||||||
# Named volumes for node_modules caching
|
# Named volumes for node_modules caching
|
||||||
app_node_modules:
|
app_node_modules:
|
||||||
name: aurora_app_node_modules
|
name: aurora_app_node_modules
|
||||||
web_node_modules:
|
|
||||||
name: aurora_web_node_modules
|
|
||||||
|
|||||||
492
docs/api.md
Normal file
492
docs/api.md
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
# Aurora API Reference
|
||||||
|
|
||||||
|
REST API server for Aurora bot management. Base URL: `http://localhost:3000`
|
||||||
|
|
||||||
|
## Common Response Formats
|
||||||
|
|
||||||
|
**Success Responses:**
|
||||||
|
- Single resource: `{ ...resource }` or `{ success: true, resource: {...} }`
|
||||||
|
- List operations: `{ items: [...], total: number }`
|
||||||
|
- Mutations: `{ success: true, resource: {...} }`
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Brief error message",
|
||||||
|
"details": "Optional detailed error information"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**HTTP Status Codes:**
|
||||||
|
| Code | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| 200 | Success |
|
||||||
|
| 201 | Created |
|
||||||
|
| 204 | No Content (successful DELETE) |
|
||||||
|
| 400 | Bad Request (validation error) |
|
||||||
|
| 404 | Not Found |
|
||||||
|
| 409 | Conflict (e.g., duplicate name) |
|
||||||
|
| 429 | Too Many Requests |
|
||||||
|
| 500 | Internal Server Error |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Health
|
||||||
|
|
||||||
|
### `GET /api/health`
|
||||||
|
Returns server health status.
|
||||||
|
|
||||||
|
**Response:** `{ "status": "ok", "timestamp": 1234567890 }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Items
|
||||||
|
|
||||||
|
### `GET /api/items`
|
||||||
|
List all items with optional filtering.
|
||||||
|
|
||||||
|
| Query Param | Type | Description |
|
||||||
|
|-------------|------|-------------|
|
||||||
|
| `search` | string | Filter by name/description |
|
||||||
|
| `type` | string | Filter by item type |
|
||||||
|
| `rarity` | string | Filter by rarity (C, R, SR, SSR) |
|
||||||
|
| `limit` | number | Max results (default: 100) |
|
||||||
|
| `offset` | number | Pagination offset |
|
||||||
|
|
||||||
|
**Response:** `{ "items": [...], "total": number }`
|
||||||
|
|
||||||
|
### `GET /api/items/:id`
|
||||||
|
Get single item by ID.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Health Potion",
|
||||||
|
"description": "Restores HP",
|
||||||
|
"type": "CONSUMABLE",
|
||||||
|
"rarity": "C",
|
||||||
|
"price": "100",
|
||||||
|
"iconUrl": "/assets/items/1.png",
|
||||||
|
"imageUrl": "/assets/items/1.png",
|
||||||
|
"usageData": { "consume": true, "effects": [] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/items`
|
||||||
|
Create new item. Supports JSON or multipart/form-data with image.
|
||||||
|
|
||||||
|
**Body (JSON):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Health Potion",
|
||||||
|
"description": "Restores HP",
|
||||||
|
"type": "CONSUMABLE",
|
||||||
|
"rarity": "C",
|
||||||
|
"price": "100",
|
||||||
|
"iconUrl": "/assets/items/placeholder.png",
|
||||||
|
"imageUrl": "/assets/items/placeholder.png",
|
||||||
|
"usageData": { "consume": true, "effects": [] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body (Multipart):**
|
||||||
|
- `data`: JSON string with item fields
|
||||||
|
- `image`: Image file (PNG, JPEG, WebP, GIF, max 15MB)
|
||||||
|
|
||||||
|
### `PUT /api/items/:id`
|
||||||
|
Update existing item.
|
||||||
|
|
||||||
|
### `DELETE /api/items/:id`
|
||||||
|
Delete item and associated asset.
|
||||||
|
|
||||||
|
### `POST /api/items/:id/icon`
|
||||||
|
Upload/replace item image. Accepts multipart/form-data with `image` field.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
### `GET /api/users`
|
||||||
|
List all users with optional filtering and sorting.
|
||||||
|
|
||||||
|
| Query Param | Type | Description |
|
||||||
|
|-------------|------|-------------|
|
||||||
|
| `search` | string | Filter by username (partial match) |
|
||||||
|
| `sortBy` | string | Sort field: `balance`, `level`, `xp`, `username` (default: `balance`) |
|
||||||
|
| `sortOrder` | string | Sort order: `asc`, `desc` (default: `desc`) |
|
||||||
|
| `limit` | number | Max results (default: 50) |
|
||||||
|
| `offset` | number | Pagination offset |
|
||||||
|
|
||||||
|
**Response:** `{ "users": [...], "total": number }`
|
||||||
|
|
||||||
|
### `GET /api/users/:id`
|
||||||
|
Get single user by Discord ID.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "123456789012345678",
|
||||||
|
"username": "Player1",
|
||||||
|
"balance": "1000",
|
||||||
|
"xp": "500",
|
||||||
|
"level": 5,
|
||||||
|
"dailyStreak": 3,
|
||||||
|
"isActive": true,
|
||||||
|
"classId": "1",
|
||||||
|
"class": { "id": "1", "name": "Warrior", "balance": "5000" },
|
||||||
|
"settings": {},
|
||||||
|
"createdAt": "2024-01-01T00:00:00Z",
|
||||||
|
"updatedAt": "2024-01-15T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `PUT /api/users/:id`
|
||||||
|
Update user fields.
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "NewName",
|
||||||
|
"balance": "2000",
|
||||||
|
"xp": "750",
|
||||||
|
"level": 10,
|
||||||
|
"dailyStreak": 5,
|
||||||
|
"classId": "1",
|
||||||
|
"isActive": true,
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/users/:id/inventory`
|
||||||
|
Get user's inventory with item details.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"inventory": [
|
||||||
|
{
|
||||||
|
"userId": "123456789012345678",
|
||||||
|
"itemId": 1,
|
||||||
|
"quantity": "5",
|
||||||
|
"item": { "id": 1, "name": "Health Potion", ... }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/users/:id/inventory`
|
||||||
|
Add item to user inventory.
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"itemId": 1,
|
||||||
|
"quantity": "5"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `DELETE /api/users/:id/inventory/:itemId`
|
||||||
|
Remove item from user inventory. Use query param `amount` to specify quantity (default: 1).
|
||||||
|
|
||||||
|
| Query Param | Type | Description |
|
||||||
|
|-------------|------|-------------|
|
||||||
|
| `amount` | number | Amount to remove (default: 1) |
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `GET /api/classes`
|
||||||
|
List all classes.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"classes": [
|
||||||
|
{ "id": "1", "name": "Warrior", "balance": "5000", "roleId": "123456789" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/classes`
|
||||||
|
Create new class.
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Mage",
|
||||||
|
"balance": "0",
|
||||||
|
"roleId": "987654321"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `PUT /api/classes/:id`
|
||||||
|
Update class.
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Updated Name",
|
||||||
|
"balance": "10000",
|
||||||
|
"roleId": "111222333"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `DELETE /api/classes/:id`
|
||||||
|
Delete class.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Moderation
|
||||||
|
|
||||||
|
### `GET /api/moderation`
|
||||||
|
List moderation cases with optional filtering.
|
||||||
|
|
||||||
|
| Query Param | Type | Description |
|
||||||
|
|-------------|------|-------------|
|
||||||
|
| `userId` | string | Filter by target user ID |
|
||||||
|
| `moderatorId` | string | Filter by moderator ID |
|
||||||
|
| `type` | string | Filter by case type: `warn`, `timeout`, `kick`, `ban`, `note`, `prune` |
|
||||||
|
| `active` | boolean | Filter by active status |
|
||||||
|
| `limit` | number | Max results (default: 50) |
|
||||||
|
| `offset` | number | Pagination offset |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"caseId": "CASE-0001",
|
||||||
|
"type": "warn",
|
||||||
|
"userId": "123456789",
|
||||||
|
"username": "User1",
|
||||||
|
"moderatorId": "987654321",
|
||||||
|
"moderatorName": "Mod1",
|
||||||
|
"reason": "Spam",
|
||||||
|
"metadata": {},
|
||||||
|
"active": true,
|
||||||
|
"createdAt": "2024-01-15T12:00:00Z",
|
||||||
|
"resolvedAt": null,
|
||||||
|
"resolvedBy": null,
|
||||||
|
"resolvedReason": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/moderation/:caseId`
|
||||||
|
Get single case by case ID (e.g., `CASE-0001`).
|
||||||
|
|
||||||
|
### `POST /api/moderation`
|
||||||
|
Create new moderation case.
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "warn",
|
||||||
|
"userId": "123456789",
|
||||||
|
"username": "User1",
|
||||||
|
"moderatorId": "987654321",
|
||||||
|
"moderatorName": "Mod1",
|
||||||
|
"reason": "Rule violation",
|
||||||
|
"metadata": { "duration": "24h" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `PUT /api/moderation/:caseId/clear`
|
||||||
|
Clear/resolve a moderation case.
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"clearedBy": "987654321",
|
||||||
|
"clearedByName": "Mod1",
|
||||||
|
"reason": "Appeal accepted"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transactions
|
||||||
|
|
||||||
|
### `GET /api/transactions`
|
||||||
|
List economy transactions.
|
||||||
|
|
||||||
|
| Query Param | Type | Description |
|
||||||
|
|-------------|------|-------------|
|
||||||
|
| `userId` | string | Filter by user ID |
|
||||||
|
| `type` | string | Filter by transaction type |
|
||||||
|
| `limit` | number | Max results (default: 50) |
|
||||||
|
| `offset` | number | Pagination offset |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"transactions": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"userId": "123456789",
|
||||||
|
"relatedUserId": null,
|
||||||
|
"amount": "100",
|
||||||
|
"type": "DAILY_REWARD",
|
||||||
|
"description": "Daily reward (Streak: 3)",
|
||||||
|
"createdAt": "2024-01-15T12:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Transaction Types:**
|
||||||
|
- `DAILY_REWARD` - Daily claim reward
|
||||||
|
- `TRANSFER_IN` - Received from another user
|
||||||
|
- `TRANSFER_OUT` - Sent to another user
|
||||||
|
- `LOOTDROP_CLAIM` - Claimed lootdrop
|
||||||
|
- `SHOP_BUY` - Item purchase
|
||||||
|
- `QUEST_REWARD` - Quest completion reward
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lootdrops
|
||||||
|
|
||||||
|
### `GET /api/lootdrops`
|
||||||
|
List lootdrops (default limit 50, sorted by newest).
|
||||||
|
|
||||||
|
| Query Param | Type | Description |
|
||||||
|
|-------------|------|-------------|
|
||||||
|
| `limit` | number | Max results (default: 50) |
|
||||||
|
|
||||||
|
**Response:** `{ "lootdrops": [...] }`
|
||||||
|
|
||||||
|
### `POST /api/lootdrops`
|
||||||
|
Spawn a lootdrop in a channel.
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channelId": "1234567890",
|
||||||
|
"amount": 100,
|
||||||
|
"currency": "Gold"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `DELETE /api/lootdrops/:messageId`
|
||||||
|
Cancel and delete a lootdrop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quests
|
||||||
|
|
||||||
|
### `GET /api/quests`
|
||||||
|
List all quests.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Daily Login",
|
||||||
|
"description": "Login once",
|
||||||
|
"triggerEvent": "login",
|
||||||
|
"requirements": { "target": 1 },
|
||||||
|
"rewards": { "xp": 50, "balance": 100 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/quests`
|
||||||
|
Create new quest.
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Daily Login",
|
||||||
|
"description": "Login once",
|
||||||
|
"triggerEvent": "login",
|
||||||
|
"target": 1,
|
||||||
|
"xpReward": 50,
|
||||||
|
"balanceReward": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `PUT /api/quests/:id`
|
||||||
|
Update quest.
|
||||||
|
|
||||||
|
### `DELETE /api/quests/:id`
|
||||||
|
Delete quest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
### `GET /api/settings`
|
||||||
|
Get current bot configuration.
|
||||||
|
|
||||||
|
### `POST /api/settings`
|
||||||
|
Update configuration (partial merge supported).
|
||||||
|
|
||||||
|
### `GET /api/settings/meta`
|
||||||
|
Get Discord metadata (roles, channels, commands).
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"roles": [{ "id": "123", "name": "Admin", "color": "#FF0000" }],
|
||||||
|
"channels": [{ "id": "456", "name": "general", "type": 0 }],
|
||||||
|
"commands": [{ "name": "daily", "category": "economy" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Actions
|
||||||
|
|
||||||
|
### `POST /api/actions/reload-commands`
|
||||||
|
Reload bot slash commands.
|
||||||
|
|
||||||
|
### `POST /api/actions/clear-cache`
|
||||||
|
Clear internal caches.
|
||||||
|
|
||||||
|
### `POST /api/actions/maintenance-mode`
|
||||||
|
Toggle maintenance mode.
|
||||||
|
|
||||||
|
**Body:** `{ "enabled": true, "reason": "Updating..." }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stats
|
||||||
|
|
||||||
|
### `GET /api/stats`
|
||||||
|
Get full dashboard statistics.
|
||||||
|
|
||||||
|
### `GET /api/stats/activity`
|
||||||
|
Get activity aggregation (cached 5 min).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
### `GET /assets/items/:filename`
|
||||||
|
Serve item images. Cached 24 hours.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WebSocket
|
||||||
|
|
||||||
|
### `ws://localhost:3000/ws`
|
||||||
|
Real-time dashboard updates.
|
||||||
|
|
||||||
|
**Messages:**
|
||||||
|
- `STATS_UPDATE` - Periodic stats broadcast (every 5s when clients connected)
|
||||||
|
- `NEW_EVENT` - Real-time system events
|
||||||
|
- `PING/PONG` - Heartbeat
|
||||||
|
|
||||||
|
**Limits:** Max 10 concurrent connections, 16KB max payload, 60s idle timeout.
|
||||||
769
docs/aurora-admin-design-guidelines.md
Normal file
769
docs/aurora-admin-design-guidelines.md
Normal 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.*
|
||||||
168
docs/feature-flags.md
Normal file
168
docs/feature-flags.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# Feature Flag System
|
||||||
|
|
||||||
|
The feature flag system enables controlled beta testing of new features in production without requiring a separate test environment.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Feature flags allow you to:
|
||||||
|
- Test new features with a limited audience before full rollout
|
||||||
|
- Enable/disable features without code changes or redeployment
|
||||||
|
- Control access per guild, user, or role
|
||||||
|
- Eliminate environment drift between test and production
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
**`feature_flags` table:**
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | serial | Primary key |
|
||||||
|
| `name` | varchar(100) | Unique flag identifier |
|
||||||
|
| `enabled` | boolean | Whether the flag is active |
|
||||||
|
| `description` | text | Human-readable description |
|
||||||
|
| `created_at` | timestamp | Creation time |
|
||||||
|
| `updated_at` | timestamp | Last update time |
|
||||||
|
|
||||||
|
**`feature_flag_access` table:**
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | serial | Primary key |
|
||||||
|
| `flag_id` | integer | References feature_flags.id |
|
||||||
|
| `guild_id` | bigint | Guild whitelist (nullable) |
|
||||||
|
| `user_id` | bigint | User whitelist (nullable) |
|
||||||
|
| `role_id` | bigint | Role whitelist (nullable) |
|
||||||
|
| `created_at` | timestamp | Creation time |
|
||||||
|
|
||||||
|
### Service Layer
|
||||||
|
|
||||||
|
The `featureFlagsService` (`shared/modules/feature-flags/feature-flags.service.ts`) provides:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Check if a flag is globally enabled
|
||||||
|
await featureFlagsService.isFlagEnabled("trading_system");
|
||||||
|
|
||||||
|
// Check if a user has access to a flagged feature
|
||||||
|
await featureFlagsService.hasAccess("trading_system", {
|
||||||
|
guildId: "123456789",
|
||||||
|
userId: "987654321",
|
||||||
|
memberRoles: ["role1", "role2"]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a new feature flag
|
||||||
|
await featureFlagsService.createFlag("new_feature", "Description");
|
||||||
|
|
||||||
|
// Enable/disable a flag
|
||||||
|
await featureFlagsService.setFlagEnabled("new_feature", true);
|
||||||
|
|
||||||
|
// Grant access to users/roles/guilds
|
||||||
|
await featureFlagsService.grantAccess("new_feature", { userId: "123" });
|
||||||
|
await featureFlagsService.grantAccess("new_feature", { roleId: "456" });
|
||||||
|
await featureFlagsService.grantAccess("new_feature", { guildId: "789" });
|
||||||
|
|
||||||
|
// List all flags or access records
|
||||||
|
await featureFlagsService.listFlags();
|
||||||
|
await featureFlagsService.listAccess("new_feature");
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Marking a Command as Beta
|
||||||
|
|
||||||
|
Add `beta: true` to any command definition:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const newFeature = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("newfeature")
|
||||||
|
.setDescription("A new experimental feature"),
|
||||||
|
beta: true, // Marks this command as a beta feature
|
||||||
|
execute: async (interaction) => {
|
||||||
|
// Implementation
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
By default, the command name is used as the feature flag name. To use a custom flag name:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const trade = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("trade")
|
||||||
|
.setDescription("Trade items with another user"),
|
||||||
|
beta: true,
|
||||||
|
featureFlag: "trading_system", // Custom flag name
|
||||||
|
execute: async (interaction) => {
|
||||||
|
// Implementation
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access Control Flow
|
||||||
|
|
||||||
|
When a user attempts to use a beta command:
|
||||||
|
|
||||||
|
1. **Check if flag exists and is enabled** - Returns false if flag doesn't exist or is disabled
|
||||||
|
2. **Check guild whitelist** - User's guild has access if `guild_id` matches
|
||||||
|
3. **Check user whitelist** - User has access if `user_id` matches
|
||||||
|
4. **Check role whitelist** - User has access if any of their roles match
|
||||||
|
|
||||||
|
If none of these conditions are met, the user sees:
|
||||||
|
> **Beta Feature**
|
||||||
|
> This feature is currently in beta testing and not available to all users. Stay tuned for the official release!
|
||||||
|
|
||||||
|
## Admin Commands
|
||||||
|
|
||||||
|
The `/featureflags` command (Administrator only) provides full management:
|
||||||
|
|
||||||
|
### Subcommands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `/featureflags list` | List all feature flags with status |
|
||||||
|
| `/featureflags create <name> [description]` | Create a new flag (disabled by default) |
|
||||||
|
| `/featureflags delete <name>` | Delete a flag and all access records |
|
||||||
|
| `/featureflags enable <name>` | Enable a flag globally |
|
||||||
|
| `/featureflags disable <name>` | Disable a flag globally |
|
||||||
|
| `/featureflags grant <name> [user\|role]` | Grant access to a user or role |
|
||||||
|
| `/featureflags revoke <id>` | Revoke access by record ID |
|
||||||
|
| `/featureflags access <name>` | List all access records for a flag |
|
||||||
|
|
||||||
|
### Example Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Create the flag:
|
||||||
|
/featureflags create trading_system "Item trading between users"
|
||||||
|
|
||||||
|
2. Grant access to beta testers:
|
||||||
|
/featureflags grant trading_system user:@beta_tester
|
||||||
|
/featureflags grant trading_system role:@Beta Testers
|
||||||
|
|
||||||
|
3. Enable the flag:
|
||||||
|
/featureflags enable trading_system
|
||||||
|
|
||||||
|
4. View access list:
|
||||||
|
/featureflags access trading_system
|
||||||
|
|
||||||
|
5. When ready for full release:
|
||||||
|
- Remove beta: true from the command
|
||||||
|
- Delete the flag: /featureflags delete trading_system
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Descriptive Names**: Use snake_case names that clearly describe the feature
|
||||||
|
2. **Document Flags**: Always add a description when creating flags
|
||||||
|
3. **Role-Based Access**: Prefer role-based access over user-based for easier management
|
||||||
|
4. **Clean Up**: Delete flags after features are fully released
|
||||||
|
5. **Testing**: Always test with a small group before wider rollout
|
||||||
|
|
||||||
|
## Implementation Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `shared/db/schema/feature-flags.ts` | Database schema |
|
||||||
|
| `shared/modules/feature-flags/feature-flags.service.ts` | Service layer |
|
||||||
|
| `shared/lib/types.ts` | Command interface with beta properties |
|
||||||
|
| `bot/lib/handlers/CommandHandler.ts` | Beta access check |
|
||||||
|
| `bot/commands/admin/featureflags.ts` | Admin command |
|
||||||
199
docs/guild-settings.md
Normal file
199
docs/guild-settings.md
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
# Guild Settings System
|
||||||
|
|
||||||
|
The guild settings system enables per-guild configuration stored in the database, eliminating environment-specific config files and enabling runtime updates without redeployment.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Guild settings allow you to:
|
||||||
|
- Store per-guild configuration in the database
|
||||||
|
- Update settings at runtime without code changes
|
||||||
|
- Support multiple guilds with different configurations
|
||||||
|
- Maintain backward compatibility with file-based config
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
**`guild_settings` table:**
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `guild_id` | bigint | Primary key (Discord guild ID) |
|
||||||
|
| `student_role_id` | bigint | Student role ID |
|
||||||
|
| `visitor_role_id` | bigint | Visitor role ID |
|
||||||
|
| `color_role_ids` | jsonb | Array of color role IDs |
|
||||||
|
| `welcome_channel_id` | bigint | Welcome message channel |
|
||||||
|
| `welcome_message` | text | Custom welcome message |
|
||||||
|
| `feedback_channel_id` | bigint | Feedback channel |
|
||||||
|
| `terminal_channel_id` | bigint | Terminal channel |
|
||||||
|
| `terminal_message_id` | bigint | Terminal message ID |
|
||||||
|
| `moderation_log_channel_id` | bigint | Moderation log channel |
|
||||||
|
| `moderation_dm_on_warn` | jsonb | DM user on warn |
|
||||||
|
| `moderation_auto_timeout_threshold` | jsonb | Auto timeout after N warnings |
|
||||||
|
| `feature_overrides` | jsonb | Feature flag overrides |
|
||||||
|
| `created_at` | timestamp | Creation time |
|
||||||
|
| `updated_at` | timestamp | Last update time |
|
||||||
|
|
||||||
|
### Service Layer
|
||||||
|
|
||||||
|
The `guildSettingsService` (`shared/modules/guild-settings/guild-settings.service.ts`) provides:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Get settings for a guild (returns null if not configured)
|
||||||
|
await guildSettingsService.getSettings(guildId);
|
||||||
|
|
||||||
|
// Create or update settings
|
||||||
|
await guildSettingsService.upsertSettings({
|
||||||
|
guildId: "123456789",
|
||||||
|
studentRoleId: "987654321",
|
||||||
|
visitorRoleId: "111222333",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update a single setting
|
||||||
|
await guildSettingsService.updateSetting(guildId, "welcomeChannel", "456789123");
|
||||||
|
|
||||||
|
// Delete all settings for a guild
|
||||||
|
await guildSettingsService.deleteSettings(guildId);
|
||||||
|
|
||||||
|
// Color role helpers
|
||||||
|
await guildSettingsService.addColorRole(guildId, roleId);
|
||||||
|
await guildSettingsService.removeColorRole(guildId, roleId);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Getting Guild Configuration
|
||||||
|
|
||||||
|
Use `getGuildConfig()` instead of direct `config` imports for guild-specific settings:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
|
|
||||||
|
// In a command or interaction
|
||||||
|
const guildConfig = await getGuildConfig(interaction.guildId);
|
||||||
|
|
||||||
|
// Access settings
|
||||||
|
const studentRole = guildConfig.studentRole;
|
||||||
|
const welcomeChannel = guildConfig.welcomeChannelId;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fallback Behavior
|
||||||
|
|
||||||
|
`getGuildConfig()` returns settings in this order:
|
||||||
|
1. **Database settings** (if guild is configured in DB)
|
||||||
|
2. **File config fallback** (during migration period)
|
||||||
|
|
||||||
|
This ensures backward compatibility while migrating from file-based config.
|
||||||
|
|
||||||
|
### Cache Invalidation
|
||||||
|
|
||||||
|
Settings are cached for 60 seconds. After updating settings, invalidate the cache:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { invalidateGuildConfigCache } from "@shared/lib/config";
|
||||||
|
|
||||||
|
await guildSettingsService.upsertSettings({ guildId, ...settings });
|
||||||
|
invalidateGuildConfigCache(guildId);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin Commands
|
||||||
|
|
||||||
|
The `/settings` command (Administrator only) provides full management:
|
||||||
|
|
||||||
|
### Subcommands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `/settings show` | Display current guild settings |
|
||||||
|
| `/settings set <key> [value]` | Update a setting |
|
||||||
|
| `/settings reset <key>` | Reset a setting to default |
|
||||||
|
| `/settings colors <action> [role]` | Manage color roles |
|
||||||
|
|
||||||
|
### Settable Keys
|
||||||
|
|
||||||
|
| Key | Type | Description |
|
||||||
|
|-----|------|-------------|
|
||||||
|
| `studentRole` | Role | Role for enrolled students |
|
||||||
|
| `visitorRole` | Role | Role for visitors |
|
||||||
|
| `welcomeChannel` | Channel | Channel for welcome messages |
|
||||||
|
| `welcomeMessage` | Text | Custom welcome message |
|
||||||
|
| `feedbackChannel` | Channel | Channel for feedback |
|
||||||
|
| `terminalChannel` | Channel | Terminal channel |
|
||||||
|
| `terminalMessage` | Text | Terminal message ID |
|
||||||
|
| `moderationLogChannel` | Channel | Moderation log channel |
|
||||||
|
| `moderationDmOnWarn` | Boolean | DM users on warn |
|
||||||
|
| `moderationAutoTimeoutThreshold` | Number | Auto timeout threshold |
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/guilds/:guildId/settings` | Get guild settings |
|
||||||
|
| PUT | `/api/guilds/:guildId/settings` | Create/replace settings |
|
||||||
|
| PATCH | `/api/guilds/:guildId/settings` | Partial update |
|
||||||
|
| DELETE | `/api/guilds/:guildId/settings` | Delete settings |
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
To migrate existing config.json settings to the database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run db:migrate-config
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Read values from `config.json`
|
||||||
|
2. Create a database record for `DISCORD_GUILD_ID`
|
||||||
|
3. Store all guild-specific settings
|
||||||
|
|
||||||
|
## Migration Strategy for Code
|
||||||
|
|
||||||
|
Update code references incrementally:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
import { config } from "@shared/lib/config";
|
||||||
|
const role = config.studentRole;
|
||||||
|
|
||||||
|
// After
|
||||||
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
|
const guildConfig = await getGuildConfig(guildId);
|
||||||
|
const role = guildConfig.studentRole;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Files to Update
|
||||||
|
|
||||||
|
Files using guild-specific config that should be updated:
|
||||||
|
- `bot/events/guildMemberAdd.ts`
|
||||||
|
- `bot/modules/user/enrollment.interaction.ts`
|
||||||
|
- `bot/modules/feedback/feedback.interaction.ts`
|
||||||
|
- `bot/commands/feedback/feedback.ts`
|
||||||
|
- `bot/commands/inventory/use.ts`
|
||||||
|
- `bot/commands/admin/create_color.ts`
|
||||||
|
- `shared/modules/moderation/moderation.service.ts`
|
||||||
|
- `shared/modules/terminal/terminal.service.ts`
|
||||||
|
|
||||||
|
## Files Updated to Use Database Config
|
||||||
|
|
||||||
|
All code has been migrated to use `getGuildConfig()`:
|
||||||
|
|
||||||
|
- `bot/events/guildMemberAdd.ts` - Role assignment on join
|
||||||
|
- `bot/modules/user/enrollment.interaction.ts` - Enrollment flow
|
||||||
|
- `bot/modules/feedback/feedback.interaction.ts` - Feedback submission
|
||||||
|
- `bot/commands/feedback/feedback.ts` - Feedback command
|
||||||
|
- `bot/commands/inventory/use.ts` - Color role handling
|
||||||
|
- `bot/commands/admin/create_color.ts` - Color role creation
|
||||||
|
- `bot/commands/admin/warn.ts` - Warning with DM and auto-timeout
|
||||||
|
- `shared/modules/moderation/moderation.service.ts` - Accepts config param
|
||||||
|
- `shared/modules/terminal/terminal.service.ts` - Terminal location persistence
|
||||||
|
- `shared/modules/economy/lootdrop.service.ts` - Terminal updates
|
||||||
|
|
||||||
|
## Implementation Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `shared/db/schema/guild-settings.ts` | Database schema |
|
||||||
|
| `shared/modules/guild-settings/guild-settings.service.ts` | Service layer |
|
||||||
|
| `shared/lib/config.ts` | Config loader with getGuildConfig() |
|
||||||
|
| `bot/commands/admin/settings.ts` | Admin command |
|
||||||
|
| `web/src/routes/guild-settings.routes.ts` | API routes |
|
||||||
|
| `shared/scripts/migrate-config-to-db.ts` | Migration script |
|
||||||
52
docs/main.md
52
docs/main.md
@@ -4,7 +4,7 @@ A comprehensive, feature-rich Discord RPG bot built with modern technologies usi
|
|||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
Aurora uses a **Single Process Monolith** architecture that runs both the Discord bot and web dashboard in the same Bun process. This design maximizes performance by eliminating inter-process communication overhead and simplifies deployment to a single Docker container.
|
Aurora uses a **Single Process Monolith** architecture that runs both the Discord bot and REST API in the same Bun process. This design maximizes performance by eliminating inter-process communication overhead and simplifies deployment to a single Docker container.
|
||||||
|
|
||||||
## Monorepo Structure
|
## Monorepo Structure
|
||||||
|
|
||||||
@@ -15,12 +15,8 @@ aurora-bot-discord/
|
|||||||
│ ├── events/ # Discord event handlers
|
│ ├── events/ # Discord event handlers
|
||||||
│ ├── lib/ # Bot core logic (BotClient, utilities)
|
│ ├── lib/ # Bot core logic (BotClient, utilities)
|
||||||
│ └── index.ts # Bot entry point
|
│ └── index.ts # Bot entry point
|
||||||
├── web/ # React web dashboard
|
├── web/ # REST API server
|
||||||
│ ├── src/ # React components and pages
|
│ └── src/routes/ # API route handlers
|
||||||
│ │ ├── pages/ # Dashboard pages (Admin, Settings, Home)
|
|
||||||
│ │ ├── components/ # Reusable UI components
|
|
||||||
│ │ └── server.ts # Web server with API endpoints
|
|
||||||
│ └── build.ts # Vite build configuration
|
|
||||||
├── shared/ # Shared code between bot and web
|
├── shared/ # Shared code between bot and web
|
||||||
│ ├── db/ # Database schema and Drizzle ORM
|
│ ├── db/ # Database schema and Drizzle ORM
|
||||||
│ ├── lib/ # Utilities, config, logger, events
|
│ ├── lib/ # Utilities, config, logger, events
|
||||||
@@ -52,28 +48,26 @@ The bot is built with Discord.js v14 and handles all Discord-related functionali
|
|||||||
- `ready.ts`: Bot ready events
|
- `ready.ts`: Bot ready events
|
||||||
- `guildMemberAdd.ts`: New member handling
|
- `guildMemberAdd.ts`: New member handling
|
||||||
|
|
||||||
### 2. Web Dashboard (`web/`)
|
### 2. REST API (`web/`)
|
||||||
|
|
||||||
A React 19 + Bun web application for bot administration and monitoring.
|
A headless REST API built with Bun's native HTTP server for bot administration and data access.
|
||||||
|
|
||||||
**Key Pages:**
|
**Key Endpoints:**
|
||||||
|
|
||||||
- **Home** (`/`): Dashboard overview with live statistics
|
- **Stats** (`/api/stats`): Real-time bot metrics and statistics
|
||||||
- **Admin Overview** (`/admin/overview`): Real-time bot metrics
|
- **Settings** (`/api/settings`): Configuration management endpoints
|
||||||
- **Admin Quests** (`/admin/quests`): Quest management interface
|
- **Users** (`/api/users`): User data and profiles
|
||||||
- **Settings** (`/settings/*`): Configuration pages for:
|
- **Items** (`/api/items`): Item catalog and management
|
||||||
- General settings
|
- **Quests** (`/api/quests`): Quest data and progress
|
||||||
- Economy settings
|
- **Economy** (`/api/transactions`): Economy and transaction data
|
||||||
- Systems settings
|
|
||||||
- Roles settings
|
|
||||||
|
|
||||||
**Web Server Features:**
|
**API Features:**
|
||||||
|
|
||||||
- Built with Bun's native HTTP server
|
- Built with Bun's native HTTP server
|
||||||
- WebSocket support for real-time updates
|
- WebSocket support for real-time updates (`/ws`)
|
||||||
- REST API endpoints for dashboard data
|
- REST API endpoints for all bot data
|
||||||
- SPA fallback for client-side routing
|
- Real-time event streaming via WebSocket
|
||||||
- Bun dev server with hot module replacement
|
- Zod validation for all requests
|
||||||
|
|
||||||
### 3. Shared Core (`shared/`)
|
### 3. Shared Core (`shared/`)
|
||||||
|
|
||||||
@@ -123,15 +117,15 @@ Shared code accessible by both bot and web applications.
|
|||||||
|
|
||||||
### For Server Administrators
|
### For Server Administrators
|
||||||
|
|
||||||
1. **Bot Configuration**: Adjust economy rates, enable/disable features via dashboard
|
1. **Bot Configuration**: Adjust economy rates, enable/disable features via API
|
||||||
2. **Moderation Tools**:
|
2. **Moderation Tools**:
|
||||||
- Warn, note, and track moderation cases
|
- Warn, note, and track moderation cases
|
||||||
- Mass prune inactive members
|
- Mass prune inactive members
|
||||||
- Role management
|
- Role management
|
||||||
3. **Quest Management**: Create and manage server-specific quests
|
3. **Quest Management**: Create and manage server-specific quests
|
||||||
4. **Monitoring**:
|
4. **Monitoring**:
|
||||||
- Real-time dashboard with live statistics
|
- Real-time statistics via REST API
|
||||||
- Activity charts and event logs
|
- Activity data and event logs
|
||||||
- Economy leaderboards
|
- Economy leaderboards
|
||||||
|
|
||||||
### For Developers
|
### For Developers
|
||||||
@@ -148,10 +142,10 @@ Shared code accessible by both bot and web applications.
|
|||||||
| ---------------- | --------------------------------- |
|
| ---------------- | --------------------------------- |
|
||||||
| Runtime | Bun 1.0+ |
|
| Runtime | Bun 1.0+ |
|
||||||
| Bot Framework | Discord.js 14.x |
|
| Bot Framework | Discord.js 14.x |
|
||||||
| Web Framework | React 19 + Bun |
|
| Web Framework | Bun HTTP Server (REST API) |
|
||||||
| Database | PostgreSQL 17 |
|
| Database | PostgreSQL 17 |
|
||||||
| ORM | Drizzle ORM |
|
| ORM | Drizzle ORM |
|
||||||
| Styling | Tailwind CSS v4 + ShadCN/Radix UI |
|
| UI | Discord embeds and components |
|
||||||
| Validation | Zod |
|
| Validation | Zod |
|
||||||
| Containerization | Docker |
|
| Containerization | Docker |
|
||||||
|
|
||||||
@@ -165,4 +159,4 @@ bun run migrate
|
|||||||
docker compose up
|
docker compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
The bot and dashboard process run on port 3000 and are accessible at `http://localhost:3000`.
|
The bot and API server run on port 3000 and are accessible at `http://localhost:3000`.
|
||||||
|
|||||||
26
package.json
26
package.json
@@ -4,6 +4,7 @@
|
|||||||
"module": "bot/index.ts",
|
"module": "bot/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"workspaces": ["panel"],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"drizzle-kit": "^0.31.8"
|
"drizzle-kit": "^0.31.8"
|
||||||
@@ -12,16 +13,29 @@
|
|||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate": "docker compose run --rm app drizzle-kit generate",
|
"dev": "bun --watch bot/index.ts",
|
||||||
"migrate": "docker compose run --rm app drizzle-kit migrate",
|
"logs": "bash shared/scripts/logs.sh",
|
||||||
|
"remote": "bash shared/scripts/remote.sh",
|
||||||
|
"db:generate": "docker compose run --rm app drizzle-kit generate",
|
||||||
|
"db:migrate": "docker compose run --rm app drizzle-kit migrate",
|
||||||
|
"generate": "bun run db:generate",
|
||||||
|
"migrate": "bun run db:migrate",
|
||||||
"db:push": "docker compose run --rm app drizzle-kit push",
|
"db:push": "docker compose run --rm app drizzle-kit push",
|
||||||
"db:push:local": "drizzle-kit push",
|
"db:push:local": "drizzle-kit push",
|
||||||
"dev": "bun --watch bot/index.ts",
|
|
||||||
"db:studio": "drizzle-kit studio --port 4983 --host 0.0.0.0",
|
"db:studio": "drizzle-kit studio --port 4983 --host 0.0.0.0",
|
||||||
"remote": "bash shared/scripts/remote.sh",
|
|
||||||
"logs": "bash shared/scripts/logs.sh",
|
|
||||||
"db:backup": "bash shared/scripts/db-backup.sh",
|
"db:backup": "bash shared/scripts/db-backup.sh",
|
||||||
"test": "bun test",
|
"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'",
|
||||||
|
"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"
|
"docker:cleanup": "bash shared/scripts/docker-cleanup.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
12
panel/index.html
Normal file
12
panel/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Aurora Admin Panel</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
32
panel/package.json
Normal file
32
panel/package.json
Normal 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
91
panel/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
panel/src/components/Layout.tsx
Normal file
140
panel/src/components/Layout.tsx
Normal 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
51
panel/src/index.css
Normal 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
47
panel/src/lib/api.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
const BASE = "";
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
error: string;
|
||||||
|
details?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api<T = unknown>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.href = `/auth/discord?return_to=${encodeURIComponent(window.location.href)}`;
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
throw body as ApiError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204 || res.headers.get("content-length") === "0") {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const get = <T = unknown>(path: string) => api<T>(path);
|
||||||
|
|
||||||
|
export const post = <T = unknown>(path: string, data?: unknown) =>
|
||||||
|
api<T>(path, { method: "POST", body: data ? JSON.stringify(data) : undefined });
|
||||||
|
|
||||||
|
export const put = <T = unknown>(path: string, data?: unknown) =>
|
||||||
|
api<T>(path, { method: "PUT", body: data ? JSON.stringify(data) : undefined });
|
||||||
|
|
||||||
|
export const del = <T = unknown>(path: string) =>
|
||||||
|
api<T>(path, { method: "DELETE" });
|
||||||
35
panel/src/lib/useAuth.ts
Normal file
35
panel/src/lib/useAuth.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
discordId: string;
|
||||||
|
username: string;
|
||||||
|
avatar: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
loading: boolean;
|
||||||
|
user: AuthUser | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthState & { logout: () => Promise<void> } {
|
||||||
|
const [state, setState] = useState<AuthState>({ loading: true, user: null });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/auth/me", { credentials: "same-origin" })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { authenticated: boolean; user?: AuthUser }) => {
|
||||||
|
setState({
|
||||||
|
loading: false,
|
||||||
|
user: data.authenticated ? data.user! : null,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => setState({ loading: false, user: null }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await fetch("/auth/logout", { method: "POST", credentials: "same-origin" });
|
||||||
|
setState({ loading: false, user: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...state, logout };
|
||||||
|
}
|
||||||
51
panel/src/lib/useDashboard.ts
Normal file
51
panel/src/lib/useDashboard.ts
Normal 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
99
panel/src/lib/useItems.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
192
panel/src/lib/useSettings.ts
Normal file
192
panel/src/lib/useSettings.ts
Normal 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
343
panel/src/lib/useUsers.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,5 +2,5 @@ import { type ClassValue, clsx } from "clsx";
|
|||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
10
panel/src/main.tsx
Normal file
10
panel/src/main.tsx
Normal 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>
|
||||||
|
);
|
||||||
882
panel/src/pages/BackgroundRemoval.tsx
Normal file
882
panel/src/pages/BackgroundRemoval.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
509
panel/src/pages/CanvasTool.tsx
Normal file
509
panel/src/pages/CanvasTool.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
653
panel/src/pages/CropTool.tsx
Normal file
653
panel/src/pages/CropTool.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
297
panel/src/pages/Dashboard.tsx
Normal file
297
panel/src/pages/Dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
470
panel/src/pages/HueShifter.tsx
Normal file
470
panel/src/pages/HueShifter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1826
panel/src/pages/ItemStudio.tsx
Normal file
1826
panel/src/pages/ItemStudio.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user