forked from syntaxbullet/aurorabot
Compare commits
44 Commits
73ad889018
...
7cc2f61db6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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_GUILD_ID=your-discord-guild-id
|
||||
|
||||
# Admin Panel (Discord OAuth)
|
||||
# Get client secret from: https://discord.com/developers/applications → OAuth2
|
||||
DISCORD_CLIENT_SECRET=your-discord-client-secret
|
||||
SESSION_SECRET=change-me-to-a-random-string
|
||||
ADMIN_USER_IDS=123456789012345678
|
||||
PANEL_BASE_URL=http://localhost:3000
|
||||
|
||||
# Server (for remote access scripts)
|
||||
# Use a non-root user (see shared/scripts/setup-server.sh)
|
||||
VPS_USER=deploy
|
||||
|
||||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -95,6 +95,6 @@ jobs:
|
||||
ADMIN_TOKEN="admin_token_123"
|
||||
LOG_LEVEL="error"
|
||||
EOF
|
||||
bash shared/scripts/test-sequential.sh
|
||||
bash shared/scripts/test-sequential.sh --integration
|
||||
env:
|
||||
NODE_ENV: test
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,6 +3,7 @@ node_modules
|
||||
docker-compose.override.yml
|
||||
shared/db-logs
|
||||
shared/db/data
|
||||
shared/db/backups
|
||||
shared/db/loga
|
||||
.cursor
|
||||
# dependencies (bun install)
|
||||
@@ -46,5 +47,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
src/db/data
|
||||
src/db/log
|
||||
scratchpad/
|
||||
tickets/
|
||||
bot/assets/graphics/items
|
||||
tickets/
|
||||
.citrine.local
|
||||
|
||||
39
AGENTS.md
39
AGENTS.md
@@ -141,22 +141,36 @@ throw new UserError("You don't have enough coins!");
|
||||
throw new SystemError("Database connection failed");
|
||||
```
|
||||
|
||||
### Standard Error Pattern
|
||||
### Recommended: `withCommandErrorHandling`
|
||||
|
||||
Use the `withCommandErrorHandling` utility from `@lib/commandUtils` to standardize
|
||||
error handling across all commands. It handles `deferReply`, `UserError` display,
|
||||
and unexpected error logging automatically.
|
||||
|
||||
```typescript
|
||||
try {
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const myCommand = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("mycommand")
|
||||
.setDescription("Does something"),
|
||||
execute: async (interaction) => {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const result = await service.method();
|
||||
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
||||
} catch (error) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Unexpected error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred.")],
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
{ ephemeral: true } // optional: makes the deferred reply ephemeral
|
||||
);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Options:
|
||||
- `ephemeral` — whether `deferReply` should be ephemeral
|
||||
- `successMessage` — a simple string to send on success
|
||||
- `onSuccess` — a callback invoked with the operation result
|
||||
```
|
||||
|
||||
## Database Patterns
|
||||
@@ -240,3 +254,4 @@ describe("serviceName", () => {
|
||||
| Environment | `shared/lib/env.ts` |
|
||||
| Embed helpers | `bot/lib/embeds.ts` |
|
||||
| Command utils | `shared/lib/utils.ts` |
|
||||
| Error handler | `bot/lib/commandUtils.ts` |
|
||||
|
||||
187
CLAUDE.md
Normal file
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` |
|
||||
42
Dockerfile
42
Dockerfile
@@ -16,6 +16,7 @@ FROM base AS deps
|
||||
|
||||
# Copy only package files first (better layer caching)
|
||||
COPY package.json bun.lock ./
|
||||
COPY panel/package.json panel/
|
||||
|
||||
# Install dependencies
|
||||
RUN bun install --frozen-lockfile
|
||||
@@ -33,3 +34,44 @@ EXPOSE 3000
|
||||
|
||||
# Default command
|
||||
CMD ["bun", "run", "dev"]
|
||||
|
||||
# ============================================
|
||||
# Builder stage - copies source for production
|
||||
# ============================================
|
||||
FROM base AS builder
|
||||
|
||||
# Copy source code first, then deps on top (so node_modules aren't overwritten)
|
||||
COPY . .
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# Install panel deps and build
|
||||
RUN cd panel && bun install --frozen-lockfile && bun run build
|
||||
|
||||
# ============================================
|
||||
# Production stage - minimal runtime image
|
||||
# ============================================
|
||||
FROM oven/bun:latest AS production
|
||||
WORKDIR /app
|
||||
|
||||
# Copy only what's needed for production
|
||||
COPY --from=builder --chown=bun:bun /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=bun:bun /app/api/src ./api/src
|
||||
COPY --from=builder --chown=bun:bun /app/bot ./bot
|
||||
COPY --from=builder --chown=bun:bun /app/shared ./shared
|
||||
COPY --from=builder --chown=bun:bun /app/panel/dist ./panel/dist
|
||||
COPY --from=builder --chown=bun:bun /app/package.json .
|
||||
COPY --from=builder --chown=bun:bun /app/drizzle.config.ts .
|
||||
COPY --from=builder --chown=bun:bun /app/tsconfig.json .
|
||||
|
||||
# Switch to non-root user
|
||||
USER bun
|
||||
|
||||
# Expose web dashboard port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
|
||||
|
||||
# Run in production mode
|
||||
CMD ["bun", "run", "bot/index.ts"]
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
# =============================================================================
|
||||
# Stage 1: Dependencies & Build
|
||||
# =============================================================================
|
||||
FROM oven/bun:latest AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies needed for build
|
||||
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install root project dependencies
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# =============================================================================
|
||||
# Stage 2: Production Runtime
|
||||
# =============================================================================
|
||||
FROM oven/bun:latest AS production
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user for security (bun user already exists with 1000:1000)
|
||||
# No need to create user/group
|
||||
|
||||
|
||||
|
||||
# Copy only what's needed for production
|
||||
COPY --from=builder --chown=bun:bun /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=bun:bun /app/web/src ./web/src
|
||||
COPY --from=builder --chown=bun:bun /app/bot ./bot
|
||||
COPY --from=builder --chown=bun:bun /app/shared ./shared
|
||||
COPY --from=builder --chown=bun:bun /app/package.json .
|
||||
COPY --from=builder --chown=bun:bun /app/drizzle.config.ts .
|
||||
COPY --from=builder --chown=bun:bun /app/tsconfig.json .
|
||||
|
||||
# Switch to non-root user
|
||||
USER bun
|
||||
|
||||
# Expose web dashboard port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
|
||||
|
||||
# Run in production mode
|
||||
CMD ["bun", "run", "bot/index.ts"]
|
||||
@@ -7,6 +7,7 @@
|
||||

|
||||

|
||||

|
||||
|
||||
Aurora is a powerful Discord bot designed to facilitate RPG-like elements within a Discord server. It features a robust economy, class system, inventory management, quests, and more, all built on top of a high-performance stack using Bun and Drizzle ORM.
|
||||
|
||||
**New in v1.0:** Aurora now includes a fully integrated **REST API** for accessing bot data, statistics, and configuration, running alongside the bot in a single process.
|
||||
|
||||
0
web/.gitignore → api/.gitignore
vendored
0
web/.gitignore → api/.gitignore
vendored
0
web/bun-env.d.ts → api/bun-env.d.ts
vendored
0
web/bun-env.d.ts → api/bun-env.d.ts
vendored
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,
|
||||
};
|
||||
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
|
||||
};
|
||||
@@ -4,11 +4,13 @@
|
||||
*/
|
||||
|
||||
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";
|
||||
@@ -16,17 +18,21 @@ import { moderationRoutes } from "./moderation.routes";
|
||||
import { transactionsRoutes } from "./transactions.routes";
|
||||
import { lootdropsRoutes } from "./lootdrops.routes";
|
||||
import { assetsRoutes } from "./assets.routes";
|
||||
import { errorResponse } from "./utils";
|
||||
|
||||
/**
|
||||
* All registered route modules in order of precedence.
|
||||
* Routes are checked in order; the first matching route wins.
|
||||
*/
|
||||
const routeModules: RouteModule[] = [
|
||||
/** Routes that do NOT require authentication */
|
||||
const publicRoutes: RouteModule[] = [
|
||||
authRoutes,
|
||||
healthRoutes,
|
||||
];
|
||||
|
||||
/** Routes that require an authenticated admin session */
|
||||
const protectedRoutes: RouteModule[] = [
|
||||
statsRoutes,
|
||||
actionsRoutes,
|
||||
questsRoutes,
|
||||
settingsRoutes,
|
||||
guildSettingsRoutes,
|
||||
itemsRoutes,
|
||||
usersRoutes,
|
||||
classesRoutes,
|
||||
@@ -56,12 +62,23 @@ export async function handleRequest(req: Request, url: URL): Promise<Response |
|
||||
pathname: url.pathname,
|
||||
};
|
||||
|
||||
// Try each route module in order
|
||||
for (const module of routeModules) {
|
||||
// Try public routes first (auth, health)
|
||||
for (const module of publicRoutes) {
|
||||
const response = await module.handler(ctx);
|
||||
if (response !== null) {
|
||||
return response;
|
||||
if (response !== null) return response;
|
||||
}
|
||||
|
||||
// For API routes, enforce authentication
|
||||
if (ctx.pathname.startsWith("/api/")) {
|
||||
if (!isAuthenticated(req)) {
|
||||
return errorResponse("Unauthorized", 401);
|
||||
}
|
||||
}
|
||||
|
||||
// Try protected routes
|
||||
for (const module of protectedRoutes) {
|
||||
const response = await module.handler(ctx);
|
||||
if (response !== null) return response;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -72,5 +89,5 @@ export async function handleRequest(req: Request, url: URL): Promise<Response |
|
||||
* Useful for debugging and documentation.
|
||||
*/
|
||||
export function getRegisteredRoutes(): string[] {
|
||||
return routeModules.map(m => m.name);
|
||||
return [...publicRoutes, ...protectedRoutes].map(m => m.name);
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { join, resolve, dirname } from "path";
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import type { CreateItemDTO, UpdateItemDTO } from "@shared/modules/items/items.service";
|
||||
import {
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
@@ -121,7 +122,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
return withErrorHandling(async () => {
|
||||
const contentType = req.headers.get("content-type") || "";
|
||||
|
||||
let itemData: any;
|
||||
let itemData: CreateItemDTO | null = null;
|
||||
let imageFile: File | null = null;
|
||||
|
||||
if (contentType.includes("multipart/form-data")) {
|
||||
@@ -130,12 +131,16 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
imageFile = formData.get("image") as File | null;
|
||||
|
||||
if (typeof jsonData === "string") {
|
||||
itemData = JSON.parse(jsonData);
|
||||
itemData = JSON.parse(jsonData) as CreateItemDTO;
|
||||
} else {
|
||||
return errorResponse("Missing item data", 400);
|
||||
}
|
||||
} else {
|
||||
itemData = await req.json();
|
||||
itemData = await req.json() as CreateItemDTO;
|
||||
}
|
||||
|
||||
if (!itemData) {
|
||||
return errorResponse("Missing item data", 400);
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
@@ -235,7 +240,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
if (!id) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const data = await req.json() as Record<string, any>;
|
||||
const data = await req.json() as Partial<UpdateItemDTO>;
|
||||
|
||||
const existing = await itemsService.getItemById(id);
|
||||
if (!existing) {
|
||||
@@ -250,7 +255,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
}
|
||||
|
||||
// Build update data
|
||||
const updateData: any = {};
|
||||
const updateData: Partial<UpdateItemDTO> = {};
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.rarity !== undefined) updateData.rarity = data.rarity;
|
||||
@@ -29,7 +29,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { ModerationService } = await import("@shared/modules/moderation/moderation.service");
|
||||
const { moderationService } = await import("@shared/modules/moderation/moderation.service");
|
||||
|
||||
/**
|
||||
* @route GET /api/moderation
|
||||
@@ -78,7 +78,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
filter.limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
|
||||
filter.offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
|
||||
|
||||
const cases = await ModerationService.searchCases(filter);
|
||||
const cases = await moderationService.searchCases(filter);
|
||||
return jsonResponse({ cases });
|
||||
}, "fetch moderation cases");
|
||||
}
|
||||
@@ -97,7 +97,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const caseId = pathname.split("/").pop()!.toUpperCase();
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const moderationCase = await ModerationService.getCaseById(caseId);
|
||||
const moderationCase = await moderationService.getCaseById(caseId);
|
||||
|
||||
if (!moderationCase) {
|
||||
return errorResponse("Case not found", 404);
|
||||
@@ -148,7 +148,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
);
|
||||
}
|
||||
|
||||
const newCase = await ModerationService.createCase({
|
||||
const newCase = await moderationService.createCase({
|
||||
type: data.type,
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
@@ -193,7 +193,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
return errorResponse("Missing required fields: clearedBy, clearedByName", 400);
|
||||
}
|
||||
|
||||
const updatedCase = await ModerationService.clearCase({
|
||||
const updatedCase = await moderationService.clearCase({
|
||||
caseId,
|
||||
clearedBy: data.clearedBy,
|
||||
clearedByName: data.clearedByName,
|
||||
@@ -7,13 +7,6 @@
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
|
||||
|
||||
/**
|
||||
* JSON replacer for BigInt serialization.
|
||||
*/
|
||||
function jsonReplacer(_key: string, value: unknown): unknown {
|
||||
return typeof value === "bigint" ? value.toString() : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings routes handler.
|
||||
*
|
||||
@@ -32,26 +25,31 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
|
||||
/**
|
||||
* @route GET /api/settings
|
||||
* @description Returns the current bot configuration.
|
||||
* @description Returns the current bot configuration from database.
|
||||
* Configuration includes economy settings, leveling settings,
|
||||
* command toggles, and other system settings.
|
||||
* @response 200 - Full configuration object
|
||||
* @response 200 - Full configuration object (DB format with strings for BigInts)
|
||||
* @response 500 - Error fetching settings
|
||||
*
|
||||
* @example
|
||||
* // Response
|
||||
* {
|
||||
* "economy": { "dailyReward": 100, "streakBonus": 10 },
|
||||
* "leveling": { "xpPerMessage": 15, "levelUpChannel": "123456789" },
|
||||
* "economy": { "daily": { "amount": "100", "streakBonus": "10" } },
|
||||
* "leveling": { "base": 100, "exponent": 1.5 },
|
||||
* "commands": { "disabled": [], "channelLocks": {} }
|
||||
* }
|
||||
*/
|
||||
if (pathname === "/api/settings" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const { config } = await import("@shared/lib/config");
|
||||
return new Response(JSON.stringify(config, jsonReplacer), {
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
|
||||
const settings = await gameSettingsService.getSettings();
|
||||
|
||||
if (!settings) {
|
||||
// Return defaults if no settings in DB yet
|
||||
return jsonResponse(gameSettingsService.getDefaults());
|
||||
}
|
||||
|
||||
return jsonResponse(settings);
|
||||
}, "fetch settings");
|
||||
}
|
||||
|
||||
@@ -61,7 +59,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
* Only the provided fields will be updated; other settings remain unchanged.
|
||||
* After updating, commands are automatically reloaded.
|
||||
*
|
||||
* @body Partial configuration object
|
||||
* @body Partial configuration object (DB format with strings for BigInts)
|
||||
* @response 200 - `{ success: true }`
|
||||
* @response 400 - Validation error
|
||||
* @response 500 - Error saving settings
|
||||
@@ -69,19 +67,15 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
* @example
|
||||
* // Request - Only update economy daily reward
|
||||
* POST /api/settings
|
||||
* { "economy": { "dailyReward": 150 } }
|
||||
* { "economy": { "daily": { "amount": "150" } } }
|
||||
*/
|
||||
if (pathname === "/api/settings" && method === "POST") {
|
||||
try {
|
||||
const partialConfig = await req.json();
|
||||
const { saveConfig, config: currentConfig } = await import("@shared/lib/config");
|
||||
const { deepMerge } = await import("@shared/lib/utils");
|
||||
const partialConfig = await req.json() as Record<string, unknown>;
|
||||
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
|
||||
|
||||
// Merge partial update into current config
|
||||
const mergedConfig = deepMerge(currentConfig, partialConfig);
|
||||
|
||||
// saveConfig throws if validation fails
|
||||
saveConfig(mergedConfig);
|
||||
// Use upsertSettings to merge partial update
|
||||
await gameSettingsService.upsertSettings(partialConfig as Record<string, unknown>);
|
||||
|
||||
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
||||
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
|
||||
@@ -145,7 +139,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return jsonResponse({ roles, channels, commands });
|
||||
return jsonResponse({ guildId: env.DISCORD_GUILD_ID, roles, channels, commands });
|
||||
}, "fetch settings meta");
|
||||
}
|
||||
|
||||
@@ -132,6 +132,13 @@ mock.module("@shared/lib/utils", () => ({
|
||||
typeof value === "bigint" ? value.toString() : value,
|
||||
}));
|
||||
|
||||
// --- Mock Auth (bypass authentication) ---
|
||||
mock.module("./routes/auth.routes", () => ({
|
||||
authRoutes: { name: "auth", handler: () => null },
|
||||
isAuthenticated: () => true,
|
||||
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
|
||||
}));
|
||||
|
||||
// --- Mock Logger ---
|
||||
mock.module("@shared/lib/logger", () => ({
|
||||
logger: {
|
||||
@@ -403,8 +410,11 @@ describe("Items API", () => {
|
||||
});
|
||||
|
||||
test("should prevent path traversal attacks", async () => {
|
||||
const response = await fetch(`${baseUrl}/assets/../../../etc/passwd`);
|
||||
// Should either return 403 (Forbidden) or 404 (Not found after sanitization)
|
||||
// Note: fetch() and HTTP servers normalize ".." segments before the handler sees them,
|
||||
// so we can't send raw traversal paths over HTTP. Instead, test that a suspicious
|
||||
// asset path (with encoded sequences) doesn't serve sensitive file content.
|
||||
const response = await fetch(`${baseUrl}/assets/..%2f..%2f..%2fetc%2fpasswd`);
|
||||
// Should not serve actual file content — expect 403 or 404
|
||||
expect([403, 404]).toContain(response.status);
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,57 @@
|
||||
import { describe, expect, it, mock, beforeEach, afterEach, jest } from "bun:test";
|
||||
import { type WebServerInstance } from "./server";
|
||||
|
||||
// Mock the dependencies
|
||||
const mockConfig = {
|
||||
// Mock gameSettingsService — the route now uses this instead of config/saveConfig
|
||||
const mockSettings = {
|
||||
leveling: {
|
||||
base: 100,
|
||||
exponent: 1.5,
|
||||
chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 }
|
||||
},
|
||||
economy: {
|
||||
daily: { amount: 100n, streakBonus: 10n, weeklyBonus: 50n, cooldownMs: 86400000 },
|
||||
transfers: { allowSelfTransfer: false, minAmount: 50n },
|
||||
daily: { amount: "100", streakBonus: "10", weeklyBonus: "50", cooldownMs: 86400000 },
|
||||
transfers: { allowSelfTransfer: false, minAmount: "1" },
|
||||
exam: { multMin: 1.5, multMax: 2.5 }
|
||||
},
|
||||
inventory: { maxStackSize: 99n, maxSlots: 20 },
|
||||
inventory: { maxStackSize: "99", maxSlots: 20 },
|
||||
lootdrop: {
|
||||
spawnChance: 0.1,
|
||||
cooldownMs: 3600000,
|
||||
minMessages: 10,
|
||||
activityWindowMs: 300000,
|
||||
reward: { min: 100, max: 500, currency: "gold" }
|
||||
},
|
||||
commands: { "help": true },
|
||||
system: {},
|
||||
moderation: {
|
||||
prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 },
|
||||
cases: { dmOnWarn: true }
|
||||
},
|
||||
trivia: {
|
||||
entryFee: "50",
|
||||
rewardMultiplier: 1.5,
|
||||
timeoutSeconds: 30,
|
||||
cooldownMs: 60000,
|
||||
categories: [],
|
||||
difficulty: "random"
|
||||
}
|
||||
};
|
||||
|
||||
const mockSaveConfig = jest.fn();
|
||||
const mockGetSettings = jest.fn(() => Promise.resolve(mockSettings));
|
||||
const mockUpsertSettings = jest.fn(() => Promise.resolve(mockSettings));
|
||||
const mockGetDefaults = jest.fn(() => mockSettings);
|
||||
|
||||
// Mock @shared/lib/config using mock.module
|
||||
mock.module("@shared/lib/config", () => ({
|
||||
config: mockConfig,
|
||||
saveConfig: mockSaveConfig,
|
||||
GameConfigType: {}
|
||||
mock.module("@shared/modules/game-settings/game-settings.service", () => ({
|
||||
gameSettingsService: {
|
||||
getSettings: mockGetSettings,
|
||||
upsertSettings: mockUpsertSettings,
|
||||
getDefaults: mockGetDefaults,
|
||||
invalidateCache: jest.fn(),
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock DrizzleClient (dependency potentially imported transitively)
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: {}
|
||||
}));
|
||||
|
||||
// Mock @shared/lib/utils (deepMerge is used by settings API)
|
||||
@@ -93,6 +110,13 @@ mock.module("bun", () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock auth (bypass authentication)
|
||||
mock.module("./routes/auth.routes", () => ({
|
||||
authRoutes: { name: "auth", handler: () => null },
|
||||
isAuthenticated: () => true,
|
||||
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
|
||||
}));
|
||||
|
||||
// Import createWebServer after mocks
|
||||
import { createWebServer } from "./server";
|
||||
|
||||
@@ -104,6 +128,8 @@ describe("Settings API", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
mockGetSettings.mockImplementation(() => Promise.resolve(mockSettings));
|
||||
mockUpsertSettings.mockImplementation(() => Promise.resolve(mockSettings));
|
||||
serverInstance = await createWebServer({ port: PORT, hostname: HOSTNAME });
|
||||
});
|
||||
|
||||
@@ -117,18 +143,14 @@ describe("Settings API", () => {
|
||||
const res = await fetch(`${BASE_URL}/api/settings`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
// Check if BigInts are converted to strings
|
||||
const data = await res.json() as any;
|
||||
// Check values come through correctly
|
||||
expect(data.economy.daily.amount).toBe("100");
|
||||
expect(data.leveling.base).toBe(100);
|
||||
});
|
||||
|
||||
it("POST /api/settings should save valid configuration via merge", async () => {
|
||||
// We only send a partial update, expecting the server to merge it
|
||||
// Note: For now the server implementation might still default to overwrite if we haven't updated it yet.
|
||||
// But the user requested "partial vs full" fix.
|
||||
// Let's assume we implement the merge logic.
|
||||
const partialConfig = { studentRole: "new-role-partial" };
|
||||
const partialConfig = { economy: { daily: { amount: "200" } } };
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: "POST",
|
||||
@@ -137,26 +159,27 @@ describe("Settings API", () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Expect saveConfig to be called with the MERGED result
|
||||
expect(mockSaveConfig).toHaveBeenCalledWith(expect.objectContaining({
|
||||
studentRole: "new-role-partial",
|
||||
leveling: mockConfig.leveling // Should keep existing values
|
||||
}));
|
||||
// upsertSettings should be called with the partial config
|
||||
expect(mockUpsertSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
economy: { daily: { amount: "200" } }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /api/settings should return 400 when save fails", async () => {
|
||||
mockSaveConfig.mockImplementationOnce(() => {
|
||||
mockUpsertSettings.mockImplementationOnce(() => {
|
||||
throw new Error("Validation failed");
|
||||
});
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}) // Empty might be valid partial, but mocks throw
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
const data = await res.json() as any;
|
||||
expect(data.details).toBe("Validation failed");
|
||||
});
|
||||
|
||||
@@ -164,7 +187,7 @@ describe("Settings API", () => {
|
||||
const res = await fetch(`${BASE_URL}/api/settings/meta`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
const data = await res.json() as any;
|
||||
expect(data.roles).toHaveLength(2);
|
||||
expect(data.roles[0]).toEqual({ id: "role1", name: "Admin", color: "#ffffff" });
|
||||
expect(data.channels[0]).toEqual({ id: "chan1", name: "general", type: 0 });
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, test, expect, afterAll, mock } from "bun:test";
|
||||
import type { WebServerInstance } from "./server";
|
||||
import { createWebServer } from "./server";
|
||||
|
||||
interface MockBotStats {
|
||||
bot: { name: string; avatarUrl: string | null };
|
||||
@@ -13,21 +12,21 @@ interface MockBotStats {
|
||||
}
|
||||
|
||||
// 1. Mock DrizzleClient (dependency of dashboardService)
|
||||
// Must provide full chainable builder for select().from().leftJoin().groupBy().orderBy().limit()
|
||||
mock.module("@shared/db/DrizzleClient", () => {
|
||||
const mockBuilder = {
|
||||
where: mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }])),
|
||||
then: (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]),
|
||||
orderBy: mock(() => mockBuilder), // Chainable
|
||||
limit: mock(() => Promise.resolve([])), // Terminal
|
||||
};
|
||||
|
||||
const mockFrom = {
|
||||
from: mock(() => mockBuilder),
|
||||
};
|
||||
const mockBuilder: Record<string, any> = {};
|
||||
// Every chainable method returns mock builder; terminal calls return resolved promise
|
||||
mockBuilder.where = mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]));
|
||||
mockBuilder.then = (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]);
|
||||
mockBuilder.orderBy = mock(() => mockBuilder);
|
||||
mockBuilder.limit = mock(() => Promise.resolve([]));
|
||||
mockBuilder.leftJoin = mock(() => mockBuilder);
|
||||
mockBuilder.groupBy = mock(() => mockBuilder);
|
||||
mockBuilder.from = mock(() => mockBuilder);
|
||||
|
||||
return {
|
||||
DrizzleClient: {
|
||||
select: mock(() => mockFrom),
|
||||
select: mock(() => mockBuilder),
|
||||
query: {
|
||||
transactions: { findMany: mock(() => Promise.resolve([])) },
|
||||
moderationCases: { findMany: mock(() => Promise.resolve([])) },
|
||||
@@ -54,7 +53,38 @@ mock.module("../../bot/lib/clientStats", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
// 3. System Events (No mock needed, use real events)
|
||||
// 3. Mock config (used by lootdrop.service.getLootdropState)
|
||||
mock.module("@shared/lib/config", () => ({
|
||||
config: {
|
||||
lootdrop: {
|
||||
activityWindowMs: 120000,
|
||||
minMessages: 1,
|
||||
spawnChance: 1,
|
||||
cooldownMs: 3000,
|
||||
reward: { min: 40, max: 150, currency: "Astral Units" }
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// 4. Mock auth (bypass authentication for testing)
|
||||
mock.module("./routes/auth.routes", () => ({
|
||||
authRoutes: { name: "auth", handler: () => null },
|
||||
isAuthenticated: () => true,
|
||||
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
|
||||
}));
|
||||
|
||||
// 5. Mock BotClient (used by stats helper for maintenanceMode)
|
||||
mock.module("../../bot/lib/BotClient", () => ({
|
||||
AuroraClient: {
|
||||
maintenanceMode: false,
|
||||
guilds: { cache: { get: () => null } },
|
||||
commands: [],
|
||||
knownCommands: new Map(),
|
||||
}
|
||||
}));
|
||||
|
||||
// Import after all mocks are set up
|
||||
import { createWebServer } from "./server";
|
||||
|
||||
describe("WebServer Security & Limits", () => {
|
||||
const port = 3001;
|
||||
@@ -7,10 +7,11 @@
|
||||
* Each route module handles its own validation, business logic, and responses.
|
||||
*/
|
||||
|
||||
import { serve } from "bun";
|
||||
import { serve, file } from "bun";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
import { handleRequest } from "./routes";
|
||||
import { getFullDashboardStats } from "./routes/stats.helper";
|
||||
import { join } from "path";
|
||||
|
||||
export interface WebServerConfig {
|
||||
port?: number;
|
||||
@@ -38,6 +39,54 @@ export interface WebServerInstance {
|
||||
* // To stop the server:
|
||||
* await server.stop();
|
||||
*/
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".html": "text/html",
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
};
|
||||
|
||||
/**
|
||||
* Serve static files from the panel dist directory.
|
||||
* Falls back to index.html for SPA routing.
|
||||
*/
|
||||
async function servePanelStatic(pathname: string, distDir: string): Promise<Response | null> {
|
||||
// Don't serve panel for API/auth/ws/assets routes
|
||||
if (pathname.startsWith("/api/") || pathname.startsWith("/auth/") || pathname === "/ws" || pathname.startsWith("/assets/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to serve the exact file
|
||||
const filePath = join(distDir, pathname);
|
||||
const bunFile = file(filePath);
|
||||
if (await bunFile.exists()) {
|
||||
const ext = pathname.substring(pathname.lastIndexOf("."));
|
||||
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
||||
return new Response(bunFile, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// SPA fallback: serve index.html for all non-file routes
|
||||
const indexFile = file(join(distDir, "index.html"));
|
||||
if (await indexFile.exists()) {
|
||||
return new Response(indexFile, {
|
||||
headers: { "Content-Type": "text/html", "Cache-Control": "no-cache" },
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function createWebServer(config: WebServerConfig = {}): Promise<WebServerInstance> {
|
||||
const { port = 3000, hostname = "localhost" } = config;
|
||||
|
||||
@@ -72,6 +121,11 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
const response = await handleRequest(req, url);
|
||||
if (response) return response;
|
||||
|
||||
// Serve panel static files (production)
|
||||
const panelDistDir = join(import.meta.dir, "../../panel/dist");
|
||||
const staticResponse = await servePanelStatic(url.pathname, panelDistDir);
|
||||
if (staticResponse) return staticResponse;
|
||||
|
||||
// No matching route found
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const moderationCase = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -16,9 +17,9 @@ export const moderationCase = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||
|
||||
// Validate case ID format
|
||||
@@ -30,7 +31,7 @@ export const moderationCase = createCommand({
|
||||
}
|
||||
|
||||
// Get the case
|
||||
const moderationCase = await ModerationService.getCaseById(caseId);
|
||||
const moderationCase = await moderationService.getCaseById(caseId);
|
||||
|
||||
if (!moderationCase) {
|
||||
await interaction.editReply({
|
||||
@@ -43,12 +44,8 @@ export const moderationCase = createCommand({
|
||||
await interaction.editReply({
|
||||
embeds: [getCaseEmbed(moderationCase)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Case command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching the case.")]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const cases = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -22,14 +23,14 @@ export const cases = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
||||
|
||||
// Get cases for the user
|
||||
const userCases = await ModerationService.getUserCases(targetUser.id, activeOnly);
|
||||
const userCases = await moderationService.getUserCases(targetUser.id, activeOnly);
|
||||
|
||||
const title = activeOnly
|
||||
? `⚠️ Active Cases for ${targetUser.username}`
|
||||
@@ -43,12 +44,8 @@ export const cases = createCommand({
|
||||
await interaction.editReply({
|
||||
embeds: [getCasesListEmbed(userCases, title, description)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Cases command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching cases.")]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const clearwarning = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -23,9 +24,9 @@ export const clearwarning = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||
const reason = interaction.options.getString("reason") || "Cleared by moderator";
|
||||
|
||||
@@ -38,7 +39,7 @@ export const clearwarning = createCommand({
|
||||
}
|
||||
|
||||
// Check if case exists and is active
|
||||
const existingCase = await ModerationService.getCaseById(caseId);
|
||||
const existingCase = await moderationService.getCaseById(caseId);
|
||||
|
||||
if (!existingCase) {
|
||||
await interaction.editReply({
|
||||
@@ -62,7 +63,7 @@ export const clearwarning = createCommand({
|
||||
}
|
||||
|
||||
// Clear the warning
|
||||
await ModerationService.clearCase({
|
||||
await moderationService.clearCase({
|
||||
caseId,
|
||||
clearedBy: interaction.user.id,
|
||||
clearedByName: interaction.user.username,
|
||||
@@ -73,12 +74,8 @@ export const clearwarning = createCommand({
|
||||
await interaction.editReply({
|
||||
embeds: [getClearSuccessEmbed(caseId)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Clear warning command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while clearing the warning.")]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
|
||||
import { config, saveConfig } from "@shared/lib/config";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
|
||||
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { items } from "@db/schema";
|
||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const createColor = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -31,8 +33,9 @@ export const createColor = createCommand({
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const name = interaction.options.getString("name", true);
|
||||
const colorInput = interaction.options.getString("color", true);
|
||||
const price = interaction.options.getNumber("price") || 500;
|
||||
@@ -45,11 +48,10 @@ export const createColor = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. 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
|
||||
color: colorInput as any,
|
||||
reason: `Created via /createcolor by ${interaction.user.tag}`
|
||||
});
|
||||
|
||||
@@ -57,11 +59,9 @@ export const createColor = createCommand({
|
||||
throw new Error("Failed to create role.");
|
||||
}
|
||||
|
||||
// 3. Update Config
|
||||
if (!config.colorRoles.includes(role.id)) {
|
||||
config.colorRoles.push(role.id);
|
||||
saveConfig(config);
|
||||
}
|
||||
// 3. Add to guild settings
|
||||
await guildSettingsService.addColorRole(interaction.guildId!, role.id);
|
||||
invalidateGuildConfigCache(interaction.guildId!);
|
||||
|
||||
// 4. Create Item
|
||||
await DrizzleClient.insert(items).values({
|
||||
@@ -85,10 +85,8 @@ export const createColor = createCommand({
|
||||
"✅ 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}`)] });
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { items } from "@db/schema";
|
||||
import { ilike, isNotNull, and, inArray } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { getShopListingMessage } from "@/modules/economy/shop.view";
|
||||
import { EffectType, LootType } from "@shared/lib/constants";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const listing = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -31,8 +31,9 @@ export const listing = createCommand({
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
|
||||
|
||||
@@ -86,17 +87,11 @@ export const listing = createCommand({
|
||||
price: item.price
|
||||
}, context);
|
||||
|
||||
try {
|
||||
await targetChannel.send(listingMessage as any);
|
||||
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error creating listing:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
}
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
},
|
||||
autocomplete: async (interaction) => {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { CaseType } from "@shared/lib/constants";
|
||||
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const note = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -24,14 +25,14 @@ export const note = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const noteText = interaction.options.getString("note", true);
|
||||
|
||||
// Create the note case
|
||||
const moderationCase = await ModerationService.createCase({
|
||||
const moderationCase = await moderationService.createCase({
|
||||
type: CaseType.NOTE,
|
||||
userId: targetUser.id,
|
||||
username: targetUser.username,
|
||||
@@ -51,12 +52,8 @@ export const note = createCommand({
|
||||
await interaction.editReply({
|
||||
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Note command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while adding the note.")]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const notes = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -16,13 +17,13 @@ export const notes = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
|
||||
// Get all notes for the user
|
||||
const userNotes = await ModerationService.getUserNotes(targetUser.id);
|
||||
const userNotes = await moderationService.getUserNotes(targetUser.id);
|
||||
|
||||
// Display the notes
|
||||
await interaction.editReply({
|
||||
@@ -32,12 +33,8 @@ export const notes = createCommand({
|
||||
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Notes command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching notes.")]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { PruneService } from "@shared/modules/moderation/prune.service";
|
||||
import { pruneService } from "@shared/modules/moderation/prune.service";
|
||||
import {
|
||||
getConfirmationMessage,
|
||||
getProgressEmbed,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getPruneWarningEmbed,
|
||||
getCancelledEmbed
|
||||
} from "@/modules/moderation/prune.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const prune = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -38,9 +39,9 @@ export const prune = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const amount = interaction.options.getInteger("amount");
|
||||
const user = interaction.options.getUser("user");
|
||||
const all = interaction.options.getBoolean("all") || false;
|
||||
@@ -66,7 +67,7 @@ export const prune = createCommand({
|
||||
let estimatedCount: number | undefined;
|
||||
if (all) {
|
||||
try {
|
||||
estimatedCount = await PruneService.estimateMessageCount(interaction.channel!);
|
||||
estimatedCount = await pruneService.estimateMessageCount(interaction.channel!);
|
||||
} catch {
|
||||
estimatedCount = undefined;
|
||||
}
|
||||
@@ -97,7 +98,7 @@ export const prune = createCommand({
|
||||
});
|
||||
|
||||
// Execute deletion with progress callback for 'all' mode
|
||||
const result = await PruneService.deleteMessages(
|
||||
const result = await pruneService.deleteMessages(
|
||||
interaction.channel!,
|
||||
{
|
||||
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
||||
@@ -129,7 +130,7 @@ export const prune = createCommand({
|
||||
}
|
||||
} else {
|
||||
// No confirmation needed, proceed directly
|
||||
const result = await PruneService.deleteMessages(
|
||||
const result = await pruneService.deleteMessages(
|
||||
interaction.channel!,
|
||||
{
|
||||
amount: finalAmount as number,
|
||||
@@ -156,24 +157,8 @@ export const prune = createCommand({
|
||||
embeds: [getSuccessEmbed(result)]
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Prune command error:", error);
|
||||
|
||||
let errorMessage = "An unexpected error occurred while trying to delete messages.";
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("permission")) {
|
||||
errorMessage = "I don't have permission to delete messages in this channel.";
|
||||
} else if (error.message.includes("channel type")) {
|
||||
errorMessage = "This command cannot be used in this type of channel.";
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneErrorEmbed(errorMessage)]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { createSuccessEmbed } from "@lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const refresh = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -9,9 +10,9 @@ export const refresh = createCommand({
|
||||
.setDescription("Reloads all commands and config without restarting")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const start = Date.now();
|
||||
await AuroraClient.loadCommands(true);
|
||||
const duration = Date.now() - start;
|
||||
@@ -25,9 +26,8 @@ export const refresh = createCommand({
|
||||
);
|
||||
|
||||
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")] });
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
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 { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
||||
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
||||
import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const terminal = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -23,15 +24,14 @@ export const terminal = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ ephemeral: true, content: "Initializing terminal..." });
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
await terminalService.init(channel as TextChannel);
|
||||
await interaction.editReply({ content: "✅ Terminal initialized!" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await interaction.editReply({ content: "❌ Failed to initialize terminal." });
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import {
|
||||
getWarnSuccessEmbed,
|
||||
getModerationErrorEmbed,
|
||||
getUserWarningEmbed
|
||||
} from "@/modules/moderation/moderation.view";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const warn = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -28,9 +28,9 @@ export const warn = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const reason = interaction.options.getString("reason", true);
|
||||
|
||||
@@ -50,8 +50,11 @@ export const warn = createCommand({
|
||||
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({
|
||||
const { moderationCase, warningCount, autoTimeoutIssued } = await moderationService.issueWarning({
|
||||
userId: targetUser.id,
|
||||
username: targetUser.username,
|
||||
moderatorId: interaction.user.id,
|
||||
@@ -59,7 +62,11 @@ export const warn = createCommand({
|
||||
reason,
|
||||
guildName: interaction.guild?.name || undefined,
|
||||
dmTarget: targetUser,
|
||||
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id)
|
||||
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id),
|
||||
config: {
|
||||
dmOnWarn: guildConfig.moderation?.cases?.dmOnWarn,
|
||||
autoTimeoutThreshold: guildConfig.moderation?.cases?.autoTimeoutThreshold,
|
||||
},
|
||||
});
|
||||
|
||||
// Send success message to moderator
|
||||
@@ -76,12 +83,8 @@ export const warn = createCommand({
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Warn command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while issuing the warning.")]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getWarningsEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const warnings = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -16,24 +17,20 @@ export const warnings = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
|
||||
// Get active warnings for the user
|
||||
const activeWarnings = await ModerationService.getUserWarnings(targetUser.id);
|
||||
const activeWarnings = await moderationService.getUserWarnings(targetUser.id);
|
||||
|
||||
// Display the warnings
|
||||
await interaction.editReply({
|
||||
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Warnings command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching warnings.")]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const webhook = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -14,8 +15,9 @@ export const webhook = createCommand({
|
||||
.setRequired(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const payloadString = interaction.options.getString("payload", true);
|
||||
let payload;
|
||||
|
||||
@@ -37,7 +39,6 @@ export const webhook = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendWebhookMessage(
|
||||
channel,
|
||||
payload,
|
||||
@@ -46,11 +47,8 @@ export const webhook = createCommand({
|
||||
);
|
||||
|
||||
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")]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { createSuccessEmbed } from "@lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const daily = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("daily")
|
||||
.setDescription("Claim your daily reward"),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const result = await economyService.claimDaily(interaction.user.id);
|
||||
|
||||
const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!")
|
||||
@@ -23,14 +24,7 @@ export const daily = createCommand({
|
||||
.setColor("Gold");
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error claiming daily:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
@@ -10,9 +11,9 @@ export const exam = createCommand({
|
||||
.setName("exam")
|
||||
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
// First, try to take the exam or check status
|
||||
const result = await examService.takeExam(interaction.user.id);
|
||||
|
||||
@@ -65,11 +66,7 @@ export const exam = createCommand({
|
||||
"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 { config } from "@shared/lib/config";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const pay = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -50,20 +50,14 @@ export const pay = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await interaction.deferReply();
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
await economyService.transfer(senderId, receiverId.toString(), amount);
|
||||
|
||||
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
|
||||
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error sending payment:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createErrorEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { TriviaCategory } from "@shared/lib/constants";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const trivia = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -53,9 +54,10 @@ export const trivia = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
// User can play - defer publicly for trivia question
|
||||
await interaction.deferReply();
|
||||
|
||||
// User can play - use standardized error handling for the main operation
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
// Start trivia session (deducts entry fee)
|
||||
const session = await triviaService.startTrivia(
|
||||
interaction.user.id,
|
||||
@@ -84,28 +86,18 @@ export const trivia = createCommand({
|
||||
}
|
||||
}
|
||||
}, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period
|
||||
}
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
// Handle errors from the pre-defer canPlayTrivia check
|
||||
if (error instanceof UserError) {
|
||||
// Check if we've already deferred
|
||||
if (interaction.deferred) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(error.message)]
|
||||
});
|
||||
} else {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed(error.message)],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error("Error in trivia command:", error);
|
||||
// Check if we've already deferred
|
||||
if (interaction.deferred) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")]
|
||||
});
|
||||
} else {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
|
||||
ephemeral: true
|
||||
@@ -113,5 +105,4 @@ export const trivia = createCommand({
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
|
||||
|
||||
@@ -9,8 +9,10 @@ export const feedback = createCommand({
|
||||
.setName("feedback")
|
||||
.setDescription("Submit feedback, feature requests, or bug reports"),
|
||||
execute: async (interaction) => {
|
||||
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||
|
||||
// Check if feedback channel is configured
|
||||
if (!config.feedbackChannelId) {
|
||||
if (!guildConfig.feedbackChannelId) {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("Feedback system is not configured. Please contact an administrator.")],
|
||||
ephemeral: true
|
||||
|
||||
@@ -4,9 +4,8 @@ import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
||||
import type { ItemUsageData } from "@shared/lib/types";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
|
||||
export const use = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -19,7 +18,11 @@ export const use = createCommand({
|
||||
.setAutocomplete(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||
const colorRoles = guildConfig.colorRoles ?? [];
|
||||
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
@@ -28,7 +31,6 @@ export const use = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await inventoryService.useItem(user.id.toString(), itemId);
|
||||
|
||||
const usageData = result.usageData;
|
||||
@@ -42,7 +44,7 @@ export const use = createCommand({
|
||||
await member.roles.add(effect.roleId);
|
||||
} else if (effect.type === 'COLOR_ROLE') {
|
||||
// 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);
|
||||
await member.roles.add(effect.roleId);
|
||||
}
|
||||
@@ -58,15 +60,8 @@ export const use = createCommand({
|
||||
const { embed, files } = getItemUseResultEmbed(result.results, result.item);
|
||||
|
||||
await interaction.editReply({ embeds: [embed], files });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error using item:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred while using the item.")] });
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
autocomplete: async (interaction) => {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { Events } from "discord.js";
|
||||
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";
|
||||
|
||||
// Visitor role
|
||||
const event: Event<Events.GuildMemberAdd> = {
|
||||
name: Events.GuildMemberAdd,
|
||||
execute: async (member) => {
|
||||
console.log(`👤 New member joined: ${member.user.tag} (${member.id})`);
|
||||
|
||||
const guildConfig = await getGuildConfig(member.guild.id);
|
||||
|
||||
try {
|
||||
const user = await userService.getUserById(member.id);
|
||||
|
||||
if (user && user.class) {
|
||||
console.log(`🔄 Returning student detected: ${member.user.tag}`);
|
||||
await member.roles.remove(config.visitorRole);
|
||||
await member.roles.add(config.studentRole);
|
||||
if (guildConfig.visitorRole) {
|
||||
await member.roles.remove(guildConfig.visitorRole);
|
||||
}
|
||||
if (guildConfig.studentRole) {
|
||||
await member.roles.add(guildConfig.studentRole);
|
||||
}
|
||||
|
||||
if (user.class.roleId) {
|
||||
await member.roles.add(user.class.roleId);
|
||||
@@ -22,9 +28,11 @@ const event: Event<Events.GuildMemberAdd> = {
|
||||
}
|
||||
console.log(`Restored student role to ${member.user.tag}`);
|
||||
} else {
|
||||
await member.roles.add(config.visitorRole);
|
||||
if (guildConfig.visitorRole) {
|
||||
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(", ")}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to handle role assignment for ${member.user.tag}:`, error);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { env } from "@shared/lib/env";
|
||||
import { join } from "node:path";
|
||||
import { initializeConfig } from "@shared/lib/config";
|
||||
|
||||
import { startWebServerFromRoot } from "../web/src/server";
|
||||
import { startWebServerFromRoot } from "../api/src/server";
|
||||
|
||||
// Initialize config from database
|
||||
await initializeConfig();
|
||||
|
||||
// Load commands & events
|
||||
await AuroraClient.loadCommands();
|
||||
@@ -14,7 +18,7 @@ console.log("🌐 Starting web server...");
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
const webProjectPath = join(import.meta.dir, "../web");
|
||||
const webProjectPath = join(import.meta.dir, "../api");
|
||||
const webPort = Number(process.env.WEB_PORT) || 3000;
|
||||
const webHost = process.env.HOST || "0.0.0.0";
|
||||
|
||||
|
||||
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";
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("./DrizzleClient", () => ({
|
||||
// Mock DrizzleClient — must match the import path used in db.ts
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: {
|
||||
transaction: async (cb: any) => cb("MOCK_TX")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
|
||||
@@ -25,6 +26,37 @@ export class CommandHandler {
|
||||
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
|
||||
try {
|
||||
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Interaction } 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 { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
|
||||
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.");
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
@@ -52,7 +58,7 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
||||
};
|
||||
|
||||
// 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) {
|
||||
throw new UserError("Feedback channel not found. Please contact an administrator.");
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { userTimers } from "@db/schema";
|
||||
import type { EffectHandler } from "./effect.types";
|
||||
import type { EffectHandler, ValidatedEffectPayload } from "./effect.types";
|
||||
import { EffectType } from "@shared/lib/constants";
|
||||
import type { LootTableItem } from "@shared/lib/types";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { inventory, items } from "@db/schema";
|
||||
@@ -15,21 +16,21 @@ const getDuration = (effect: any): number => {
|
||||
return effect.durationSeconds || 60; // Default to 60s if nothing provided
|
||||
};
|
||||
|
||||
export const handleAddXp: EffectHandler = async (userId, effect, txFn) => {
|
||||
export const handleAddXp: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.ADD_XP }>, txFn) => {
|
||||
await levelingService.addXp(userId, BigInt(effect.amount), txFn);
|
||||
return `Gained ${effect.amount} XP`;
|
||||
};
|
||||
|
||||
export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => {
|
||||
export const handleAddBalance: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.ADD_BALANCE }>, txFn) => {
|
||||
await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
|
||||
return `Gained ${effect.amount} 🪙`;
|
||||
};
|
||||
|
||||
export const handleReplyMessage: EffectHandler = async (_userId, effect, _txFn) => {
|
||||
export const handleReplyMessage: EffectHandler = async (_userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.REPLY_MESSAGE }>, _txFn) => {
|
||||
return effect.message;
|
||||
};
|
||||
|
||||
export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
|
||||
export const handleXpBoost: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.XP_BOOST }>, txFn) => {
|
||||
const boostDuration = getDuration(effect);
|
||||
const expiresAt = new Date(Date.now() + boostDuration * 1000);
|
||||
await txFn.insert(userTimers).values({
|
||||
@@ -45,7 +46,7 @@ export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
|
||||
return `XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`;
|
||||
};
|
||||
|
||||
export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
|
||||
export const handleTempRole: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.TEMP_ROLE }>, txFn) => {
|
||||
const roleDuration = getDuration(effect);
|
||||
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
|
||||
await txFn.insert(userTimers).values({
|
||||
@@ -62,11 +63,11 @@ export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
|
||||
return `Temporary Role granted for ${Math.floor(roleDuration / 60)}m`;
|
||||
};
|
||||
|
||||
export const handleColorRole: EffectHandler = async (_userId, _effect, _txFn) => {
|
||||
export const handleColorRole: EffectHandler = async (_userId, _effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.COLOR_ROLE }>, _txFn) => {
|
||||
return "Color Role Equipped";
|
||||
};
|
||||
|
||||
export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
||||
export const handleLootbox: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.LOOTBOX }>, txFn) => {
|
||||
const pool = effect.pool as LootTableItem[];
|
||||
if (!pool || pool.length === 0) return "The box is empty...";
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
handleColorRole,
|
||||
handleLootbox
|
||||
} from "./effect.handlers";
|
||||
import type { EffectHandler } from "./effect.types";
|
||||
import type { EffectHandler, ValidatedEffectPayload } from "./effect.types";
|
||||
import { EffectPayloadSchema } from "./effect.types";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
|
||||
export const effectHandlers: Record<string, EffectHandler> = {
|
||||
'ADD_XP': handleAddXp,
|
||||
@@ -18,3 +21,21 @@ export const effectHandlers: Record<string, EffectHandler> = {
|
||||
'COLOR_ROLE': handleColorRole,
|
||||
'LOOTBOX': handleLootbox
|
||||
};
|
||||
|
||||
export async function validateAndExecuteEffect(
|
||||
effect: unknown,
|
||||
userId: string,
|
||||
tx: Transaction
|
||||
) {
|
||||
const result = EffectPayloadSchema.safeParse(effect);
|
||||
if (!result.success) {
|
||||
throw new UserError(`Invalid effect configuration: ${result.error.message}`);
|
||||
}
|
||||
|
||||
const handler = effectHandlers[result.data.type];
|
||||
if (!handler) {
|
||||
throw new UserError(`Unknown effect type: ${result.data.type}`);
|
||||
}
|
||||
|
||||
return handler(userId, result.data, tx);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,71 @@
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
import { z } from "zod";
|
||||
import { EffectType, LootType } from "@shared/lib/constants";
|
||||
|
||||
// Helper Schemas
|
||||
const LootTableItemSchema = z.object({
|
||||
type: z.nativeEnum(LootType),
|
||||
weight: z.number(),
|
||||
amount: z.number().optional(),
|
||||
itemId: z.number().optional(),
|
||||
minAmount: z.number().optional(),
|
||||
maxAmount: z.number().optional(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
const DurationSchema = z.object({
|
||||
durationSeconds: z.number().optional(),
|
||||
durationMinutes: z.number().optional(),
|
||||
durationHours: z.number().optional(),
|
||||
});
|
||||
|
||||
// Effect Schemas
|
||||
const AddXpSchema = z.object({
|
||||
type: z.literal(EffectType.ADD_XP),
|
||||
amount: z.number().positive(),
|
||||
});
|
||||
|
||||
const AddBalanceSchema = z.object({
|
||||
type: z.literal(EffectType.ADD_BALANCE),
|
||||
amount: z.number(),
|
||||
});
|
||||
|
||||
const ReplyMessageSchema = z.object({
|
||||
type: z.literal(EffectType.REPLY_MESSAGE),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
const XpBoostSchema = DurationSchema.extend({
|
||||
type: z.literal(EffectType.XP_BOOST),
|
||||
multiplier: z.number(),
|
||||
});
|
||||
|
||||
const TempRoleSchema = DurationSchema.extend({
|
||||
type: z.literal(EffectType.TEMP_ROLE),
|
||||
roleId: z.string(),
|
||||
});
|
||||
|
||||
const ColorRoleSchema = z.object({
|
||||
type: z.literal(EffectType.COLOR_ROLE),
|
||||
roleId: z.string(),
|
||||
});
|
||||
|
||||
const LootboxSchema = z.object({
|
||||
type: z.literal(EffectType.LOOTBOX),
|
||||
pool: z.array(LootTableItemSchema),
|
||||
});
|
||||
|
||||
// Union Schema
|
||||
export const EffectPayloadSchema = z.discriminatedUnion('type', [
|
||||
AddXpSchema,
|
||||
AddBalanceSchema,
|
||||
ReplyMessageSchema,
|
||||
XpBoostSchema,
|
||||
TempRoleSchema,
|
||||
ColorRoleSchema,
|
||||
LootboxSchema,
|
||||
]);
|
||||
|
||||
export type ValidatedEffectPayload = z.infer<typeof EffectPayloadSchema>;
|
||||
|
||||
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<any>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { temporaryRoleService } from "@shared/modules/system/temp-role.service";
|
||||
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
||||
|
||||
export const schedulerService = {
|
||||
start: () => {
|
||||
@@ -10,7 +11,6 @@ export const schedulerService = {
|
||||
}, 60 * 1000);
|
||||
|
||||
// 2. Terminal Update Loop (every 60s)
|
||||
const { terminalService } = require("@shared/modules/terminal/terminal.service");
|
||||
setInterval(() => {
|
||||
terminalService.update();
|
||||
}, 60 * 1000);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { classService } from "@shared/modules/class/class.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.");
|
||||
}
|
||||
|
||||
const { studentRole, visitorRole } = config;
|
||||
const guildConfig = await getGuildConfig(interaction.guildId);
|
||||
const { studentRole, visitorRole, welcomeChannelId, welcomeMessage } = guildConfig;
|
||||
|
||||
if (!studentRole || !visitorRole) {
|
||||
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)
|
||||
if (config.welcomeChannelId) {
|
||||
const welcomeChannel = interaction.guild.channels.cache.get(config.welcomeChannelId);
|
||||
if (welcomeChannelId) {
|
||||
const welcomeChannel = interaction.guild.channels.cache.get(welcomeChannelId);
|
||||
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
|
||||
.replace(/{user}/g, member.toString())
|
||||
|
||||
345
bun.lock
345
bun.lock
@@ -20,8 +20,71 @@
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
"panel": {
|
||||
"name": "panel",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@imgly/background-removal": "^1.7.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.564.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.3.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@discordjs/builders": ["@discordjs/builders@1.13.0", "", { "dependencies": { "@discordjs/formatters": "^0.6.1", "@discordjs/util": "^1.1.1", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.31", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q=="],
|
||||
|
||||
"@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
|
||||
@@ -92,6 +155,18 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@imgly/background-removal": ["@imgly/background-removal@1.7.0", "", { "dependencies": { "lodash-es": "^4.17.21", "ndarray": "~1.0.0", "zod": "^3.23.8" }, "peerDependencies": { "onnxruntime-web": "1.21.0" } }, "sha512-/1ZryrMYg2ckIvJKoTu5Np50JfYMVffDMlVmppw/BdbN3pBTN7e6stI5/7E/LVh9DDzz6J588s7sWqul3fy5wA=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.89", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.89", "@napi-rs/canvas-darwin-arm64": "0.1.89", "@napi-rs/canvas-darwin-x64": "0.1.89", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.89", "@napi-rs/canvas-linux-arm64-gnu": "0.1.89", "@napi-rs/canvas-linux-arm64-musl": "0.1.89", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-musl": "0.1.89", "@napi-rs/canvas-win32-arm64-msvc": "0.1.89", "@napi-rs/canvas-win32-x64-msvc": "0.1.89" } }, "sha512-7GjmkMirJHejeALCqUnZY3QwID7bbumOiLrqq2LKgxrdjdmxWQBTc6rcASa2u8wuWrH7qo4/4n/VNrOwCoKlKg=="],
|
||||
|
||||
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.89", "", { "os": "android", "cpu": "arm64" }, "sha512-CXxQTXsjtQqKGENS8Ejv9pZOFJhOPIl2goenS+aU8dY4DygvkyagDhy/I07D1YLqrDtPvLEX5zZHt8qUdnuIpQ=="],
|
||||
@@ -116,26 +191,162 @@
|
||||
|
||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="],
|
||||
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
|
||||
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||
|
||||
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
|
||||
|
||||
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
|
||||
|
||||
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
|
||||
|
||||
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
||||
|
||||
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
|
||||
|
||||
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
||||
|
||||
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
|
||||
|
||||
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
|
||||
|
||||
"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
|
||||
|
||||
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
|
||||
|
||||
"autoprefixer": ["autoprefixer@10.4.24", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001766", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"discord-api-types": ["discord-api-types@0.38.34", "", {}, "sha512-muq7xKGznj5MSFCzuIm/2TO7DpttuomUTemVM82fRqgnMl70YRzEyY24jlbiV6R9tzOTq6A6UnZ0bsfZeKD38Q=="],
|
||||
|
||||
"discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="],
|
||||
@@ -146,30 +357,144 @@
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="],
|
||||
|
||||
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="],
|
||||
|
||||
"iota-array": ["iota-array@1.0.0", "", {}, "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA=="],
|
||||
|
||||
"is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
|
||||
|
||||
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
|
||||
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="],
|
||||
|
||||
"magic-bytes.js": ["magic-bytes.js@1.12.1", "", {}, "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"ndarray": ["ndarray@1.0.19", "", { "dependencies": { "iota-array": "^1.0.0", "is-buffer": "^1.0.2" } }, "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||
|
||||
"onnxruntime-common": ["onnxruntime-common@1.21.0", "", {}, "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ=="],
|
||||
|
||||
"onnxruntime-web": ["onnxruntime-web@1.21.0", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.21.0", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-adzOe+7uI7lKz6pQNbAsLMQd2Fq5Jhmoxd8LZjJr8m3KvbFyiYyRxRiC57/XXD+jb18voppjeGAjoZmskXG+7A=="],
|
||||
|
||||
"panel": ["panel@workspace:panel"],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
||||
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||
|
||||
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
|
||||
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
@@ -180,8 +505,14 @@
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
@@ -190,6 +521,20 @@
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"@imgly/background-removal/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||
|
||||
@@ -34,9 +34,11 @@ services:
|
||||
restart: unless-stopped
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.prod
|
||||
dockerfile: Dockerfile
|
||||
target: production
|
||||
image: aurora-app:latest
|
||||
volumes:
|
||||
- ./bot/assets/graphics/items:/app/bot/assets/graphics/items
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
|
||||
@@ -53,6 +55,10 @@ services:
|
||||
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
|
||||
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
||||
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
|
||||
- SESSION_SECRET=${SESSION_SECRET}
|
||||
- ADMIN_USER_IDS=${ADMIN_USER_IDS}
|
||||
- PANEL_BASE_URL=${PANEL_BASE_URL:-https://aurora.syntaxbullet.com}
|
||||
|
||||
depends_on:
|
||||
db:
|
||||
@@ -76,6 +82,8 @@ services:
|
||||
studio:
|
||||
container_name: aurora_studio
|
||||
image: aurora-app:latest
|
||||
volumes:
|
||||
- ./bot/assets/graphics/items:/app/bot/assets/graphics/items
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
@@ -90,6 +98,10 @@ services:
|
||||
- DB_PORT=5432
|
||||
- DB_HOST=db
|
||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
||||
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
|
||||
- SESSION_SECRET=${SESSION_SECRET}
|
||||
- ADMIN_USER_IDS=${ADMIN_USER_IDS}
|
||||
- PANEL_BASE_URL=${PANEL_BASE_URL:-https://aurora.syntaxbullet.com}
|
||||
networks:
|
||||
- internal
|
||||
- web
|
||||
|
||||
@@ -47,6 +47,10 @@ services:
|
||||
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
|
||||
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
||||
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
|
||||
- SESSION_SECRET=${SESSION_SECRET}
|
||||
- ADMIN_USER_IDS=${ADMIN_USER_IDS}
|
||||
- PANEL_BASE_URL=${PANEL_BASE_URL:-http://localhost:3000}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
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 |
|
||||
26
package.json
26
package.json
@@ -4,6 +4,7 @@
|
||||
"module": "bot/index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"workspaces": ["panel"],
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"drizzle-kit": "^0.31.8"
|
||||
@@ -12,16 +13,29 @@
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "docker compose run --rm app drizzle-kit generate",
|
||||
"migrate": "docker compose run --rm app drizzle-kit migrate",
|
||||
"dev": "bun --watch bot/index.ts",
|
||||
"logs": "bash shared/scripts/logs.sh",
|
||||
"remote": "bash shared/scripts/remote.sh",
|
||||
"db:generate": "docker compose run --rm app drizzle-kit generate",
|
||||
"db:migrate": "docker compose run --rm app drizzle-kit migrate",
|
||||
"generate": "bun run db:generate",
|
||||
"migrate": "bun run db:migrate",
|
||||
"db:push": "docker compose run --rm app drizzle-kit push",
|
||||
"db:push:local": "drizzle-kit push",
|
||||
"dev": "bun --watch bot/index.ts",
|
||||
"db:studio": "drizzle-kit studio --port 4983 --host 0.0.0.0",
|
||||
"remote": "bash shared/scripts/remote.sh",
|
||||
"logs": "bash shared/scripts/logs.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"
|
||||
},
|
||||
"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,
|
||||
};
|
||||
}
|
||||
6
panel/src/lib/utils.ts
Normal file
6
panel/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
10
panel/src/main.tsx
Normal file
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>
|
||||
);
|
||||
881
panel/src/pages/BackgroundRemoval.tsx
Normal file
881
panel/src/pages/BackgroundRemoval.tsx
Normal file
@@ -0,0 +1,881 @@
|
||||
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;
|
||||
vUV.y = 1.0 - vUV.y;
|
||||
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.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
1588
panel/src/pages/ItemStudio.tsx
Normal file
1588
panel/src/pages/ItemStudio.tsx
Normal file
File diff suppressed because it is too large
Load Diff
555
panel/src/pages/Items.tsx
Normal file
555
panel/src/pages/Items.tsx
Normal file
@@ -0,0 +1,555 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Search,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Package,
|
||||
AlertTriangle,
|
||||
Sparkles,
|
||||
Scissors,
|
||||
Crop,
|
||||
Palette,
|
||||
Maximize2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useItems, type Item } from "../lib/useItems";
|
||||
import { ItemStudio } from "./ItemStudio";
|
||||
import { BackgroundRemoval } from "./BackgroundRemoval";
|
||||
import { CropTool } from "./CropTool";
|
||||
import { HueShifter } from "./HueShifter";
|
||||
import { CanvasTool } from "./CanvasTool";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatNumber(num: number | string): string {
|
||||
const n = typeof num === "string" ? parseInt(num) : num;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
const RARITY_COLORS: Record<string, string> = {
|
||||
C: "bg-gray-500/20 text-gray-400",
|
||||
R: "bg-blue-500/20 text-blue-400",
|
||||
SR: "bg-purple-500/20 text-purple-400",
|
||||
SSR: "bg-amber-500/20 text-amber-400",
|
||||
};
|
||||
|
||||
type Tab = "all" | "studio" | "bgremoval" | "crop" | "hue" | "canvas";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SearchFilterBar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SearchFilterBar({
|
||||
search,
|
||||
onSearchChange,
|
||||
type,
|
||||
onTypeChange,
|
||||
rarity,
|
||||
onRarityChange,
|
||||
onClear,
|
||||
}: {
|
||||
search: string;
|
||||
onSearchChange: (v: string) => void;
|
||||
type: string | null;
|
||||
onTypeChange: (v: string | null) => void;
|
||||
rarity: string | null;
|
||||
onRarityChange: (v: string | null) => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Search items..."
|
||||
className={cn(
|
||||
"w-full bg-input border border-border rounded-md pl-10 pr-3 py-2 text-sm text-foreground",
|
||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||
"transition-colors"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={type ?? ""}
|
||||
onChange={(e) => onTypeChange(e.target.value || null)}
|
||||
className={cn(
|
||||
"bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||
"transition-colors"
|
||||
)}
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="MATERIAL">Material</option>
|
||||
<option value="CONSUMABLE">Consumable</option>
|
||||
<option value="EQUIPMENT">Equipment</option>
|
||||
<option value="QUEST">Quest</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={rarity ?? ""}
|
||||
onChange={(e) => onRarityChange(e.target.value || null)}
|
||||
className={cn(
|
||||
"bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||
"transition-colors"
|
||||
)}
|
||||
>
|
||||
<option value="">All Rarities</option>
|
||||
<option value="C">Common (C)</option>
|
||||
<option value="R">Rare (R)</option>
|
||||
<option value="SR">Super Rare (SR)</option>
|
||||
<option value="SSR">SSR</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={onClear}
|
||||
className={cn(
|
||||
"bg-input border border-border rounded-md px-3 py-2 text-sm text-text-secondary",
|
||||
"hover:bg-destructive hover:text-white hover:border-destructive transition-colors"
|
||||
)}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ItemTable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ItemTable({
|
||||
items,
|
||||
loading,
|
||||
}: {
|
||||
items: Item[];
|
||||
loading: boolean;
|
||||
}) {
|
||||
const columns = ["ID", "Icon", "Name", "Type", "Rarity", "Price", "Description"];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-raised border-b border-border">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={col} className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<tr key={i} className="border-b border-border">
|
||||
{columns.map((col) => (
|
||||
<td key={col} className="px-4 py-3">
|
||||
<div className="h-4 bg-raised rounded animate-pulse w-20"></div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-12 text-center">
|
||||
<Package className="w-16 h-16 mx-auto mb-4 text-text-tertiary" />
|
||||
<p className="text-lg font-semibold text-text-secondary mb-2">
|
||||
No items found
|
||||
</p>
|
||||
<p className="text-sm text-text-tertiary">
|
||||
Try adjusting your search or filter criteria
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-raised border-b border-border">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={col} className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-b border-border hover:bg-raised transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm font-mono text-text-tertiary">
|
||||
{item.id}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<img
|
||||
src={item.iconUrl}
|
||||
alt={item.name}
|
||||
className="w-8 h-8 rounded object-cover bg-raised"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{item.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-foreground capitalize">
|
||||
{item.type.toLowerCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center px-2 py-1 rounded-full text-xs font-medium",
|
||||
RARITY_COLORS[item.rarity] ?? "bg-gray-500/20 text-gray-400"
|
||||
)}
|
||||
>
|
||||
{item.rarity}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm font-mono text-foreground">
|
||||
{item.price ? formatNumber(item.price) : "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 max-w-[200px]">
|
||||
<span className="text-sm text-text-secondary truncate block">
|
||||
{item.description || "—"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pagination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
limit,
|
||||
total,
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
}: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onLimitChange: (limit: number) => void;
|
||||
}) {
|
||||
const startItem = (currentPage - 1) * limit + 1;
|
||||
const endItem = Math.min(currentPage * limit, total);
|
||||
|
||||
const getPageNumbers = () => {
|
||||
const pages: (number | string)[] = [];
|
||||
const showPages = 5;
|
||||
const halfShow = Math.floor(showPages / 2);
|
||||
|
||||
let start = Math.max(1, currentPage - halfShow);
|
||||
const end = Math.min(totalPages, start + showPages - 1);
|
||||
|
||||
if (end - start < showPages - 1) {
|
||||
start = Math.max(1, end - showPages + 1);
|
||||
}
|
||||
|
||||
if (start > 1) {
|
||||
pages.push(1);
|
||||
if (start > 2) pages.push("...");
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (end < totalPages) {
|
||||
if (end < totalPages - 1) pages.push("...");
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex flex-wrap gap-4 items-center justify-between">
|
||||
<p className="text-sm text-text-secondary">
|
||||
Showing {startItem}–{endItem} of {formatNumber(total)} items
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className={cn(
|
||||
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||
currentPage === 1
|
||||
? "bg-raised text-text-tertiary cursor-not-allowed"
|
||||
: "bg-input border border-border text-foreground hover:bg-raised"
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{getPageNumbers().map((page, i) =>
|
||||
typeof page === "number" ? (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onPageChange(page)}
|
||||
className={cn(
|
||||
"px-3 py-2 rounded-md text-sm font-medium transition-colors min-w-[40px]",
|
||||
page === currentPage
|
||||
? "bg-primary text-white"
|
||||
: "bg-input border border-border text-foreground hover:bg-raised"
|
||||
)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
) : (
|
||||
<span key={i} className="px-2 text-text-tertiary">
|
||||
{page}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className={cn(
|
||||
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||
currentPage === totalPages
|
||||
? "bg-raised text-text-tertiary cursor-not-allowed"
|
||||
: "bg-input border border-border text-foreground hover:bg-raised"
|
||||
)}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<select
|
||||
value={limit}
|
||||
onChange={(e) => onLimitChange(Number(e.target.value))}
|
||||
className={cn(
|
||||
"ml-2 bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||
"transition-colors"
|
||||
)}
|
||||
>
|
||||
<option value="10">10 / page</option>
|
||||
<option value="25">25 / page</option>
|
||||
<option value="50">50 / page</option>
|
||||
<option value="100">100 / page</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Items Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function Items() {
|
||||
const {
|
||||
items,
|
||||
total,
|
||||
currentPage,
|
||||
limit,
|
||||
setLimit,
|
||||
filters,
|
||||
setFilters,
|
||||
setSearchDebounced,
|
||||
setPage,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
} = useItems();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>("all");
|
||||
const [searchInput, setSearchInput] = useState(filters.search);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchInput(filters.search);
|
||||
}, [filters.search]);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchInput(value);
|
||||
setSearchDebounced(value);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearchInput("");
|
||||
setFilters({ search: "", type: null, rarity: null });
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<header className="border-b border-border p-6 space-y-4">
|
||||
<h1 className="text-2xl font-bold text-foreground">Items</h1>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-border -mb-4 pb-px">
|
||||
<button
|
||||
onClick={() => setActiveTab("all")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
|
||||
activeTab === "all"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
|
||||
)}
|
||||
>
|
||||
<Package className="w-4 h-4" />
|
||||
All Items
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("studio")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
|
||||
activeTab === "studio"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
|
||||
)}
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Item Studio
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("bgremoval")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
|
||||
activeTab === "bgremoval"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
|
||||
)}
|
||||
>
|
||||
<Scissors className="w-4 h-4" />
|
||||
Background Removal
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("crop")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
|
||||
activeTab === "crop"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
|
||||
)}
|
||||
>
|
||||
<Crop className="w-4 h-4" />
|
||||
Crop
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("hue")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
|
||||
activeTab === "hue"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
|
||||
)}
|
||||
>
|
||||
<Palette className="w-4 h-4" />
|
||||
Hue Shifter
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("canvas")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
|
||||
activeTab === "canvas"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
|
||||
)}
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
Canvas
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="mx-6 mt-4 bg-destructive/10 border border-destructive/30 rounded-lg p-4 flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-destructive">Error</p>
|
||||
<p className="text-sm text-destructive/90">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{activeTab === "all" ? (
|
||||
<div className="space-y-4">
|
||||
<SearchFilterBar
|
||||
search={searchInput}
|
||||
onSearchChange={handleSearchChange}
|
||||
type={filters.type}
|
||||
onTypeChange={(v) => setFilters({ type: v })}
|
||||
rarity={filters.rarity}
|
||||
onRarityChange={(v) => setFilters({ rarity: v })}
|
||||
onClear={handleClearFilters}
|
||||
/>
|
||||
<ItemTable items={items} loading={loading} />
|
||||
{!loading && items.length > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
limit={limit}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
onLimitChange={setLimit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : activeTab === "studio" ? (
|
||||
<ItemStudio
|
||||
onSuccess={() => {
|
||||
refetch();
|
||||
setActiveTab("all");
|
||||
}}
|
||||
/>
|
||||
) : activeTab === "bgremoval" ? (
|
||||
<BackgroundRemoval />
|
||||
) : activeTab === "crop" ? (
|
||||
<CropTool />
|
||||
) : activeTab === "hue" ? (
|
||||
<HueShifter />
|
||||
) : (
|
||||
<CanvasTool />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
panel/src/pages/PlaceholderPage.tsx
Normal file
17
panel/src/pages/PlaceholderPage.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Construction } from "lucide-react";
|
||||
|
||||
export default function PlaceholderPage({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-32 text-center">
|
||||
<Construction className="w-10 h-10 text-text-tertiary mb-4" />
|
||||
<h1 className="font-display text-2xl font-bold mb-2">{title}</h1>
|
||||
<p className="text-sm text-text-tertiary max-w-md">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1411
panel/src/pages/Settings.tsx
Normal file
1411
panel/src/pages/Settings.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1062
panel/src/pages/Users.tsx
Normal file
1062
panel/src/pages/Users.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