Compare commits
29 Commits
2d35a5eabb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10c84a8478 | ||
|
|
9eba64621a | ||
|
|
7cc2f61db6 | ||
|
|
f5fecb59cb | ||
|
|
65f5663c97 | ||
| de83307adc | |||
|
|
15e01906a3 | ||
|
|
fed27c0227 | ||
|
|
9751e62e30 | ||
|
|
87d5aa259c | ||
|
|
f0bfaecb0b | ||
|
|
9471b6fdab | ||
|
|
04e5851387 | ||
| 1a59c9e796 | |||
|
|
251616fe15 | ||
|
|
fbb2e0f010 | ||
|
|
dc10ad5c37 | ||
|
|
2381f073ba | ||
|
|
121c242168 | ||
|
|
942875e8d0 | ||
|
|
878e3306eb | ||
|
|
aca5538d57 | ||
|
|
f822d90dd3 | ||
|
|
141c3098f8 | ||
|
|
0c67a8754f | ||
|
|
bf20c61190 | ||
|
|
099601ce6d | ||
|
|
55d2376ca1 | ||
|
|
6eb4a32a12 |
7
.citrine
Normal file
7
.citrine
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
### Frontend
|
||||||
|
[8bb0] [>] implement items page
|
||||||
|
[de51] [ ] implement classes page
|
||||||
|
[d108] [ ] implement quests page
|
||||||
|
[8bbe] [ ] implement lootdrops page
|
||||||
|
[094e] [ ] implement moderation page
|
||||||
|
[220d] [ ] implement transactions page
|
||||||
@@ -20,6 +20,13 @@ DISCORD_BOT_TOKEN=your-discord-bot-token
|
|||||||
DISCORD_CLIENT_ID=your-discord-client-id
|
DISCORD_CLIENT_ID=your-discord-client-id
|
||||||
DISCORD_GUILD_ID=your-discord-guild-id
|
DISCORD_GUILD_ID=your-discord-guild-id
|
||||||
|
|
||||||
|
# Admin Panel (Discord OAuth)
|
||||||
|
# Get client secret from: https://discord.com/developers/applications → OAuth2
|
||||||
|
DISCORD_CLIENT_SECRET=your-discord-client-secret
|
||||||
|
SESSION_SECRET=change-me-to-a-random-string
|
||||||
|
ADMIN_USER_IDS=123456789012345678
|
||||||
|
PANEL_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
# Server (for remote access scripts)
|
# Server (for remote access scripts)
|
||||||
# Use a non-root user (see shared/scripts/setup-server.sh)
|
# Use a non-root user (see shared/scripts/setup-server.sh)
|
||||||
VPS_USER=deploy
|
VPS_USER=deploy
|
||||||
|
|||||||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -95,6 +95,6 @@ jobs:
|
|||||||
ADMIN_TOKEN="admin_token_123"
|
ADMIN_TOKEN="admin_token_123"
|
||||||
LOG_LEVEL="error"
|
LOG_LEVEL="error"
|
||||||
EOF
|
EOF
|
||||||
bash shared/scripts/test-sequential.sh
|
bash shared/scripts/test-sequential.sh --integration
|
||||||
env:
|
env:
|
||||||
NODE_ENV: test
|
NODE_ENV: test
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,6 +3,7 @@ node_modules
|
|||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
shared/db-logs
|
shared/db-logs
|
||||||
shared/db/data
|
shared/db/data
|
||||||
|
shared/db/backups
|
||||||
shared/db/loga
|
shared/db/loga
|
||||||
.cursor
|
.cursor
|
||||||
# dependencies (bun install)
|
# dependencies (bun install)
|
||||||
@@ -46,5 +47,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
src/db/data
|
src/db/data
|
||||||
src/db/log
|
src/db/log
|
||||||
scratchpad/
|
scratchpad/
|
||||||
tickets/
|
|
||||||
bot/assets/graphics/items
|
bot/assets/graphics/items
|
||||||
|
tickets/
|
||||||
|
.citrine.local
|
||||||
|
|||||||
43
AGENTS.md
43
AGENTS.md
@@ -141,22 +141,36 @@ throw new UserError("You don't have enough coins!");
|
|||||||
throw new SystemError("Database connection failed");
|
throw new SystemError("Database connection failed");
|
||||||
```
|
```
|
||||||
|
|
||||||
### Standard Error Pattern
|
### Recommended: `withCommandErrorHandling`
|
||||||
|
|
||||||
|
Use the `withCommandErrorHandling` utility from `@lib/commandUtils` to standardize
|
||||||
|
error handling across all commands. It handles `deferReply`, `UserError` display,
|
||||||
|
and unexpected error logging automatically.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
try {
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
const result = await service.method();
|
|
||||||
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
export const myCommand = createCommand({
|
||||||
} catch (error) {
|
data: new SlashCommandBuilder()
|
||||||
if (error instanceof UserError) {
|
.setName("mycommand")
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
.setDescription("Does something"),
|
||||||
} else {
|
execute: async (interaction) => {
|
||||||
console.error("Unexpected error:", error);
|
await withCommandErrorHandling(
|
||||||
await interaction.editReply({
|
interaction,
|
||||||
embeds: [createErrorEmbed("An unexpected error occurred.")],
|
async () => {
|
||||||
});
|
const result = await service.method();
|
||||||
}
|
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
||||||
}
|
},
|
||||||
|
{ ephemeral: true } // optional: makes the deferred reply ephemeral
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `ephemeral` — whether `deferReply` should be ephemeral
|
||||||
|
- `successMessage` — a simple string to send on success
|
||||||
|
- `onSuccess` — a callback invoked with the operation result
|
||||||
```
|
```
|
||||||
|
|
||||||
## Database Patterns
|
## Database Patterns
|
||||||
@@ -240,3 +254,4 @@ describe("serviceName", () => {
|
|||||||
| Environment | `shared/lib/env.ts` |
|
| Environment | `shared/lib/env.ts` |
|
||||||
| Embed helpers | `bot/lib/embeds.ts` |
|
| Embed helpers | `bot/lib/embeds.ts` |
|
||||||
| Command utils | `shared/lib/utils.ts` |
|
| Command utils | `shared/lib/utils.ts` |
|
||||||
|
| Error handler | `bot/lib/commandUtils.ts` |
|
||||||
|
|||||||
187
CLAUDE.md
Normal file
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 only package files first (better layer caching)
|
||||||
COPY package.json bun.lock ./
|
COPY package.json bun.lock ./
|
||||||
|
COPY panel/package.json panel/
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
@@ -33,3 +34,44 @@ EXPOSE 3000
|
|||||||
|
|
||||||
# Default command
|
# Default command
|
||||||
CMD ["bun", "run", "dev"]
|
CMD ["bun", "run", "dev"]
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Builder stage - copies source for production
|
||||||
|
# ============================================
|
||||||
|
FROM base AS builder
|
||||||
|
|
||||||
|
# Copy source code first, then deps on top (so node_modules aren't overwritten)
|
||||||
|
COPY . .
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Install panel deps and build
|
||||||
|
RUN cd panel && bun install --frozen-lockfile && bun run build
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Production stage - minimal runtime image
|
||||||
|
# ============================================
|
||||||
|
FROM oven/bun:latest AS production
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy only what's needed for production
|
||||||
|
COPY --from=builder --chown=bun:bun /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=bun:bun /app/api/src ./api/src
|
||||||
|
COPY --from=builder --chown=bun:bun /app/bot ./bot
|
||||||
|
COPY --from=builder --chown=bun:bun /app/shared ./shared
|
||||||
|
COPY --from=builder --chown=bun:bun /app/panel/dist ./panel/dist
|
||||||
|
COPY --from=builder --chown=bun:bun /app/package.json .
|
||||||
|
COPY --from=builder --chown=bun:bun /app/drizzle.config.ts .
|
||||||
|
COPY --from=builder --chown=bun:bun /app/tsconfig.json .
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER bun
|
||||||
|
|
||||||
|
# Expose web dashboard port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
|
||||||
|
|
||||||
|
# Run in production mode
|
||||||
|
CMD ["bun", "run", "bot/index.ts"]
|
||||||
|
|||||||
@@ -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.
|
Aurora is a powerful Discord bot designed to facilitate RPG-like elements within a Discord server. It features a robust economy, class system, inventory management, quests, and more, all built on top of a high-performance stack using Bun and Drizzle ORM.
|
||||||
|
|
||||||
**New in v1.0:** Aurora now includes a fully integrated **REST API** for accessing bot data, statistics, and configuration, running alongside the bot in a single process.
|
**New in v1.0:** Aurora now includes a fully integrated **REST API** for accessing bot data, statistics, and configuration, running alongside the bot in a single process.
|
||||||
|
|||||||
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
@@ -66,7 +66,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
return new Response(file, {
|
return new Response(file, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": contentType,
|
"Content-Type": contentType,
|
||||||
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
|
"Cache-Control": "no-cache",
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
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,
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { RouteContext, RouteModule } from "./types";
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import { authRoutes, isAuthenticated } from "./auth.routes";
|
||||||
import { healthRoutes } from "./health.routes";
|
import { healthRoutes } from "./health.routes";
|
||||||
import { statsRoutes } from "./stats.routes";
|
import { statsRoutes } from "./stats.routes";
|
||||||
import { actionsRoutes } from "./actions.routes";
|
import { actionsRoutes } from "./actions.routes";
|
||||||
@@ -17,13 +18,16 @@ import { moderationRoutes } from "./moderation.routes";
|
|||||||
import { transactionsRoutes } from "./transactions.routes";
|
import { transactionsRoutes } from "./transactions.routes";
|
||||||
import { lootdropsRoutes } from "./lootdrops.routes";
|
import { lootdropsRoutes } from "./lootdrops.routes";
|
||||||
import { assetsRoutes } from "./assets.routes";
|
import { assetsRoutes } from "./assets.routes";
|
||||||
|
import { errorResponse } from "./utils";
|
||||||
|
|
||||||
/**
|
/** Routes that do NOT require authentication */
|
||||||
* All registered route modules in order of precedence.
|
const publicRoutes: RouteModule[] = [
|
||||||
* Routes are checked in order; the first matching route wins.
|
authRoutes,
|
||||||
*/
|
|
||||||
const routeModules: RouteModule[] = [
|
|
||||||
healthRoutes,
|
healthRoutes,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Routes that require an authenticated admin session */
|
||||||
|
const protectedRoutes: RouteModule[] = [
|
||||||
statsRoutes,
|
statsRoutes,
|
||||||
actionsRoutes,
|
actionsRoutes,
|
||||||
questsRoutes,
|
questsRoutes,
|
||||||
@@ -58,14 +62,25 @@ export async function handleRequest(req: Request, url: URL): Promise<Response |
|
|||||||
pathname: url.pathname,
|
pathname: url.pathname,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try each route module in order
|
// Try public routes first (auth, health)
|
||||||
for (const module of routeModules) {
|
for (const module of publicRoutes) {
|
||||||
const response = await module.handler(ctx);
|
const response = await module.handler(ctx);
|
||||||
if (response !== null) {
|
if (response !== null) return response;
|
||||||
return response;
|
}
|
||||||
|
|
||||||
|
// For API routes, enforce authentication
|
||||||
|
if (ctx.pathname.startsWith("/api/")) {
|
||||||
|
if (!isAuthenticated(req)) {
|
||||||
|
return errorResponse("Unauthorized", 401);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try protected routes
|
||||||
|
for (const module of protectedRoutes) {
|
||||||
|
const response = await module.handler(ctx);
|
||||||
|
if (response !== null) return response;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,5 +89,5 @@ export async function handleRequest(req: Request, url: URL): Promise<Response |
|
|||||||
* Useful for debugging and documentation.
|
* Useful for debugging and documentation.
|
||||||
*/
|
*/
|
||||||
export function getRegisteredRoutes(): string[] {
|
export function getRegisteredRoutes(): string[] {
|
||||||
return routeModules.map(m => m.name);
|
return [...publicRoutes, ...protectedRoutes].map(m => m.name);
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { join, resolve, dirname } from "path";
|
import { join, resolve, dirname } from "path";
|
||||||
import type { RouteContext, RouteModule } from "./types";
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import type { CreateItemDTO, UpdateItemDTO } from "@shared/modules/items/items.service";
|
||||||
import {
|
import {
|
||||||
jsonResponse,
|
jsonResponse,
|
||||||
errorResponse,
|
errorResponse,
|
||||||
@@ -121,7 +122,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
return withErrorHandling(async () => {
|
return withErrorHandling(async () => {
|
||||||
const contentType = req.headers.get("content-type") || "";
|
const contentType = req.headers.get("content-type") || "";
|
||||||
|
|
||||||
let itemData: any;
|
let itemData: CreateItemDTO | null = null;
|
||||||
let imageFile: File | null = null;
|
let imageFile: File | null = null;
|
||||||
|
|
||||||
if (contentType.includes("multipart/form-data")) {
|
if (contentType.includes("multipart/form-data")) {
|
||||||
@@ -130,12 +131,16 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
imageFile = formData.get("image") as File | null;
|
imageFile = formData.get("image") as File | null;
|
||||||
|
|
||||||
if (typeof jsonData === "string") {
|
if (typeof jsonData === "string") {
|
||||||
itemData = JSON.parse(jsonData);
|
itemData = JSON.parse(jsonData) as CreateItemDTO;
|
||||||
} else {
|
} else {
|
||||||
return errorResponse("Missing item data", 400);
|
return errorResponse("Missing item data", 400);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
itemData = await req.json();
|
itemData = await req.json() as CreateItemDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!itemData) {
|
||||||
|
return errorResponse("Missing item data", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
@@ -183,7 +188,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
const filePath = join(assetsDir, fileName);
|
const filePath = join(assetsDir, fileName);
|
||||||
await Bun.write(filePath, buffer);
|
await Bun.write(filePath, buffer);
|
||||||
|
|
||||||
const assetUrl = `/assets/items/${fileName}`;
|
const assetUrl = `/assets/items/${fileName}?v=${Date.now()}`;
|
||||||
await itemsService.updateItem(item.id, {
|
await itemsService.updateItem(item.id, {
|
||||||
iconUrl: assetUrl,
|
iconUrl: assetUrl,
|
||||||
imageUrl: assetUrl,
|
imageUrl: assetUrl,
|
||||||
@@ -235,7 +240,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
|
|
||||||
return withErrorHandling(async () => {
|
return withErrorHandling(async () => {
|
||||||
const data = await req.json() as Record<string, any>;
|
const data = await req.json() as Partial<UpdateItemDTO>;
|
||||||
|
|
||||||
const existing = await itemsService.getItemById(id);
|
const existing = await itemsService.getItemById(id);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@@ -250,7 +255,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build update data
|
// Build update data
|
||||||
const updateData: any = {};
|
const updateData: Partial<UpdateItemDTO> = {};
|
||||||
if (data.name !== undefined) updateData.name = data.name;
|
if (data.name !== undefined) updateData.name = data.name;
|
||||||
if (data.description !== undefined) updateData.description = data.description;
|
if (data.description !== undefined) updateData.description = data.description;
|
||||||
if (data.rarity !== undefined) updateData.rarity = data.rarity;
|
if (data.rarity !== undefined) updateData.rarity = data.rarity;
|
||||||
@@ -347,7 +352,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
const filePath = join(assetsDir, fileName);
|
const filePath = join(assetsDir, fileName);
|
||||||
await Bun.write(filePath, buffer);
|
await Bun.write(filePath, buffer);
|
||||||
|
|
||||||
const assetUrl = `/assets/items/${fileName}`;
|
const assetUrl = `/assets/items/${fileName}?v=${Date.now()}`;
|
||||||
const updatedItem = await itemsService.updateItem(id, {
|
const updatedItem = await itemsService.updateItem(id, {
|
||||||
iconUrl: assetUrl,
|
iconUrl: assetUrl,
|
||||||
imageUrl: assetUrl,
|
imageUrl: assetUrl,
|
||||||
@@ -29,7 +29,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { ModerationService } = await import("@shared/modules/moderation/moderation.service");
|
const { moderationService } = await import("@shared/modules/moderation/moderation.service");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route GET /api/moderation
|
* @route GET /api/moderation
|
||||||
@@ -78,7 +78,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
filter.limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
|
filter.limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
|
||||||
filter.offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
|
filter.offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
|
||||||
|
|
||||||
const cases = await ModerationService.searchCases(filter);
|
const cases = await moderationService.searchCases(filter);
|
||||||
return jsonResponse({ cases });
|
return jsonResponse({ cases });
|
||||||
}, "fetch moderation cases");
|
}, "fetch moderation cases");
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
const caseId = pathname.split("/").pop()!.toUpperCase();
|
const caseId = pathname.split("/").pop()!.toUpperCase();
|
||||||
|
|
||||||
return withErrorHandling(async () => {
|
return withErrorHandling(async () => {
|
||||||
const moderationCase = await ModerationService.getCaseById(caseId);
|
const moderationCase = await moderationService.getCaseById(caseId);
|
||||||
|
|
||||||
if (!moderationCase) {
|
if (!moderationCase) {
|
||||||
return errorResponse("Case not found", 404);
|
return errorResponse("Case not found", 404);
|
||||||
@@ -148,7 +148,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newCase = await ModerationService.createCase({
|
const newCase = await moderationService.createCase({
|
||||||
type: data.type,
|
type: data.type,
|
||||||
userId: data.userId,
|
userId: data.userId,
|
||||||
username: data.username,
|
username: data.username,
|
||||||
@@ -193,7 +193,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
return errorResponse("Missing required fields: clearedBy, clearedByName", 400);
|
return errorResponse("Missing required fields: clearedBy, clearedByName", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedCase = await ModerationService.clearCase({
|
const updatedCase = await moderationService.clearCase({
|
||||||
caseId,
|
caseId,
|
||||||
clearedBy: data.clearedBy,
|
clearedBy: data.clearedBy,
|
||||||
clearedByName: data.clearedByName,
|
clearedByName: data.clearedByName,
|
||||||
@@ -7,13 +7,6 @@
|
|||||||
import type { RouteContext, RouteModule } from "./types";
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
|
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON replacer for BigInt serialization.
|
|
||||||
*/
|
|
||||||
function jsonReplacer(_key: string, value: unknown): unknown {
|
|
||||||
return typeof value === "bigint" ? value.toString() : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings routes handler.
|
* Settings routes handler.
|
||||||
*
|
*
|
||||||
@@ -32,26 +25,31 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @route GET /api/settings
|
* @route GET /api/settings
|
||||||
* @description Returns the current bot configuration.
|
* @description Returns the current bot configuration from database.
|
||||||
* Configuration includes economy settings, leveling settings,
|
* Configuration includes economy settings, leveling settings,
|
||||||
* command toggles, and other system settings.
|
* command toggles, and other system settings.
|
||||||
* @response 200 - Full configuration object
|
* @response 200 - Full configuration object (DB format with strings for BigInts)
|
||||||
* @response 500 - Error fetching settings
|
* @response 500 - Error fetching settings
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* // Response
|
* // Response
|
||||||
* {
|
* {
|
||||||
* "economy": { "dailyReward": 100, "streakBonus": 10 },
|
* "economy": { "daily": { "amount": "100", "streakBonus": "10" } },
|
||||||
* "leveling": { "xpPerMessage": 15, "levelUpChannel": "123456789" },
|
* "leveling": { "base": 100, "exponent": 1.5 },
|
||||||
* "commands": { "disabled": [], "channelLocks": {} }
|
* "commands": { "disabled": [], "channelLocks": {} }
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
if (pathname === "/api/settings" && method === "GET") {
|
if (pathname === "/api/settings" && method === "GET") {
|
||||||
return withErrorHandling(async () => {
|
return withErrorHandling(async () => {
|
||||||
const { config } = await import("@shared/lib/config");
|
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
|
||||||
return new Response(JSON.stringify(config, jsonReplacer), {
|
const settings = await gameSettingsService.getSettings();
|
||||||
headers: { "Content-Type": "application/json" }
|
|
||||||
});
|
if (!settings) {
|
||||||
|
// Return defaults if no settings in DB yet
|
||||||
|
return jsonResponse(gameSettingsService.getDefaults());
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(settings);
|
||||||
}, "fetch settings");
|
}, "fetch settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +59,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
* Only the provided fields will be updated; other settings remain unchanged.
|
* Only the provided fields will be updated; other settings remain unchanged.
|
||||||
* After updating, commands are automatically reloaded.
|
* After updating, commands are automatically reloaded.
|
||||||
*
|
*
|
||||||
* @body Partial configuration object
|
* @body Partial configuration object (DB format with strings for BigInts)
|
||||||
* @response 200 - `{ success: true }`
|
* @response 200 - `{ success: true }`
|
||||||
* @response 400 - Validation error
|
* @response 400 - Validation error
|
||||||
* @response 500 - Error saving settings
|
* @response 500 - Error saving settings
|
||||||
@@ -69,19 +67,15 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
* @example
|
* @example
|
||||||
* // Request - Only update economy daily reward
|
* // Request - Only update economy daily reward
|
||||||
* POST /api/settings
|
* POST /api/settings
|
||||||
* { "economy": { "dailyReward": 150 } }
|
* { "economy": { "daily": { "amount": "150" } } }
|
||||||
*/
|
*/
|
||||||
if (pathname === "/api/settings" && method === "POST") {
|
if (pathname === "/api/settings" && method === "POST") {
|
||||||
try {
|
try {
|
||||||
const partialConfig = await req.json();
|
const partialConfig = await req.json() as Record<string, unknown>;
|
||||||
const { saveConfig, config: currentConfig } = await import("@shared/lib/config");
|
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
|
||||||
const { deepMerge } = await import("@shared/lib/utils");
|
|
||||||
|
// Use upsertSettings to merge partial update
|
||||||
// Merge partial update into current config
|
await gameSettingsService.upsertSettings(partialConfig as Record<string, unknown>);
|
||||||
const mergedConfig = deepMerge(currentConfig, partialConfig);
|
|
||||||
|
|
||||||
// saveConfig throws if validation fails
|
|
||||||
saveConfig(mergedConfig);
|
|
||||||
|
|
||||||
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
||||||
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
|
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
|
||||||
@@ -145,7 +139,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonResponse({ roles, channels, commands });
|
return jsonResponse({ guildId: env.DISCORD_GUILD_ID, roles, channels, commands });
|
||||||
}, "fetch settings meta");
|
}, "fetch settings meta");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +132,13 @@ mock.module("@shared/lib/utils", () => ({
|
|||||||
typeof value === "bigint" ? value.toString() : value,
|
typeof value === "bigint" ? value.toString() : value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// --- Mock Auth (bypass authentication) ---
|
||||||
|
mock.module("./routes/auth.routes", () => ({
|
||||||
|
authRoutes: { name: "auth", handler: () => null },
|
||||||
|
isAuthenticated: () => true,
|
||||||
|
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
|
||||||
|
}));
|
||||||
|
|
||||||
// --- Mock Logger ---
|
// --- Mock Logger ---
|
||||||
mock.module("@shared/lib/logger", () => ({
|
mock.module("@shared/lib/logger", () => ({
|
||||||
logger: {
|
logger: {
|
||||||
@@ -403,8 +410,11 @@ describe("Items API", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should prevent path traversal attacks", async () => {
|
test("should prevent path traversal attacks", async () => {
|
||||||
const response = await fetch(`${baseUrl}/assets/../../../etc/passwd`);
|
// Note: fetch() and HTTP servers normalize ".." segments before the handler sees them,
|
||||||
// Should either return 403 (Forbidden) or 404 (Not found after sanitization)
|
// so we can't send raw traversal paths over HTTP. Instead, test that a suspicious
|
||||||
|
// asset path (with encoded sequences) doesn't serve sensitive file content.
|
||||||
|
const response = await fetch(`${baseUrl}/assets/..%2f..%2f..%2fetc%2fpasswd`);
|
||||||
|
// Should not serve actual file content — expect 403 or 404
|
||||||
expect([403, 404]).toContain(response.status);
|
expect([403, 404]).toContain(response.status);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,40 +1,57 @@
|
|||||||
import { describe, expect, it, mock, beforeEach, afterEach, jest } from "bun:test";
|
import { describe, expect, it, mock, beforeEach, afterEach, jest } from "bun:test";
|
||||||
import { type WebServerInstance } from "./server";
|
import { type WebServerInstance } from "./server";
|
||||||
|
|
||||||
// Mock the dependencies
|
// Mock gameSettingsService — the route now uses this instead of config/saveConfig
|
||||||
const mockConfig = {
|
const mockSettings = {
|
||||||
leveling: {
|
leveling: {
|
||||||
base: 100,
|
base: 100,
|
||||||
exponent: 1.5,
|
exponent: 1.5,
|
||||||
chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 }
|
chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 }
|
||||||
},
|
},
|
||||||
economy: {
|
economy: {
|
||||||
daily: { amount: 100n, streakBonus: 10n, weeklyBonus: 50n, cooldownMs: 86400000 },
|
daily: { amount: "100", streakBonus: "10", weeklyBonus: "50", cooldownMs: 86400000 },
|
||||||
transfers: { allowSelfTransfer: false, minAmount: 50n },
|
transfers: { allowSelfTransfer: false, minAmount: "1" },
|
||||||
exam: { multMin: 1.5, multMax: 2.5 }
|
exam: { multMin: 1.5, multMax: 2.5 }
|
||||||
},
|
},
|
||||||
inventory: { maxStackSize: 99n, maxSlots: 20 },
|
inventory: { maxStackSize: "99", maxSlots: 20 },
|
||||||
lootdrop: {
|
lootdrop: {
|
||||||
spawnChance: 0.1,
|
spawnChance: 0.1,
|
||||||
cooldownMs: 3600000,
|
cooldownMs: 3600000,
|
||||||
minMessages: 10,
|
minMessages: 10,
|
||||||
|
activityWindowMs: 300000,
|
||||||
reward: { min: 100, max: 500, currency: "gold" }
|
reward: { min: 100, max: 500, currency: "gold" }
|
||||||
},
|
},
|
||||||
commands: { "help": true },
|
commands: { "help": true },
|
||||||
system: {},
|
system: {},
|
||||||
moderation: {
|
moderation: {
|
||||||
prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 },
|
prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 },
|
||||||
cases: { dmOnWarn: true }
|
},
|
||||||
|
trivia: {
|
||||||
|
entryFee: "50",
|
||||||
|
rewardMultiplier: 1.5,
|
||||||
|
timeoutSeconds: 30,
|
||||||
|
cooldownMs: 60000,
|
||||||
|
categories: [],
|
||||||
|
difficulty: "random"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockSaveConfig = jest.fn();
|
const mockGetSettings = jest.fn(() => Promise.resolve(mockSettings));
|
||||||
|
const mockUpsertSettings = jest.fn(() => Promise.resolve(mockSettings));
|
||||||
|
const mockGetDefaults = jest.fn(() => mockSettings);
|
||||||
|
|
||||||
// Mock @shared/lib/config using mock.module
|
mock.module("@shared/modules/game-settings/game-settings.service", () => ({
|
||||||
mock.module("@shared/lib/config", () => ({
|
gameSettingsService: {
|
||||||
config: mockConfig,
|
getSettings: mockGetSettings,
|
||||||
saveConfig: mockSaveConfig,
|
upsertSettings: mockUpsertSettings,
|
||||||
GameConfigType: {}
|
getDefaults: mockGetDefaults,
|
||||||
|
invalidateCache: jest.fn(),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock DrizzleClient (dependency potentially imported transitively)
|
||||||
|
mock.module("@shared/db/DrizzleClient", () => ({
|
||||||
|
DrizzleClient: {}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock @shared/lib/utils (deepMerge is used by settings API)
|
// Mock @shared/lib/utils (deepMerge is used by settings API)
|
||||||
@@ -93,6 +110,13 @@ mock.module("bun", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mock auth (bypass authentication)
|
||||||
|
mock.module("./routes/auth.routes", () => ({
|
||||||
|
authRoutes: { name: "auth", handler: () => null },
|
||||||
|
isAuthenticated: () => true,
|
||||||
|
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
|
||||||
|
}));
|
||||||
|
|
||||||
// Import createWebServer after mocks
|
// Import createWebServer after mocks
|
||||||
import { createWebServer } from "./server";
|
import { createWebServer } from "./server";
|
||||||
|
|
||||||
@@ -104,6 +128,8 @@ describe("Settings API", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
mockGetSettings.mockImplementation(() => Promise.resolve(mockSettings));
|
||||||
|
mockUpsertSettings.mockImplementation(() => Promise.resolve(mockSettings));
|
||||||
serverInstance = await createWebServer({ port: PORT, hostname: HOSTNAME });
|
serverInstance = await createWebServer({ port: PORT, hostname: HOSTNAME });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,18 +143,14 @@ describe("Settings API", () => {
|
|||||||
const res = await fetch(`${BASE_URL}/api/settings`);
|
const res = await fetch(`${BASE_URL}/api/settings`);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json() as any;
|
||||||
// Check if BigInts are converted to strings
|
// Check values come through correctly
|
||||||
expect(data.economy.daily.amount).toBe("100");
|
expect(data.economy.daily.amount).toBe("100");
|
||||||
expect(data.leveling.base).toBe(100);
|
expect(data.leveling.base).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("POST /api/settings should save valid configuration via merge", async () => {
|
it("POST /api/settings should save valid configuration via merge", async () => {
|
||||||
// We only send a partial update, expecting the server to merge it
|
const partialConfig = { economy: { daily: { amount: "200" } } };
|
||||||
// Note: For now the server implementation might still default to overwrite if we haven't updated it yet.
|
|
||||||
// But the user requested "partial vs full" fix.
|
|
||||||
// Let's assume we implement the merge logic.
|
|
||||||
const partialConfig = { studentRole: "new-role-partial" };
|
|
||||||
|
|
||||||
const res = await fetch(`${BASE_URL}/api/settings`, {
|
const res = await fetch(`${BASE_URL}/api/settings`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -137,26 +159,27 @@ describe("Settings API", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
// Expect saveConfig to be called with the MERGED result
|
// upsertSettings should be called with the partial config
|
||||||
expect(mockSaveConfig).toHaveBeenCalledWith(expect.objectContaining({
|
expect(mockUpsertSettings).toHaveBeenCalledWith(
|
||||||
studentRole: "new-role-partial",
|
expect.objectContaining({
|
||||||
leveling: mockConfig.leveling // Should keep existing values
|
economy: { daily: { amount: "200" } }
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("POST /api/settings should return 400 when save fails", async () => {
|
it("POST /api/settings should return 400 when save fails", async () => {
|
||||||
mockSaveConfig.mockImplementationOnce(() => {
|
mockUpsertSettings.mockImplementationOnce(() => {
|
||||||
throw new Error("Validation failed");
|
throw new Error("Validation failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await fetch(`${BASE_URL}/api/settings`, {
|
const res = await fetch(`${BASE_URL}/api/settings`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({}) // Empty might be valid partial, but mocks throw
|
body: JSON.stringify({})
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
const data = await res.json();
|
const data = await res.json() as any;
|
||||||
expect(data.details).toBe("Validation failed");
|
expect(data.details).toBe("Validation failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -164,7 +187,7 @@ describe("Settings API", () => {
|
|||||||
const res = await fetch(`${BASE_URL}/api/settings/meta`);
|
const res = await fetch(`${BASE_URL}/api/settings/meta`);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json() as any;
|
||||||
expect(data.roles).toHaveLength(2);
|
expect(data.roles).toHaveLength(2);
|
||||||
expect(data.roles[0]).toEqual({ id: "role1", name: "Admin", color: "#ffffff" });
|
expect(data.roles[0]).toEqual({ id: "role1", name: "Admin", color: "#ffffff" });
|
||||||
expect(data.channels[0]).toEqual({ id: "chan1", name: "general", type: 0 });
|
expect(data.channels[0]).toEqual({ id: "chan1", name: "general", type: 0 });
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, test, expect, afterAll, mock } from "bun:test";
|
import { describe, test, expect, afterAll, mock } from "bun:test";
|
||||||
import type { WebServerInstance } from "./server";
|
import type { WebServerInstance } from "./server";
|
||||||
import { createWebServer } from "./server";
|
|
||||||
|
|
||||||
interface MockBotStats {
|
interface MockBotStats {
|
||||||
bot: { name: string; avatarUrl: string | null };
|
bot: { name: string; avatarUrl: string | null };
|
||||||
@@ -13,21 +12,21 @@ interface MockBotStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Mock DrizzleClient (dependency of dashboardService)
|
// 1. Mock DrizzleClient (dependency of dashboardService)
|
||||||
|
// Must provide full chainable builder for select().from().leftJoin().groupBy().orderBy().limit()
|
||||||
mock.module("@shared/db/DrizzleClient", () => {
|
mock.module("@shared/db/DrizzleClient", () => {
|
||||||
const mockBuilder = {
|
const mockBuilder: Record<string, any> = {};
|
||||||
where: mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }])),
|
// Every chainable method returns mock builder; terminal calls return resolved promise
|
||||||
then: (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]),
|
mockBuilder.where = mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]));
|
||||||
orderBy: mock(() => mockBuilder), // Chainable
|
mockBuilder.then = (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]);
|
||||||
limit: mock(() => Promise.resolve([])), // Terminal
|
mockBuilder.orderBy = mock(() => mockBuilder);
|
||||||
};
|
mockBuilder.limit = mock(() => Promise.resolve([]));
|
||||||
|
mockBuilder.leftJoin = mock(() => mockBuilder);
|
||||||
const mockFrom = {
|
mockBuilder.groupBy = mock(() => mockBuilder);
|
||||||
from: mock(() => mockBuilder),
|
mockBuilder.from = mock(() => mockBuilder);
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
DrizzleClient: {
|
DrizzleClient: {
|
||||||
select: mock(() => mockFrom),
|
select: mock(() => mockBuilder),
|
||||||
query: {
|
query: {
|
||||||
transactions: { findMany: mock(() => Promise.resolve([])) },
|
transactions: { findMany: mock(() => Promise.resolve([])) },
|
||||||
moderationCases: { findMany: mock(() => Promise.resolve([])) },
|
moderationCases: { findMany: mock(() => Promise.resolve([])) },
|
||||||
@@ -54,7 +53,38 @@ mock.module("../../bot/lib/clientStats", () => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 3. System Events (No mock needed, use real events)
|
// 3. Mock config (used by lootdrop.service.getLootdropState)
|
||||||
|
mock.module("@shared/lib/config", () => ({
|
||||||
|
config: {
|
||||||
|
lootdrop: {
|
||||||
|
activityWindowMs: 120000,
|
||||||
|
minMessages: 1,
|
||||||
|
spawnChance: 1,
|
||||||
|
cooldownMs: 3000,
|
||||||
|
reward: { min: 40, max: 150, currency: "Astral Units" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 4. Mock auth (bypass authentication for testing)
|
||||||
|
mock.module("./routes/auth.routes", () => ({
|
||||||
|
authRoutes: { name: "auth", handler: () => null },
|
||||||
|
isAuthenticated: () => true,
|
||||||
|
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 5. Mock BotClient (used by stats helper for maintenanceMode)
|
||||||
|
mock.module("../../bot/lib/BotClient", () => ({
|
||||||
|
AuroraClient: {
|
||||||
|
maintenanceMode: false,
|
||||||
|
guilds: { cache: { get: () => null } },
|
||||||
|
commands: [],
|
||||||
|
knownCommands: new Map(),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after all mocks are set up
|
||||||
|
import { createWebServer } from "./server";
|
||||||
|
|
||||||
describe("WebServer Security & Limits", () => {
|
describe("WebServer Security & Limits", () => {
|
||||||
const port = 3001;
|
const port = 3001;
|
||||||
@@ -7,10 +7,11 @@
|
|||||||
* Each route module handles its own validation, business logic, and responses.
|
* Each route module handles its own validation, business logic, and responses.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { serve } from "bun";
|
import { serve, file } from "bun";
|
||||||
import { logger } from "@shared/lib/logger";
|
import { logger } from "@shared/lib/logger";
|
||||||
import { handleRequest } from "./routes";
|
import { handleRequest } from "./routes";
|
||||||
import { getFullDashboardStats } from "./routes/stats.helper";
|
import { getFullDashboardStats } from "./routes/stats.helper";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
export interface WebServerConfig {
|
export interface WebServerConfig {
|
||||||
port?: number;
|
port?: number;
|
||||||
@@ -38,6 +39,54 @@ export interface WebServerInstance {
|
|||||||
* // To stop the server:
|
* // To stop the server:
|
||||||
* await server.stop();
|
* await server.stop();
|
||||||
*/
|
*/
|
||||||
|
const MIME_TYPES: Record<string, string> = {
|
||||||
|
".html": "text/html",
|
||||||
|
".js": "application/javascript",
|
||||||
|
".css": "text/css",
|
||||||
|
".json": "application/json",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve static files from the panel dist directory.
|
||||||
|
* Falls back to index.html for SPA routing.
|
||||||
|
*/
|
||||||
|
async function servePanelStatic(pathname: string, distDir: string): Promise<Response | null> {
|
||||||
|
// Don't serve panel for API/auth/ws/assets routes
|
||||||
|
if (pathname.startsWith("/api/") || pathname.startsWith("/auth/") || pathname === "/ws" || pathname.startsWith("/assets/")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to serve the exact file
|
||||||
|
const filePath = join(distDir, pathname);
|
||||||
|
const bunFile = file(filePath);
|
||||||
|
if (await bunFile.exists()) {
|
||||||
|
const ext = pathname.substring(pathname.lastIndexOf("."));
|
||||||
|
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
||||||
|
return new Response(bunFile, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=31536000, immutable",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPA fallback: serve index.html for all non-file routes
|
||||||
|
const indexFile = file(join(distDir, "index.html"));
|
||||||
|
if (await indexFile.exists()) {
|
||||||
|
return new Response(indexFile, {
|
||||||
|
headers: { "Content-Type": "text/html", "Cache-Control": "no-cache" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createWebServer(config: WebServerConfig = {}): Promise<WebServerInstance> {
|
export async function createWebServer(config: WebServerConfig = {}): Promise<WebServerInstance> {
|
||||||
const { port = 3000, hostname = "localhost" } = config;
|
const { port = 3000, hostname = "localhost" } = config;
|
||||||
|
|
||||||
@@ -72,6 +121,11 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
|||||||
const response = await handleRequest(req, url);
|
const response = await handleRequest(req, url);
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
|
// Serve panel static files (production)
|
||||||
|
const panelDistDir = join(import.meta.dir, "../../panel/dist");
|
||||||
|
const staticResponse = await servePanelStatic(url.pathname, panelDistDir);
|
||||||
|
if (staticResponse) return staticResponse;
|
||||||
|
|
||||||
// No matching route found
|
// No matching route found
|
||||||
return new Response("Not Found", { status: 404 });
|
return new Response("Not Found", { status: 404 });
|
||||||
},
|
},
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const moderationCase = createCommand({
|
export const moderationCase = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -16,39 +17,35 @@ export const moderationCase = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||||
|
|
||||||
try {
|
// Validate case ID format
|
||||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
if (!caseId.match(/^CASE-\d+$/)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate case ID format
|
// Get the case
|
||||||
if (!caseId.match(/^CASE-\d+$/)) {
|
const moderationCase = await moderationService.getCaseById(caseId);
|
||||||
|
|
||||||
|
if (!moderationCase) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display the case
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
embeds: [getCaseEmbed(moderationCase)]
|
||||||
});
|
});
|
||||||
return;
|
},
|
||||||
}
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
// Get the case
|
|
||||||
const moderationCase = await ModerationService.getCaseById(caseId);
|
|
||||||
|
|
||||||
if (!moderationCase) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display the case
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getCaseEmbed(moderationCase)]
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Case command error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while fetching the case.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const cases = createCommand({
|
export const cases = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -22,33 +23,29 @@ export const cases = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
||||||
|
|
||||||
try {
|
// Get cases for the user
|
||||||
const targetUser = interaction.options.getUser("user", true);
|
const userCases = await moderationService.getUserCases(targetUser.id, activeOnly);
|
||||||
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
|
||||||
|
|
||||||
// Get cases for the user
|
const title = activeOnly
|
||||||
const userCases = await ModerationService.getUserCases(targetUser.id, activeOnly);
|
? `⚠️ Active Cases for ${targetUser.username}`
|
||||||
|
: `📋 All Cases for ${targetUser.username}`;
|
||||||
|
|
||||||
const title = activeOnly
|
const description = userCases.length === 0
|
||||||
? `⚠️ Active Cases for ${targetUser.username}`
|
? undefined
|
||||||
: `📋 All Cases for ${targetUser.username}`;
|
: `Total cases: **${userCases.length}**`;
|
||||||
|
|
||||||
const description = userCases.length === 0
|
// Display the cases
|
||||||
? undefined
|
await interaction.editReply({
|
||||||
: `Total cases: **${userCases.length}**`;
|
embeds: [getCasesListEmbed(userCases, title, description)]
|
||||||
|
});
|
||||||
// Display the cases
|
},
|
||||||
await interaction.editReply({
|
{ ephemeral: true }
|
||||||
embeds: [getCasesListEmbed(userCases, title, description)]
|
);
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Cases command error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while fetching cases.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const clearwarning = createCommand({
|
export const clearwarning = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -23,62 +24,58 @@ export const clearwarning = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||||
|
const reason = interaction.options.getString("reason") || "Cleared by moderator";
|
||||||
|
|
||||||
try {
|
// Validate case ID format
|
||||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
if (!caseId.match(/^CASE-\d+$/)) {
|
||||||
const reason = interaction.options.getString("reason") || "Cleared by moderator";
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate case ID format
|
// Check if case exists and is active
|
||||||
if (!caseId.match(/^CASE-\d+$/)) {
|
const existingCase = await moderationService.getCaseById(caseId);
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
if (!existingCase) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingCase.active) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed(`Case **${caseId}** is already resolved.`)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingCase.type !== 'warn') {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed(`Case **${caseId}** is not a warning. Only warnings can be cleared.`)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the warning
|
||||||
|
await moderationService.clearCase({
|
||||||
|
caseId,
|
||||||
|
clearedBy: interaction.user.id,
|
||||||
|
clearedByName: interaction.user.username,
|
||||||
|
reason
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if case exists and is active
|
// Send success message
|
||||||
const existingCase = await ModerationService.getCaseById(caseId);
|
|
||||||
|
|
||||||
if (!existingCase) {
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
embeds: [getClearSuccessEmbed(caseId)]
|
||||||
});
|
});
|
||||||
return;
|
},
|
||||||
}
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
if (!existingCase.active) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** is already resolved.`)]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingCase.type !== 'warn') {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** is not a warning. Only warnings can be cleared.`)]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear the warning
|
|
||||||
await ModerationService.clearCase({
|
|
||||||
caseId,
|
|
||||||
clearedBy: interaction.user.id,
|
|
||||||
clearedByName: interaction.user.username,
|
|
||||||
reason
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send success message
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getClearSuccessEmbed(caseId)]
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Clear warning command error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while clearing the warning.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
|
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
|
||||||
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
||||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import { items } from "@db/schema";
|
import { items } from "@db/schema";
|
||||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const createColor = createCommand({
|
export const createColor = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -32,62 +33,60 @@ export const createColor = createCommand({
|
|||||||
)
|
)
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
const colorInput = interaction.options.getString("color", true);
|
||||||
|
const price = interaction.options.getNumber("price") || 500;
|
||||||
|
const imageUrl = interaction.options.getString("image") || "https://cdn.discordapp.com/attachments/1450061247365124199/1453122950822760559/Main_Chip_1.png";
|
||||||
|
|
||||||
const name = interaction.options.getString("name", true);
|
// 1. Validate Color
|
||||||
const colorInput = interaction.options.getString("color", true);
|
const colorRegex = /^#([0-9A-F]{3}){1,2}$/i;
|
||||||
const price = interaction.options.getNumber("price") || 500;
|
if (!colorRegex.test(colorInput)) {
|
||||||
const imageUrl = interaction.options.getString("image") || "https://cdn.discordapp.com/attachments/1450061247365124199/1453122950822760559/Main_Chip_1.png";
|
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Validate Color
|
// 2. Create Role
|
||||||
const colorRegex = /^#([0-9A-F]{3}){1,2}$/i;
|
const role = await interaction.guild?.roles.create({
|
||||||
if (!colorRegex.test(colorInput)) {
|
name: name,
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] });
|
color: colorInput as any,
|
||||||
return;
|
reason: `Created via /createcolor by ${interaction.user.tag}`
|
||||||
}
|
});
|
||||||
|
|
||||||
try {
|
if (!role) {
|
||||||
// 2. Create Role
|
throw new Error("Failed to create role.");
|
||||||
const role = await interaction.guild?.roles.create({
|
}
|
||||||
name: name,
|
|
||||||
color: colorInput as any,
|
|
||||||
reason: `Created via /createcolor by ${interaction.user.tag}`
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!role) {
|
// 3. Add to guild settings
|
||||||
throw new Error("Failed to create role.");
|
await guildSettingsService.addColorRole(interaction.guildId!, role.id);
|
||||||
}
|
invalidateGuildConfigCache(interaction.guildId!);
|
||||||
|
|
||||||
// 3. Add to guild settings
|
// 4. Create Item
|
||||||
await guildSettingsService.addColorRole(interaction.guildId!, role.id);
|
await DrizzleClient.insert(items).values({
|
||||||
invalidateGuildConfigCache(interaction.guildId!);
|
name: `Color Role - ${name}`,
|
||||||
|
description: `Use this item to apply the ${name} color to your name.`,
|
||||||
|
type: "CONSUMABLE",
|
||||||
|
rarity: "Common",
|
||||||
|
price: BigInt(price),
|
||||||
|
iconUrl: "",
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
usageData: {
|
||||||
|
consume: false,
|
||||||
|
effects: [{ type: "COLOR_ROLE", roleId: role.id }]
|
||||||
|
} as any
|
||||||
|
});
|
||||||
|
|
||||||
// 4. Create Item
|
// 5. Success
|
||||||
await DrizzleClient.insert(items).values({
|
await interaction.editReply({
|
||||||
name: `Color Role - ${name}`,
|
embeds: [createSuccessEmbed(
|
||||||
description: `Use this item to apply the ${name} color to your name.`,
|
`**Role:** <@&${role.id}> (${colorInput})\n**Item:** Color Role - ${name}\n**Price:** ${price} 🪙`,
|
||||||
type: "CONSUMABLE",
|
"✅ Color Role & Item Created"
|
||||||
rarity: "Common",
|
)]
|
||||||
price: BigInt(price),
|
});
|
||||||
iconUrl: "",
|
},
|
||||||
imageUrl: imageUrl,
|
{ ephemeral: true }
|
||||||
usageData: {
|
);
|
||||||
consume: false,
|
|
||||||
effects: [{ type: "COLOR_ROLE", roleId: role.id }]
|
|
||||||
} as any
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Success
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createSuccessEmbed(
|
|
||||||
`**Role:** <@&${role.id}> (${colorInput})\n**Item:** Color Role - ${name}\n**Price:** ${price} 🪙`,
|
|
||||||
"✅ Color Role & Item Created"
|
|
||||||
)]
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error in createcolor:", error);
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(`Failed to create color role: ${error.message}`)] });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createCommand } from "@shared/lib/utils";
|
|||||||
import { SlashCommandBuilder, PermissionFlagsBits, Colors, userMention, roleMention, ChatInputCommandInteraction } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, Colors, userMention, roleMention, ChatInputCommandInteraction } from "discord.js";
|
||||||
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
|
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
|
||||||
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@shared/lib/errors";
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const featureflags = createCommand({
|
export const featureflags = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -98,57 +98,53 @@ export const featureflags = createCommand({
|
|||||||
),
|
),
|
||||||
autocomplete: async (interaction) => {
|
autocomplete: async (interaction) => {
|
||||||
const focused = interaction.options.getFocused(true);
|
const focused = interaction.options.getFocused(true);
|
||||||
|
|
||||||
if (focused.name === "name") {
|
if (focused.name === "name") {
|
||||||
const flags = await featureFlagsService.listFlags();
|
const flags = await featureFlagsService.listFlags();
|
||||||
const filtered = flags
|
const filtered = flags
|
||||||
.filter(f => f.name.toLowerCase().includes(focused.value.toLowerCase()))
|
.filter(f => f.name.toLowerCase().includes(focused.value.toLowerCase()))
|
||||||
.slice(0, 25);
|
.slice(0, 25);
|
||||||
|
|
||||||
await interaction.respond(
|
await interaction.respond(
|
||||||
filtered.map(f => ({ name: `${f.name} (${f.enabled ? "enabled" : "disabled"})`, value: f.name }))
|
filtered.map(f => ({ name: `${f.name} (${f.enabled ? "enabled" : "disabled"})`, value: f.name }))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
const subcommand = interaction.options.getSubcommand();
|
switch (subcommand) {
|
||||||
|
case "list":
|
||||||
try {
|
await handleList(interaction);
|
||||||
switch (subcommand) {
|
break;
|
||||||
case "list":
|
case "create":
|
||||||
await handleList(interaction);
|
await handleCreate(interaction);
|
||||||
break;
|
break;
|
||||||
case "create":
|
case "delete":
|
||||||
await handleCreate(interaction);
|
await handleDelete(interaction);
|
||||||
break;
|
break;
|
||||||
case "delete":
|
case "enable":
|
||||||
await handleDelete(interaction);
|
await handleEnable(interaction);
|
||||||
break;
|
break;
|
||||||
case "enable":
|
case "disable":
|
||||||
await handleEnable(interaction);
|
await handleDisable(interaction);
|
||||||
break;
|
break;
|
||||||
case "disable":
|
case "grant":
|
||||||
await handleDisable(interaction);
|
await handleGrant(interaction);
|
||||||
break;
|
break;
|
||||||
case "grant":
|
case "revoke":
|
||||||
await handleGrant(interaction);
|
await handleRevoke(interaction);
|
||||||
break;
|
break;
|
||||||
case "revoke":
|
case "access":
|
||||||
await handleRevoke(interaction);
|
await handleAccess(interaction);
|
||||||
break;
|
break;
|
||||||
case "access":
|
}
|
||||||
await handleAccess(interaction);
|
},
|
||||||
break;
|
{ ephemeral: true }
|
||||||
}
|
);
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof UserError) {
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,44 +173,44 @@ async function handleCreate(interaction: ChatInputCommandInteraction) {
|
|||||||
const description = interaction.options.getString("description");
|
const description = interaction.options.getString("description");
|
||||||
|
|
||||||
const flag = await featureFlagsService.createFlag(name, description ?? undefined);
|
const flag = await featureFlagsService.createFlag(name, description ?? undefined);
|
||||||
|
|
||||||
if (!flag) {
|
if (!flag) {
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to create feature flag.")] });
|
await interaction.editReply({ embeds: [createErrorEmbed("Failed to create feature flag.")] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" created successfully. Use \`/featureflags enable\` to enable it.`)]
|
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" created successfully. Use \`/featureflags enable\` to enable it.`)]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(interaction: ChatInputCommandInteraction) {
|
async function handleDelete(interaction: ChatInputCommandInteraction) {
|
||||||
const name = interaction.options.getString("name", true);
|
const name = interaction.options.getString("name", true);
|
||||||
|
|
||||||
const flag = await featureFlagsService.deleteFlag(name);
|
const flag = await featureFlagsService.deleteFlag(name);
|
||||||
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been deleted.`)]
|
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been deleted.`)]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEnable(interaction: ChatInputCommandInteraction) {
|
async function handleEnable(interaction: ChatInputCommandInteraction) {
|
||||||
const name = interaction.options.getString("name", true);
|
const name = interaction.options.getString("name", true);
|
||||||
|
|
||||||
const flag = await featureFlagsService.setFlagEnabled(name, true);
|
const flag = await featureFlagsService.setFlagEnabled(name, true);
|
||||||
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been enabled.`)]
|
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been enabled.`)]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDisable(interaction: ChatInputCommandInteraction) {
|
async function handleDisable(interaction: ChatInputCommandInteraction) {
|
||||||
const name = interaction.options.getString("name", true);
|
const name = interaction.options.getString("name", true);
|
||||||
|
|
||||||
const flag = await featureFlagsService.setFlagEnabled(name, false);
|
const flag = await featureFlagsService.setFlagEnabled(name, false);
|
||||||
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been disabled.`)]
|
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been disabled.`)]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,8 +220,8 @@ async function handleGrant(interaction: ChatInputCommandInteraction) {
|
|||||||
const role = interaction.options.getRole("role");
|
const role = interaction.options.getRole("role");
|
||||||
|
|
||||||
if (!user && !role) {
|
if (!user && !role) {
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createErrorEmbed("You must specify either a user or a role to grant access to.")]
|
embeds: [createErrorEmbed("You must specify either a user or a role to grant access to.")]
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -250,29 +246,29 @@ async function handleGrant(interaction: ChatInputCommandInteraction) {
|
|||||||
target = "Unknown";
|
target = "Unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createSuccessEmbed(`Access to "**${name}**" granted to ${target} (ID: ${access.id})`)]
|
embeds: [createSuccessEmbed(`Access to "**${name}**" granted to ${target} (ID: ${access.id})`)]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRevoke(interaction: ChatInputCommandInteraction) {
|
async function handleRevoke(interaction: ChatInputCommandInteraction) {
|
||||||
const id = interaction.options.getInteger("id", true);
|
const id = interaction.options.getInteger("id", true);
|
||||||
|
|
||||||
const access = await featureFlagsService.revokeAccess(id);
|
const access = await featureFlagsService.revokeAccess(id);
|
||||||
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createSuccessEmbed(`Access record #${access.id} has been revoked.`)]
|
embeds: [createSuccessEmbed(`Access record #${access.id} has been revoked.`)]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAccess(interaction: ChatInputCommandInteraction) {
|
async function handleAccess(interaction: ChatInputCommandInteraction) {
|
||||||
const name = interaction.options.getString("name", true);
|
const name = interaction.options.getString("name", true);
|
||||||
|
|
||||||
const accessRecords = await featureFlagsService.listAccess(name);
|
const accessRecords = await featureFlagsService.listAccess(name);
|
||||||
|
|
||||||
if (accessRecords.length === 0) {
|
if (accessRecords.length === 0) {
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createBaseEmbed("Feature Flag Access", `No access records for "**${name}**".`, Colors.Blue)]
|
embeds: [createBaseEmbed("Feature Flag Access", `No access records for "**${name}**".`, Colors.Blue)]
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import {
|
|||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@shared/lib/errors";
|
|
||||||
import { items } from "@db/schema";
|
import { items } from "@db/schema";
|
||||||
import { ilike, isNotNull, and, inArray } from "drizzle-orm";
|
import { ilike, isNotNull, and, inArray } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import { getShopListingMessage } from "@/modules/economy/shop.view";
|
import { getShopListingMessage } from "@/modules/economy/shop.view";
|
||||||
import { EffectType, LootType } from "@shared/lib/constants";
|
import { EffectType, LootType } from "@shared/lib/constants";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const listing = createCommand({
|
export const listing = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -31,72 +31,67 @@ export const listing = createCommand({
|
|||||||
)
|
)
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const itemId = interaction.options.getNumber("item", true);
|
||||||
|
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
|
||||||
|
|
||||||
const itemId = interaction.options.getNumber("item", true);
|
if (!targetChannel || !targetChannel.isSendable()) {
|
||||||
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
|
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
|
||||||
|
return;
|
||||||
if (!targetChannel || !targetChannel.isSendable()) {
|
|
||||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = await inventoryService.getItem(itemId);
|
|
||||||
if (!item) {
|
|
||||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item.price) {
|
|
||||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare context for lootboxes
|
|
||||||
const context: { referencedItems: Map<number, { name: string; rarity: string }> } = { referencedItems: new Map() };
|
|
||||||
|
|
||||||
const usageData = item.usageData as any;
|
|
||||||
const lootboxEffect = usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
|
|
||||||
|
|
||||||
if (lootboxEffect && lootboxEffect.pool) {
|
|
||||||
const itemIds = lootboxEffect.pool
|
|
||||||
.filter((drop: any) => drop.type === LootType.ITEM && drop.itemId)
|
|
||||||
.map((drop: any) => drop.itemId);
|
|
||||||
|
|
||||||
if (itemIds.length > 0) {
|
|
||||||
// Remove duplicates
|
|
||||||
const uniqueIds = [...new Set(itemIds)] as number[];
|
|
||||||
|
|
||||||
const referencedItems = await DrizzleClient.select({
|
|
||||||
id: items.id,
|
|
||||||
name: items.name,
|
|
||||||
rarity: items.rarity
|
|
||||||
}).from(items).where(inArray(items.id, uniqueIds));
|
|
||||||
|
|
||||||
for (const ref of referencedItems) {
|
|
||||||
context.referencedItems.set(ref.id, { name: ref.name, rarity: ref.rarity || 'C' });
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const listingMessage = getShopListingMessage({
|
const item = await inventoryService.getItem(itemId);
|
||||||
...item,
|
if (!item) {
|
||||||
rarity: item.rarity || undefined,
|
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
|
||||||
formattedPrice: `${item.price} 🪙`,
|
return;
|
||||||
price: item.price
|
}
|
||||||
}, context);
|
|
||||||
|
|
||||||
try {
|
if (!item.price) {
|
||||||
await targetChannel.send(listingMessage as any);
|
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
|
||||||
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
return;
|
||||||
} catch (error: any) {
|
}
|
||||||
if (error instanceof UserError) {
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
// Prepare context for lootboxes
|
||||||
} else {
|
const context: { referencedItems: Map<number, { name: string; rarity: string }> } = { referencedItems: new Map() };
|
||||||
console.error("Error creating listing:", error);
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
const usageData = item.usageData as any;
|
||||||
}
|
const lootboxEffect = usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
|
||||||
}
|
|
||||||
|
if (lootboxEffect && lootboxEffect.pool) {
|
||||||
|
const itemIds = lootboxEffect.pool
|
||||||
|
.filter((drop: any) => drop.type === LootType.ITEM && drop.itemId)
|
||||||
|
.map((drop: any) => drop.itemId);
|
||||||
|
|
||||||
|
if (itemIds.length > 0) {
|
||||||
|
// Remove duplicates
|
||||||
|
const uniqueIds = [...new Set(itemIds)] as number[];
|
||||||
|
|
||||||
|
const referencedItems = await DrizzleClient.select({
|
||||||
|
id: items.id,
|
||||||
|
name: items.name,
|
||||||
|
rarity: items.rarity
|
||||||
|
}).from(items).where(inArray(items.id, uniqueIds));
|
||||||
|
|
||||||
|
for (const ref of referencedItems) {
|
||||||
|
context.referencedItems.set(ref.id, { name: ref.name, rarity: ref.rarity || 'C' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listingMessage = getShopListingMessage({
|
||||||
|
...item,
|
||||||
|
rarity: item.rarity || undefined,
|
||||||
|
formattedPrice: `${item.price} 🪙`,
|
||||||
|
price: item.price
|
||||||
|
}, context);
|
||||||
|
|
||||||
|
await targetChannel.send(listingMessage as any);
|
||||||
|
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
||||||
|
},
|
||||||
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
},
|
},
|
||||||
autocomplete: async (interaction) => {
|
autocomplete: async (interaction) => {
|
||||||
const focusedValue = interaction.options.getFocused();
|
const focusedValue = interaction.options.getFocused();
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { CaseType } from "@shared/lib/constants";
|
import { CaseType } from "@shared/lib/constants";
|
||||||
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const note = createCommand({
|
export const note = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -24,39 +25,35 @@ export const note = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
const noteText = interaction.options.getString("note", true);
|
||||||
|
|
||||||
try {
|
// Create the note case
|
||||||
const targetUser = interaction.options.getUser("user", true);
|
const moderationCase = await moderationService.createCase({
|
||||||
const noteText = interaction.options.getString("note", true);
|
type: CaseType.NOTE,
|
||||||
|
userId: targetUser.id,
|
||||||
// Create the note case
|
username: targetUser.username,
|
||||||
const moderationCase = await ModerationService.createCase({
|
moderatorId: interaction.user.id,
|
||||||
type: CaseType.NOTE,
|
moderatorName: interaction.user.username,
|
||||||
userId: targetUser.id,
|
reason: noteText,
|
||||||
username: targetUser.username,
|
|
||||||
moderatorId: interaction.user.id,
|
|
||||||
moderatorName: interaction.user.username,
|
|
||||||
reason: noteText,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!moderationCase) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("Failed to create note.")]
|
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send success message
|
if (!moderationCase) {
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
|
embeds: [getModerationErrorEmbed("Failed to create note.")]
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
// Send success message
|
||||||
console.error("Note command error:", error);
|
await interaction.editReply({
|
||||||
await interaction.editReply({
|
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while adding the note.")]
|
});
|
||||||
});
|
},
|
||||||
}
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const notes = createCommand({
|
export const notes = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -16,28 +17,24 @@ export const notes = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
|
||||||
try {
|
// Get all notes for the user
|
||||||
const targetUser = interaction.options.getUser("user", true);
|
const userNotes = await moderationService.getUserNotes(targetUser.id);
|
||||||
|
|
||||||
// Get all notes for the user
|
// Display the notes
|
||||||
const userNotes = await ModerationService.getUserNotes(targetUser.id);
|
await interaction.editReply({
|
||||||
|
embeds: [getCasesListEmbed(
|
||||||
// Display the notes
|
userNotes,
|
||||||
await interaction.editReply({
|
`📝 Staff Notes for ${targetUser.username}`,
|
||||||
embeds: [getCasesListEmbed(
|
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
|
||||||
userNotes,
|
)]
|
||||||
`📝 Staff Notes for ${targetUser.username}`,
|
});
|
||||||
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
|
},
|
||||||
)]
|
{ ephemeral: true }
|
||||||
});
|
);
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Notes command error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while fetching notes.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||||
import { config } from "@shared/lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
import { PruneService } from "@shared/modules/moderation/prune.service";
|
import { pruneService } from "@shared/modules/moderation/prune.service";
|
||||||
import {
|
import {
|
||||||
getConfirmationMessage,
|
getConfirmationMessage,
|
||||||
getProgressEmbed,
|
getProgressEmbed,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getPruneWarningEmbed,
|
getPruneWarningEmbed,
|
||||||
getCancelledEmbed
|
getCancelledEmbed
|
||||||
} from "@/modules/moderation/prune.view";
|
} from "@/modules/moderation/prune.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const prune = createCommand({
|
export const prune = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -38,142 +39,126 @@ export const prune = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const amount = interaction.options.getInteger("amount");
|
||||||
|
const user = interaction.options.getUser("user");
|
||||||
|
const all = interaction.options.getBoolean("all") || false;
|
||||||
|
|
||||||
try {
|
// Validate inputs
|
||||||
const amount = interaction.options.getInteger("amount");
|
if (!amount && !all) {
|
||||||
const user = interaction.options.getUser("user");
|
// Default to 10 messages
|
||||||
const all = interaction.options.getBoolean("all") || false;
|
} else if (amount && all) {
|
||||||
|
|
||||||
// Validate inputs
|
|
||||||
if (!amount && !all) {
|
|
||||||
// Default to 10 messages
|
|
||||||
} else if (amount && all) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalAmount = all ? 'all' : (amount || 10);
|
|
||||||
const confirmThreshold = config.moderation.prune.confirmThreshold;
|
|
||||||
|
|
||||||
// Check if confirmation is needed
|
|
||||||
const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
|
|
||||||
|
|
||||||
if (needsConfirmation) {
|
|
||||||
// Estimate message count for confirmation
|
|
||||||
let estimatedCount: number | undefined;
|
|
||||||
if (all) {
|
|
||||||
try {
|
|
||||||
estimatedCount = await PruneService.estimateMessageCount(interaction.channel!);
|
|
||||||
} catch {
|
|
||||||
estimatedCount = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { embeds, components } = getConfirmationMessage(finalAmount, estimatedCount);
|
|
||||||
const response = await interaction.editReply({ embeds, components });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const confirmation = await response.awaitMessageComponent({
|
|
||||||
filter: (i) => i.user.id === interaction.user.id,
|
|
||||||
componentType: ComponentType.Button,
|
|
||||||
time: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
if (confirmation.customId === "cancel_prune") {
|
|
||||||
await confirmation.update({
|
|
||||||
embeds: [getCancelledEmbed()],
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// User confirmed, proceed with deletion
|
|
||||||
await confirmation.update({
|
|
||||||
embeds: [getProgressEmbed({ current: 0, total: estimatedCount || finalAmount as number })],
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
|
|
||||||
// Execute deletion with progress callback for 'all' mode
|
|
||||||
const result = await PruneService.deleteMessages(
|
|
||||||
interaction.channel!,
|
|
||||||
{
|
|
||||||
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
|
||||||
userId: user?.id,
|
|
||||||
all
|
|
||||||
},
|
|
||||||
all ? async (progress) => {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getProgressEmbed(progress)]
|
|
||||||
});
|
|
||||||
} : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Show success
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [getSuccessEmbed(result)],
|
embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
|
||||||
components: []
|
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.message.includes("time")) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getPruneWarningEmbed("Confirmation timed out. Please try again.")],
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No confirmation needed, proceed directly
|
|
||||||
const result = await PruneService.deleteMessages(
|
|
||||||
interaction.channel!,
|
|
||||||
{
|
|
||||||
amount: finalAmount as number,
|
|
||||||
userId: user?.id,
|
|
||||||
all: false
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if no messages were found
|
|
||||||
if (result.deletedCount === 0) {
|
|
||||||
if (user) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getPruneWarningEmbed(`No messages found from **${user.username}** in the last ${finalAmount} messages.`)]
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getPruneWarningEmbed("No messages found to delete.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.editReply({
|
const finalAmount = all ? 'all' : (amount || 10);
|
||||||
embeds: [getSuccessEmbed(result)]
|
const confirmThreshold = config.moderation.prune.confirmThreshold;
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
// Check if confirmation is needed
|
||||||
console.error("Prune command error:", error);
|
const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
|
||||||
|
|
||||||
let errorMessage = "An unexpected error occurred while trying to delete messages.";
|
if (needsConfirmation) {
|
||||||
if (error instanceof Error) {
|
// Estimate message count for confirmation
|
||||||
if (error.message.includes("permission")) {
|
let estimatedCount: number | undefined;
|
||||||
errorMessage = "I don't have permission to delete messages in this channel.";
|
if (all) {
|
||||||
} else if (error.message.includes("channel type")) {
|
try {
|
||||||
errorMessage = "This command cannot be used in this type of channel.";
|
estimatedCount = await pruneService.estimateMessageCount(interaction.channel!);
|
||||||
|
} catch {
|
||||||
|
estimatedCount = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { embeds, components } = getConfirmationMessage(finalAmount, estimatedCount);
|
||||||
|
const response = await interaction.editReply({ embeds, components });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const confirmation = await response.awaitMessageComponent({
|
||||||
|
filter: (i) => i.user.id === interaction.user.id,
|
||||||
|
componentType: ComponentType.Button,
|
||||||
|
time: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmation.customId === "cancel_prune") {
|
||||||
|
await confirmation.update({
|
||||||
|
embeds: [getCancelledEmbed()],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User confirmed, proceed with deletion
|
||||||
|
await confirmation.update({
|
||||||
|
embeds: [getProgressEmbed({ current: 0, total: estimatedCount || finalAmount as number })],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute deletion with progress callback for 'all' mode
|
||||||
|
const result = await pruneService.deleteMessages(
|
||||||
|
interaction.channel!,
|
||||||
|
{
|
||||||
|
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
||||||
|
userId: user?.id,
|
||||||
|
all
|
||||||
|
},
|
||||||
|
all ? async (progress) => {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getProgressEmbed(progress)]
|
||||||
|
});
|
||||||
|
} : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show success
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getSuccessEmbed(result)],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes("time")) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getPruneWarningEmbed("Confirmation timed out. Please try again.")],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
errorMessage = error.message;
|
// No confirmation needed, proceed directly
|
||||||
}
|
const result = await pruneService.deleteMessages(
|
||||||
}
|
interaction.channel!,
|
||||||
|
{
|
||||||
|
amount: finalAmount as number,
|
||||||
|
userId: user?.id,
|
||||||
|
all: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
await interaction.editReply({
|
// Check if no messages were found
|
||||||
embeds: [getPruneErrorEmbed(errorMessage)]
|
if (result.deletedCount === 0) {
|
||||||
});
|
if (user) {
|
||||||
}
|
await interaction.editReply({
|
||||||
|
embeds: [getPruneWarningEmbed(`No messages found from **${user.username}** in the last ${finalAmount} messages.`)]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getPruneWarningEmbed("No messages found to delete.")]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getSuccessEmbed(result)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createSuccessEmbed } from "@lib/embeds";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const refresh = createCommand({
|
export const refresh = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -9,25 +10,24 @@ export const refresh = createCommand({
|
|||||||
.setDescription("Reloads all commands and config without restarting")
|
.setDescription("Reloads all commands and config without restarting")
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const start = Date.now();
|
||||||
|
await AuroraClient.loadCommands(true);
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
try {
|
// Deploy commands
|
||||||
const start = Date.now();
|
await AuroraClient.deployCommands();
|
||||||
await AuroraClient.loadCommands(true);
|
|
||||||
const duration = Date.now() - start;
|
|
||||||
|
|
||||||
// Deploy commands
|
const embed = createSuccessEmbed(
|
||||||
await AuroraClient.deployCommands();
|
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
|
||||||
|
"System Refreshed"
|
||||||
|
);
|
||||||
|
|
||||||
const embed = createSuccessEmbed(
|
await interaction.editReply({ embeds: [embed] });
|
||||||
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
|
},
|
||||||
"System Refreshed"
|
{ ephemeral: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while refreshing commands. Check console for details.", "Refresh Failed")] });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -3,7 +3,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, Colors, ChatInputCommandInter
|
|||||||
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
||||||
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
|
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
|
||||||
import { UserError } from "@shared/lib/errors";
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const settings = createCommand({
|
export const settings = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -84,33 +84,29 @@ export const settings = createCommand({
|
|||||||
.setRequired(false))),
|
.setRequired(false))),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ ephemeral: true });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
const guildId = interaction.guildId!;
|
||||||
|
|
||||||
const subcommand = interaction.options.getSubcommand();
|
switch (subcommand) {
|
||||||
const guildId = interaction.guildId!;
|
case "show":
|
||||||
|
await handleShow(interaction, guildId);
|
||||||
try {
|
break;
|
||||||
switch (subcommand) {
|
case "set":
|
||||||
case "show":
|
await handleSet(interaction, guildId);
|
||||||
await handleShow(interaction, guildId);
|
break;
|
||||||
break;
|
case "reset":
|
||||||
case "set":
|
await handleReset(interaction, guildId);
|
||||||
await handleSet(interaction, guildId);
|
break;
|
||||||
break;
|
case "colors":
|
||||||
case "reset":
|
await handleColors(interaction, guildId);
|
||||||
await handleReset(interaction, guildId);
|
break;
|
||||||
break;
|
}
|
||||||
case "colors":
|
},
|
||||||
await handleColors(interaction, guildId);
|
{ ephemeral: true }
|
||||||
break;
|
);
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof UserError) {
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
||||||
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
||||||
import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds";
|
import { createErrorEmbed } from "@/lib/embeds";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const terminal = createCommand({
|
export const terminal = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -23,15 +24,14 @@ export const terminal = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.reply({ ephemeral: true, content: "Initializing terminal..." });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
try {
|
async () => {
|
||||||
await terminalService.init(channel as TextChannel);
|
await terminalService.init(channel as TextChannel);
|
||||||
await interaction.editReply({ content: "✅ Terminal initialized!" });
|
await interaction.editReply({ content: "✅ Terminal initialized!" });
|
||||||
} catch (error) {
|
},
|
||||||
console.error(error);
|
{ ephemeral: true }
|
||||||
await interaction.editReply({ content: "❌ Failed to initialize terminal." });
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import {
|
import {
|
||||||
getWarnSuccessEmbed,
|
getWarnSuccessEmbed,
|
||||||
getModerationErrorEmbed,
|
getModerationErrorEmbed,
|
||||||
getUserWarningEmbed
|
|
||||||
} from "@/modules/moderation/moderation.view";
|
} from "@/modules/moderation/moderation.view";
|
||||||
import { getGuildConfig } from "@shared/lib/config";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const warn = createCommand({
|
export const warn = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -28,67 +28,63 @@ export const warn = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
const reason = interaction.options.getString("reason", true);
|
||||||
|
|
||||||
try {
|
// Don't allow warning bots
|
||||||
const targetUser = interaction.options.getUser("user", true);
|
if (targetUser.bot) {
|
||||||
const reason = interaction.options.getString("reason", true);
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed("You cannot warn bots.")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Don't allow warning bots
|
// Don't allow self-warnings
|
||||||
if (targetUser.bot) {
|
if (targetUser.id === interaction.user.id) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed("You cannot warn yourself.")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch guild config for moderation settings
|
||||||
|
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||||
|
|
||||||
|
// Issue the warning via service
|
||||||
|
const { moderationCase, warningCount, autoTimeoutIssued } = await moderationService.issueWarning({
|
||||||
|
userId: targetUser.id,
|
||||||
|
username: targetUser.username,
|
||||||
|
moderatorId: interaction.user.id,
|
||||||
|
moderatorName: interaction.user.username,
|
||||||
|
reason,
|
||||||
|
guildName: interaction.guild?.name || undefined,
|
||||||
|
dmTarget: targetUser,
|
||||||
|
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id),
|
||||||
|
config: {
|
||||||
|
dmOnWarn: guildConfig.moderation?.cases?.dmOnWarn,
|
||||||
|
autoTimeoutThreshold: guildConfig.moderation?.cases?.autoTimeoutThreshold,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send success message to moderator
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [getModerationErrorEmbed("You cannot warn bots.")]
|
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't allow self-warnings
|
// Follow up if auto-timeout was issued
|
||||||
if (targetUser.id === interaction.user.id) {
|
if (autoTimeoutIssued) {
|
||||||
await interaction.editReply({
|
await interaction.followUp({
|
||||||
embeds: [getModerationErrorEmbed("You cannot warn yourself.")]
|
embeds: [getModerationErrorEmbed(
|
||||||
});
|
`⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
|
||||||
return;
|
)],
|
||||||
}
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
// Fetch guild config for moderation settings
|
}
|
||||||
const guildConfig = await getGuildConfig(interaction.guildId!);
|
},
|
||||||
|
{ ephemeral: true }
|
||||||
// Issue the warning via service
|
);
|
||||||
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
|
|
||||||
userId: targetUser.id,
|
|
||||||
username: targetUser.username,
|
|
||||||
moderatorId: interaction.user.id,
|
|
||||||
moderatorName: interaction.user.username,
|
|
||||||
reason,
|
|
||||||
guildName: interaction.guild?.name || undefined,
|
|
||||||
dmTarget: targetUser,
|
|
||||||
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id),
|
|
||||||
config: {
|
|
||||||
dmOnWarn: guildConfig.moderation?.cases?.dmOnWarn,
|
|
||||||
autoTimeoutThreshold: guildConfig.moderation?.cases?.autoTimeoutThreshold,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send success message to moderator
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Follow up if auto-timeout was issued
|
|
||||||
if (autoTimeoutIssued) {
|
|
||||||
await interaction.followUp({
|
|
||||||
embeds: [getModerationErrorEmbed(
|
|
||||||
`⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
|
|
||||||
)],
|
|
||||||
flags: MessageFlags.Ephemeral
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Warn command error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while issuing the warning.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getWarningsEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const warnings = createCommand({
|
export const warnings = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -16,24 +17,20 @@ export const warnings = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
|
||||||
try {
|
// Get active warnings for the user
|
||||||
const targetUser = interaction.options.getUser("user", true);
|
const activeWarnings = await moderationService.getUserWarnings(targetUser.id);
|
||||||
|
|
||||||
// Get active warnings for the user
|
// Display the warnings
|
||||||
const activeWarnings = await ModerationService.getUserWarnings(targetUser.id);
|
await interaction.editReply({
|
||||||
|
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
|
||||||
// Display the warnings
|
});
|
||||||
await interaction.editReply({
|
},
|
||||||
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
|
{ ephemeral: true }
|
||||||
});
|
);
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Warnings command error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while fetching warnings.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils";
|
|||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { createErrorEmbed } from "@/lib/embeds";
|
import { createErrorEmbed } from "@/lib/embeds";
|
||||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const webhook = createCommand({
|
export const webhook = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -14,43 +15,40 @@ export const webhook = createCommand({
|
|||||||
.setRequired(true)
|
.setRequired(true)
|
||||||
),
|
),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const payloadString = interaction.options.getString("payload", true);
|
||||||
|
let payload;
|
||||||
|
|
||||||
const payloadString = interaction.options.getString("payload", true);
|
try {
|
||||||
let payload;
|
payload = JSON.parse(payloadString);
|
||||||
|
} catch (error) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("The provided payload is not valid JSON.", "Invalid JSON")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const channel = interaction.channel;
|
||||||
payload = JSON.parse(payloadString);
|
|
||||||
} catch (error) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createErrorEmbed("The provided payload is not valid JSON.", "Invalid JSON")]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const channel = interaction.channel;
|
if (!channel || !('createWebhook' in channel)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!channel || !('createWebhook' in channel)) {
|
await sendWebhookMessage(
|
||||||
await interaction.editReply({
|
channel,
|
||||||
embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")]
|
payload,
|
||||||
});
|
interaction.client.user,
|
||||||
return;
|
`Proxy message requested by ${interaction.user.tag}`
|
||||||
}
|
);
|
||||||
|
|
||||||
try {
|
await interaction.editReply({ content: "Message sent successfully!" });
|
||||||
await sendWebhookMessage(
|
},
|
||||||
channel,
|
{ ephemeral: true }
|
||||||
payload,
|
);
|
||||||
interaction.client.user,
|
|
||||||
`Proxy message requested by ${interaction.user.tag}`
|
|
||||||
);
|
|
||||||
|
|
||||||
await interaction.editReply({ content: "Message sent successfully!" });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Webhook error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createErrorEmbed("Failed to send message via webhook. Ensure the bot has 'Manage Webhooks' permission and the payload is valid.", "Delivery Failed")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,35 +2,29 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { economyService } from "@shared/modules/economy/economy.service";
|
import { economyService } from "@shared/modules/economy/economy.service";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createSuccessEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@shared/lib/errors";
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const daily = createCommand({
|
export const daily = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
.setName("daily")
|
.setName("daily")
|
||||||
.setDescription("Claim your daily reward"),
|
.setDescription("Claim your daily reward"),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await withCommandErrorHandling(
|
||||||
try {
|
interaction,
|
||||||
const result = await economyService.claimDaily(interaction.user.id);
|
async () => {
|
||||||
|
const result = await economyService.claimDaily(interaction.user.id);
|
||||||
|
|
||||||
const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!")
|
const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!")
|
||||||
.addFields(
|
.addFields(
|
||||||
{ name: "Streak", value: `🔥 ${result.streak} days`, inline: true },
|
{ name: "Streak", value: `🔥 ${result.streak} days`, inline: true },
|
||||||
{ name: "Weekly Progress", value: `${"🟩".repeat(result.streak % 7 || 7)}${"⬜".repeat(7 - (result.streak % 7 || 7))} (${result.streak % 7 || 7}/7)`, inline: true },
|
{ name: "Weekly Progress", value: `${"🟩".repeat(result.streak % 7 || 7)}${"⬜".repeat(7 - (result.streak % 7 || 7))} (${result.streak % 7 || 7}/7)`, inline: true },
|
||||||
{ name: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R> `, inline: true }
|
{ name: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R> `, inline: true }
|
||||||
)
|
)
|
||||||
.setColor("Gold");
|
.setColor("Gold");
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof UserError) {
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
|
||||||
} else {
|
|
||||||
console.error("Error claiming daily:", error);
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils";
|
|||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
|
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
|
|
||||||
@@ -10,66 +11,62 @@ export const exam = createCommand({
|
|||||||
.setName("exam")
|
.setName("exam")
|
||||||
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
// First, try to take the exam or check status
|
||||||
|
const result = await examService.takeExam(interaction.user.id);
|
||||||
|
|
||||||
try {
|
if (result.status === ExamStatus.NOT_REGISTERED) {
|
||||||
// First, try to take the exam or check status
|
// Register the user
|
||||||
const result = await examService.takeExam(interaction.user.id);
|
const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username);
|
||||||
|
const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000);
|
||||||
|
|
||||||
if (result.status === ExamStatus.NOT_REGISTERED) {
|
await interaction.editReply({
|
||||||
// Register the user
|
embeds: [createSuccessEmbed(
|
||||||
const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username);
|
`You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
|
||||||
const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000);
|
`Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
|
||||||
|
"Exam Registration Successful"
|
||||||
|
)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
|
||||||
|
|
||||||
|
if (result.status === ExamStatus.COOLDOWN) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed(
|
||||||
|
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
||||||
|
`Next exam available: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
|
||||||
|
)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === ExamStatus.MISSED) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed(
|
||||||
|
`You missed your exam day! Your exam day is **${DAYS[result.examDay!]}** (Server Time).\n` +
|
||||||
|
`You verify your attendance but score a **0**.\n` +
|
||||||
|
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
|
||||||
|
"Exam Failed"
|
||||||
|
)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it reached here with AVAILABLE, it means they passed
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createSuccessEmbed(
|
embeds: [createSuccessEmbed(
|
||||||
`You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
|
`**XP Gained:** ${result.xpDiff?.toString()}\n` +
|
||||||
`Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
|
`**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
|
||||||
"Exam Registration Successful"
|
`**Reward:** ${result.reward?.toString()} Currency\n\n` +
|
||||||
|
`See you next week: <t:${nextExamTimestamp}:D>`,
|
||||||
|
"Exam Passed!"
|
||||||
)]
|
)]
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
|
|
||||||
|
|
||||||
if (result.status === ExamStatus.COOLDOWN) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createErrorEmbed(
|
|
||||||
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
|
||||||
`Next exam available: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
|
|
||||||
)]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status === ExamStatus.MISSED) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createErrorEmbed(
|
|
||||||
`You missed your exam day! Your exam day is **${DAYS[result.examDay!]}** (Server Time).\n` +
|
|
||||||
`You verify your attendance but score a **0**.\n` +
|
|
||||||
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
|
|
||||||
"Exam Failed"
|
|
||||||
)]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If it reached here with AVAILABLE, it means they passed
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createSuccessEmbed(
|
|
||||||
`**XP Gained:** ${result.xpDiff?.toString()}\n` +
|
|
||||||
`**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
|
|
||||||
`**Reward:** ${result.reward?.toString()} Currency\n\n` +
|
|
||||||
`See you next week: <t:${nextExamTimestamp}:D>`,
|
|
||||||
"Exam Passed!"
|
|
||||||
)]
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error in exam command:", error);
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An unexpected error occurred.")] });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { economyService } from "@shared/modules/economy/economy.service";
|
|||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { config } from "@shared/lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@shared/lib/errors";
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const pay = createCommand({
|
export const pay = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -50,20 +50,14 @@ export const pay = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await withCommandErrorHandling(
|
||||||
await interaction.deferReply();
|
interaction,
|
||||||
await economyService.transfer(senderId, receiverId.toString(), amount);
|
async () => {
|
||||||
|
await economyService.transfer(senderId, receiverId.toString(), amount);
|
||||||
|
|
||||||
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
|
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
|
||||||
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
|
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof UserError) {
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
|
||||||
} else {
|
|
||||||
console.error("Error sending payment:", error);
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createErrorEmbed } from "@lib/embeds";
|
|||||||
import { UserError } from "@shared/lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
import { config } from "@shared/lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
import { TriviaCategory } from "@shared/lib/constants";
|
import { TriviaCategory } from "@shared/lib/constants";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const trivia = createCommand({
|
export const trivia = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -53,64 +54,54 @@ export const trivia = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// User can play - defer publicly for trivia question
|
// User can play - use standardized error handling for the main operation
|
||||||
await interaction.deferReply();
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
// Start trivia session (deducts entry fee)
|
||||||
|
const session = await triviaService.startTrivia(
|
||||||
|
interaction.user.id,
|
||||||
|
interaction.user.username,
|
||||||
|
categoryId ? parseInt(categoryId) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
// Start trivia session (deducts entry fee)
|
// Generate Components v2 message
|
||||||
const session = await triviaService.startTrivia(
|
const { components, flags } = getTriviaQuestionView(session, interaction.user.username);
|
||||||
interaction.user.id,
|
|
||||||
interaction.user.username,
|
// Reply with Components v2 question
|
||||||
categoryId ? parseInt(categoryId) : undefined
|
await interaction.editReply({
|
||||||
|
components,
|
||||||
|
flags
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up automatic timeout cleanup
|
||||||
|
setTimeout(async () => {
|
||||||
|
const stillActive = triviaService.getSession(session.sessionId);
|
||||||
|
if (stillActive) {
|
||||||
|
// User didn't answer - clean up session with no reward
|
||||||
|
try {
|
||||||
|
await triviaService.submitAnswer(session.sessionId, interaction.user.id, false);
|
||||||
|
} catch (error) {
|
||||||
|
// Session already cleaned up, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate Components v2 message
|
|
||||||
const { components, flags } = getTriviaQuestionView(session, interaction.user.username);
|
|
||||||
|
|
||||||
// Reply with Components v2 question
|
|
||||||
await interaction.editReply({
|
|
||||||
components,
|
|
||||||
flags
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up automatic timeout cleanup
|
|
||||||
setTimeout(async () => {
|
|
||||||
const stillActive = triviaService.getSession(session.sessionId);
|
|
||||||
if (stillActive) {
|
|
||||||
// User didn't answer - clean up session with no reward
|
|
||||||
try {
|
|
||||||
await triviaService.submitAnswer(session.sessionId, interaction.user.id, false);
|
|
||||||
} catch (error) {
|
|
||||||
// Session already cleaned up, ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period
|
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
// Handle errors from the pre-defer canPlayTrivia check
|
||||||
if (error instanceof UserError) {
|
if (error instanceof UserError) {
|
||||||
// Check if we've already deferred
|
await interaction.reply({
|
||||||
if (interaction.deferred) {
|
embeds: [createErrorEmbed(error.message)],
|
||||||
await interaction.editReply({
|
ephemeral: true
|
||||||
embeds: [createErrorEmbed(error.message)]
|
});
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await interaction.reply({
|
|
||||||
embeds: [createErrorEmbed(error.message)],
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.error("Error in trivia command:", error);
|
console.error("Error in trivia command:", error);
|
||||||
// Check if we've already deferred
|
await interaction.reply({
|
||||||
if (interaction.deferred) {
|
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
|
||||||
await interaction.editReply({
|
ephemeral: true
|
||||||
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")]
|
});
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await interaction.reply({
|
|
||||||
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
|||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
||||||
import type { ItemUsageData } from "@shared/lib/types";
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
import { UserError } from "@shared/lib/errors";
|
|
||||||
import { getGuildConfig } from "@shared/lib/config";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
|
|
||||||
export const use = createCommand({
|
export const use = createCommand({
|
||||||
@@ -19,57 +18,50 @@ export const use = createCommand({
|
|||||||
.setAutocomplete(true)
|
.setAutocomplete(true)
|
||||||
),
|
),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||||
|
const colorRoles = guildConfig.colorRoles ?? [];
|
||||||
|
|
||||||
const guildConfig = await getGuildConfig(interaction.guildId!);
|
const itemId = interaction.options.getNumber("item", true);
|
||||||
const colorRoles = guildConfig.colorRoles ?? [];
|
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||||
|
if (!user) {
|
||||||
|
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const itemId = interaction.options.getNumber("item", true);
|
const result = await inventoryService.useItem(user.id.toString(), itemId);
|
||||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
|
||||||
if (!user) {
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const usageData = result.usageData;
|
||||||
const result = await inventoryService.useItem(user.id.toString(), itemId);
|
if (usageData) {
|
||||||
|
for (const effect of usageData.effects) {
|
||||||
const usageData = result.usageData;
|
if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') {
|
||||||
if (usageData) {
|
try {
|
||||||
for (const effect of usageData.effects) {
|
const member = await interaction.guild?.members.fetch(user.id.toString());
|
||||||
if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') {
|
if (member) {
|
||||||
try {
|
if (effect.type === 'TEMP_ROLE') {
|
||||||
const member = await interaction.guild?.members.fetch(user.id.toString());
|
await member.roles.add(effect.roleId);
|
||||||
if (member) {
|
} else if (effect.type === 'COLOR_ROLE') {
|
||||||
if (effect.type === 'TEMP_ROLE') {
|
// Remove existing color roles
|
||||||
await member.roles.add(effect.roleId);
|
const rolesToRemove = colorRoles.filter(r => member.roles.cache.has(r));
|
||||||
} else if (effect.type === 'COLOR_ROLE') {
|
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
|
||||||
// Remove existing color roles
|
await member.roles.add(effect.roleId);
|
||||||
const rolesToRemove = colorRoles.filter(r => member.roles.cache.has(r));
|
}
|
||||||
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
|
|
||||||
await member.roles.add(effect.roleId);
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to assign role in /use command:", e);
|
||||||
|
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to assign role in /use command:", e);
|
|
||||||
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { embed, files } = getItemUseResultEmbed(result.results, result.item);
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed], files });
|
||||||
}
|
}
|
||||||
|
);
|
||||||
const { embed, files } = getItemUseResultEmbed(result.results, result.item);
|
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed], files });
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof UserError) {
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
|
||||||
} else {
|
|
||||||
console.error("Error using item:", error);
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred while using the item.")] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
autocomplete: async (interaction) => {
|
autocomplete: async (interaction) => {
|
||||||
const focusedValue = interaction.options.getFocused();
|
const focusedValue = interaction.options.getFocused();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { env } from "@shared/lib/env";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { initializeConfig } from "@shared/lib/config";
|
import { initializeConfig } from "@shared/lib/config";
|
||||||
|
|
||||||
import { startWebServerFromRoot } from "../web/src/server";
|
import { startWebServerFromRoot } from "../api/src/server";
|
||||||
|
|
||||||
// Initialize config from database
|
// Initialize config from database
|
||||||
await initializeConfig();
|
await initializeConfig();
|
||||||
@@ -18,7 +18,7 @@ console.log("🌐 Starting web server...");
|
|||||||
|
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
|
|
||||||
const webProjectPath = join(import.meta.dir, "../web");
|
const webProjectPath = join(import.meta.dir, "../api");
|
||||||
const webPort = Number(process.env.WEB_PORT) || 3000;
|
const webPort = Number(process.env.WEB_PORT) || 3000;
|
||||||
const webHost = process.env.HOST || "0.0.0.0";
|
const webHost = process.env.HOST || "0.0.0.0";
|
||||||
|
|
||||||
|
|||||||
147
bot/lib/commandUtils.test.ts
Normal file
147
bot/lib/commandUtils.test.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, it, expect, mock, beforeEach, spyOn } from "bun:test";
|
||||||
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
|
||||||
|
// --- Mocks ---
|
||||||
|
|
||||||
|
const mockDeferReply = mock(() => Promise.resolve());
|
||||||
|
const mockEditReply = mock(() => Promise.resolve());
|
||||||
|
|
||||||
|
const mockInteraction = {
|
||||||
|
deferReply: mockDeferReply,
|
||||||
|
editReply: mockEditReply,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const mockCreateErrorEmbed = mock((msg: string) => ({ description: msg, type: "error" }));
|
||||||
|
|
||||||
|
mock.module("./embeds", () => ({
|
||||||
|
createErrorEmbed: mockCreateErrorEmbed,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import AFTER mocking
|
||||||
|
const { withCommandErrorHandling } = await import("./commandUtils");
|
||||||
|
|
||||||
|
// --- Tests ---
|
||||||
|
|
||||||
|
describe("withCommandErrorHandling", () => {
|
||||||
|
let consoleErrorSpy: ReturnType<typeof spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDeferReply.mockClear();
|
||||||
|
mockEditReply.mockClear();
|
||||||
|
mockCreateErrorEmbed.mockClear();
|
||||||
|
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => { });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should always call deferReply", async () => {
|
||||||
|
await withCommandErrorHandling(
|
||||||
|
mockInteraction,
|
||||||
|
async () => "result"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockDeferReply).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass ephemeral option to deferReply", async () => {
|
||||||
|
await withCommandErrorHandling(
|
||||||
|
mockInteraction,
|
||||||
|
async () => "result",
|
||||||
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockDeferReply).toHaveBeenCalledWith({ ephemeral: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return the operation result on success", async () => {
|
||||||
|
const result = await withCommandErrorHandling(
|
||||||
|
mockInteraction,
|
||||||
|
async () => ({ data: "test" })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ data: "test" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onSuccess with the result", async () => {
|
||||||
|
const onSuccess = mock(async (_result: string) => { });
|
||||||
|
|
||||||
|
await withCommandErrorHandling(
|
||||||
|
mockInteraction,
|
||||||
|
async () => "hello",
|
||||||
|
{ onSuccess }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onSuccess).toHaveBeenCalledWith("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send successMessage when no onSuccess is provided", async () => {
|
||||||
|
await withCommandErrorHandling(
|
||||||
|
mockInteraction,
|
||||||
|
async () => "result",
|
||||||
|
{ successMessage: "It worked!" }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockEditReply).toHaveBeenCalledWith({
|
||||||
|
content: "It worked!",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prefer onSuccess over successMessage", async () => {
|
||||||
|
const onSuccess = mock(async (_result: string) => { });
|
||||||
|
|
||||||
|
await withCommandErrorHandling(
|
||||||
|
mockInteraction,
|
||||||
|
async () => "result",
|
||||||
|
{ successMessage: "This should not be sent", onSuccess }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onSuccess).toHaveBeenCalledTimes(1);
|
||||||
|
// editReply should NOT have been called with the successMessage
|
||||||
|
expect(mockEditReply).not.toHaveBeenCalledWith({
|
||||||
|
content: "This should not be sent",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show error embed for UserError", async () => {
|
||||||
|
const result = await withCommandErrorHandling(
|
||||||
|
mockInteraction,
|
||||||
|
async () => {
|
||||||
|
throw new UserError("You can't do that!");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(mockCreateErrorEmbed).toHaveBeenCalledWith("You can't do that!");
|
||||||
|
expect(mockEditReply).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show generic error and log for unexpected errors", async () => {
|
||||||
|
const unexpectedError = new Error("Database exploded");
|
||||||
|
|
||||||
|
const result = await withCommandErrorHandling(
|
||||||
|
mockInteraction,
|
||||||
|
async () => {
|
||||||
|
throw unexpectedError;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
"Unexpected error in command:",
|
||||||
|
unexpectedError
|
||||||
|
);
|
||||||
|
expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
|
||||||
|
"An unexpected error occurred."
|
||||||
|
);
|
||||||
|
expect(mockEditReply).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined on error", async () => {
|
||||||
|
const result = await withCommandErrorHandling(
|
||||||
|
mockInteraction,
|
||||||
|
async () => {
|
||||||
|
throw new Error("fail");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
79
bot/lib/commandUtils.ts
Normal file
79
bot/lib/commandUtils.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { ChatInputCommandInteraction } from "discord.js";
|
||||||
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
import { createErrorEmbed } from "./embeds";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a command's core logic with standardized error handling.
|
||||||
|
*
|
||||||
|
* - Calls `interaction.deferReply()` automatically
|
||||||
|
* - On success, invokes `onSuccess` callback or sends `successMessage`
|
||||||
|
* - On `UserError`, shows the error message in an error embed
|
||||||
|
* - On unexpected errors, logs to console and shows a generic error embed
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* export const myCommand = createCommand({
|
||||||
|
* execute: async (interaction) => {
|
||||||
|
* await withCommandErrorHandling(
|
||||||
|
* interaction,
|
||||||
|
* async () => {
|
||||||
|
* const result = await doSomething();
|
||||||
|
* await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
||||||
|
* }
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // With deferReply options (e.g. ephemeral)
|
||||||
|
* await withCommandErrorHandling(
|
||||||
|
* interaction,
|
||||||
|
* async () => doSomething(),
|
||||||
|
* {
|
||||||
|
* ephemeral: true,
|
||||||
|
* successMessage: "Done!",
|
||||||
|
* }
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function withCommandErrorHandling<T>(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
options?: {
|
||||||
|
/** Message to send on success (if no onSuccess callback is provided) */
|
||||||
|
successMessage?: string;
|
||||||
|
/** Callback invoked with the operation result on success */
|
||||||
|
onSuccess?: (result: T) => Promise<void>;
|
||||||
|
/** Whether the deferred reply should be ephemeral */
|
||||||
|
ephemeral?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<T | undefined> {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: options?.ephemeral });
|
||||||
|
const result = await operation();
|
||||||
|
|
||||||
|
if (options?.onSuccess) {
|
||||||
|
await options.onSuccess(result);
|
||||||
|
} else if (options?.successMessage) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: options.successMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UserError) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed(error.message)],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error("Unexpected error in command:", error);
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("An unexpected error occurred.")],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||||
|
|
||||||
// Mock DrizzleClient
|
// Mock DrizzleClient — must match the import path used in db.ts
|
||||||
mock.module("./DrizzleClient", () => ({
|
mock.module("@shared/db/DrizzleClient", () => ({
|
||||||
DrizzleClient: {
|
DrizzleClient: {
|
||||||
transaction: async (cb: any) => cb("MOCK_TX")
|
transaction: async (cb: any) => cb("MOCK_TX")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
||||||
import { economyService } from "@shared/modules/economy/economy.service";
|
import { economyService } from "@shared/modules/economy/economy.service";
|
||||||
import { userTimers } from "@db/schema";
|
import { userTimers } from "@db/schema";
|
||||||
import type { EffectHandler } from "./effect.types";
|
import type { EffectHandler, ValidatedEffectPayload } from "./effect.types";
|
||||||
|
import { EffectType } from "@shared/lib/constants";
|
||||||
import type { LootTableItem } from "@shared/lib/types";
|
import type { LootTableItem } from "@shared/lib/types";
|
||||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { inventory, items } from "@db/schema";
|
import { inventory, items } from "@db/schema";
|
||||||
@@ -15,21 +16,21 @@ const getDuration = (effect: any): number => {
|
|||||||
return effect.durationSeconds || 60; // Default to 60s if nothing provided
|
return effect.durationSeconds || 60; // Default to 60s if nothing provided
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleAddXp: EffectHandler = async (userId, effect, txFn) => {
|
export const handleAddXp: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.ADD_XP }>, txFn) => {
|
||||||
await levelingService.addXp(userId, BigInt(effect.amount), txFn);
|
await levelingService.addXp(userId, BigInt(effect.amount), txFn);
|
||||||
return `Gained ${effect.amount} XP`;
|
return `Gained ${effect.amount} XP`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => {
|
export const handleAddBalance: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.ADD_BALANCE }>, txFn) => {
|
||||||
await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
|
await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
|
||||||
return `Gained ${effect.amount} 🪙`;
|
return `Gained ${effect.amount} 🪙`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleReplyMessage: EffectHandler = async (_userId, effect, _txFn) => {
|
export const handleReplyMessage: EffectHandler = async (_userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.REPLY_MESSAGE }>, _txFn) => {
|
||||||
return effect.message;
|
return effect.message;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
|
export const handleXpBoost: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.XP_BOOST }>, txFn) => {
|
||||||
const boostDuration = getDuration(effect);
|
const boostDuration = getDuration(effect);
|
||||||
const expiresAt = new Date(Date.now() + boostDuration * 1000);
|
const expiresAt = new Date(Date.now() + boostDuration * 1000);
|
||||||
await txFn.insert(userTimers).values({
|
await txFn.insert(userTimers).values({
|
||||||
@@ -45,7 +46,7 @@ export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
return `XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`;
|
return `XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
|
export const handleTempRole: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.TEMP_ROLE }>, txFn) => {
|
||||||
const roleDuration = getDuration(effect);
|
const roleDuration = getDuration(effect);
|
||||||
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
|
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
|
||||||
await txFn.insert(userTimers).values({
|
await txFn.insert(userTimers).values({
|
||||||
@@ -62,11 +63,11 @@ export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
return `Temporary Role granted for ${Math.floor(roleDuration / 60)}m`;
|
return `Temporary Role granted for ${Math.floor(roleDuration / 60)}m`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleColorRole: EffectHandler = async (_userId, _effect, _txFn) => {
|
export const handleColorRole: EffectHandler = async (_userId, _effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.COLOR_ROLE }>, _txFn) => {
|
||||||
return "Color Role Equipped";
|
return "Color Role Equipped";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
export const handleLootbox: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.LOOTBOX }>, txFn) => {
|
||||||
const pool = effect.pool as LootTableItem[];
|
const pool = effect.pool as LootTableItem[];
|
||||||
if (!pool || pool.length === 0) return "The box is empty...";
|
if (!pool || pool.length === 0) return "The box is empty...";
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
handleColorRole,
|
handleColorRole,
|
||||||
handleLootbox
|
handleLootbox
|
||||||
} from "./effect.handlers";
|
} from "./effect.handlers";
|
||||||
import type { EffectHandler } from "./effect.types";
|
import type { EffectHandler, ValidatedEffectPayload } from "./effect.types";
|
||||||
|
import { EffectPayloadSchema } from "./effect.types";
|
||||||
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
import type { Transaction } from "@shared/lib/types";
|
||||||
|
|
||||||
export const effectHandlers: Record<string, EffectHandler> = {
|
export const effectHandlers: Record<string, EffectHandler> = {
|
||||||
'ADD_XP': handleAddXp,
|
'ADD_XP': handleAddXp,
|
||||||
@@ -18,3 +21,21 @@ export const effectHandlers: Record<string, EffectHandler> = {
|
|||||||
'COLOR_ROLE': handleColorRole,
|
'COLOR_ROLE': handleColorRole,
|
||||||
'LOOTBOX': handleLootbox
|
'LOOTBOX': handleLootbox
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function validateAndExecuteEffect(
|
||||||
|
effect: unknown,
|
||||||
|
userId: string,
|
||||||
|
tx: Transaction
|
||||||
|
) {
|
||||||
|
const result = EffectPayloadSchema.safeParse(effect);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new UserError(`Invalid effect configuration: ${result.error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = effectHandlers[result.data.type];
|
||||||
|
if (!handler) {
|
||||||
|
throw new UserError(`Unknown effect type: ${result.data.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler(userId, result.data, tx);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,71 @@
|
|||||||
import type { Transaction } from "@shared/lib/types";
|
import type { Transaction } from "@shared/lib/types";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { EffectType, LootType } from "@shared/lib/constants";
|
||||||
|
|
||||||
|
// Helper Schemas
|
||||||
|
const LootTableItemSchema = z.object({
|
||||||
|
type: z.nativeEnum(LootType),
|
||||||
|
weight: z.number(),
|
||||||
|
amount: z.number().optional(),
|
||||||
|
itemId: z.number().optional(),
|
||||||
|
minAmount: z.number().optional(),
|
||||||
|
maxAmount: z.number().optional(),
|
||||||
|
message: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const DurationSchema = z.object({
|
||||||
|
durationSeconds: z.number().optional(),
|
||||||
|
durationMinutes: z.number().optional(),
|
||||||
|
durationHours: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Effect Schemas
|
||||||
|
const AddXpSchema = z.object({
|
||||||
|
type: z.literal(EffectType.ADD_XP),
|
||||||
|
amount: z.number().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const AddBalanceSchema = z.object({
|
||||||
|
type: z.literal(EffectType.ADD_BALANCE),
|
||||||
|
amount: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ReplyMessageSchema = z.object({
|
||||||
|
type: z.literal(EffectType.REPLY_MESSAGE),
|
||||||
|
message: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const XpBoostSchema = DurationSchema.extend({
|
||||||
|
type: z.literal(EffectType.XP_BOOST),
|
||||||
|
multiplier: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const TempRoleSchema = DurationSchema.extend({
|
||||||
|
type: z.literal(EffectType.TEMP_ROLE),
|
||||||
|
roleId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ColorRoleSchema = z.object({
|
||||||
|
type: z.literal(EffectType.COLOR_ROLE),
|
||||||
|
roleId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const LootboxSchema = z.object({
|
||||||
|
type: z.literal(EffectType.LOOTBOX),
|
||||||
|
pool: z.array(LootTableItemSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Union Schema
|
||||||
|
export const EffectPayloadSchema = z.discriminatedUnion('type', [
|
||||||
|
AddXpSchema,
|
||||||
|
AddBalanceSchema,
|
||||||
|
ReplyMessageSchema,
|
||||||
|
XpBoostSchema,
|
||||||
|
TempRoleSchema,
|
||||||
|
ColorRoleSchema,
|
||||||
|
LootboxSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type ValidatedEffectPayload = z.infer<typeof EffectPayloadSchema>;
|
||||||
|
|
||||||
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<any>;
|
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<any>;
|
||||||
|
|||||||
345
bun.lock
345
bun.lock
@@ -20,8 +20,71 @@
|
|||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"panel": {
|
||||||
|
"name": "panel",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@imgly/background-removal": "^1.7.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.564.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.10",
|
||||||
|
"@types/react": "^19.1.6",
|
||||||
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.10",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||||
|
|
||||||
|
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||||
|
|
||||||
|
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||||
|
|
||||||
|
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||||
|
|
||||||
|
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||||
|
|
||||||
|
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||||
|
|
||||||
|
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||||
|
|
||||||
|
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||||
|
|
||||||
|
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||||
|
|
||||||
|
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||||
|
|
||||||
|
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
|
||||||
|
|
||||||
|
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
|
||||||
|
|
||||||
|
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
|
||||||
|
|
||||||
|
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||||
|
|
||||||
|
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||||
|
|
||||||
|
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||||
|
|
||||||
|
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||||
|
|
||||||
"@discordjs/builders": ["@discordjs/builders@1.13.0", "", { "dependencies": { "@discordjs/formatters": "^0.6.1", "@discordjs/util": "^1.1.1", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.31", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q=="],
|
"@discordjs/builders": ["@discordjs/builders@1.13.0", "", { "dependencies": { "@discordjs/formatters": "^0.6.1", "@discordjs/util": "^1.1.1", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.31", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q=="],
|
||||||
|
|
||||||
"@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
|
"@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
|
||||||
@@ -92,6 +155,18 @@
|
|||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||||
|
|
||||||
|
"@imgly/background-removal": ["@imgly/background-removal@1.7.0", "", { "dependencies": { "lodash-es": "^4.17.21", "ndarray": "~1.0.0", "zod": "^3.23.8" }, "peerDependencies": { "onnxruntime-web": "1.21.0" } }, "sha512-/1ZryrMYg2ckIvJKoTu5Np50JfYMVffDMlVmppw/BdbN3pBTN7e6stI5/7E/LVh9DDzz6J588s7sWqul3fy5wA=="],
|
||||||
|
|
||||||
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.89", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.89", "@napi-rs/canvas-darwin-arm64": "0.1.89", "@napi-rs/canvas-darwin-x64": "0.1.89", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.89", "@napi-rs/canvas-linux-arm64-gnu": "0.1.89", "@napi-rs/canvas-linux-arm64-musl": "0.1.89", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-musl": "0.1.89", "@napi-rs/canvas-win32-arm64-msvc": "0.1.89", "@napi-rs/canvas-win32-x64-msvc": "0.1.89" } }, "sha512-7GjmkMirJHejeALCqUnZY3QwID7bbumOiLrqq2LKgxrdjdmxWQBTc6rcASa2u8wuWrH7qo4/4n/VNrOwCoKlKg=="],
|
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.89", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.89", "@napi-rs/canvas-darwin-arm64": "0.1.89", "@napi-rs/canvas-darwin-x64": "0.1.89", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.89", "@napi-rs/canvas-linux-arm64-gnu": "0.1.89", "@napi-rs/canvas-linux-arm64-musl": "0.1.89", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-musl": "0.1.89", "@napi-rs/canvas-win32-arm64-msvc": "0.1.89", "@napi-rs/canvas-win32-x64-msvc": "0.1.89" } }, "sha512-7GjmkMirJHejeALCqUnZY3QwID7bbumOiLrqq2LKgxrdjdmxWQBTc6rcASa2u8wuWrH7qo4/4n/VNrOwCoKlKg=="],
|
||||||
|
|
||||||
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.89", "", { "os": "android", "cpu": "arm64" }, "sha512-CXxQTXsjtQqKGENS8Ejv9pZOFJhOPIl2goenS+aU8dY4DygvkyagDhy/I07D1YLqrDtPvLEX5zZHt8qUdnuIpQ=="],
|
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.89", "", { "os": "android", "cpu": "arm64" }, "sha512-CXxQTXsjtQqKGENS8Ejv9pZOFJhOPIl2goenS+aU8dY4DygvkyagDhy/I07D1YLqrDtPvLEX5zZHt8qUdnuIpQ=="],
|
||||||
@@ -116,26 +191,162 @@
|
|||||||
|
|
||||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="],
|
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="],
|
||||||
|
|
||||||
|
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||||
|
|
||||||
|
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||||
|
|
||||||
|
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
|
||||||
|
|
||||||
|
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
|
||||||
|
|
||||||
|
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
|
||||||
|
|
||||||
|
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
||||||
|
|
||||||
|
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
|
||||||
|
|
||||||
|
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
||||||
|
|
||||||
|
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||||
|
|
||||||
|
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
||||||
|
|
||||||
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
|
||||||
|
|
||||||
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
|
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
|
||||||
|
|
||||||
"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
|
"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
|
||||||
|
|
||||||
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="],
|
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
|
||||||
|
|
||||||
|
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
||||||
|
|
||||||
|
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||||
|
|
||||||
|
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||||
|
|
||||||
|
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||||
|
|
||||||
|
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||||
|
|
||||||
|
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||||
|
|
||||||
|
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||||
|
|
||||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||||
|
|
||||||
|
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||||
|
|
||||||
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
|
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
|
||||||
|
|
||||||
|
"autoprefixer": ["autoprefixer@10.4.24", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001766", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw=="],
|
||||||
|
|
||||||
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
||||||
|
|
||||||
|
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||||
|
|
||||||
|
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
|
||||||
|
|
||||||
|
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||||
|
|
||||||
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
"discord-api-types": ["discord-api-types@0.38.34", "", {}, "sha512-muq7xKGznj5MSFCzuIm/2TO7DpttuomUTemVM82fRqgnMl70YRzEyY24jlbiV6R9tzOTq6A6UnZ0bsfZeKD38Q=="],
|
"discord-api-types": ["discord-api-types@0.38.34", "", {}, "sha512-muq7xKGznj5MSFCzuIm/2TO7DpttuomUTemVM82fRqgnMl70YRzEyY24jlbiV6R9tzOTq6A6UnZ0bsfZeKD38Q=="],
|
||||||
|
|
||||||
"discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="],
|
"discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="],
|
||||||
@@ -146,30 +357,144 @@
|
|||||||
|
|
||||||
"drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
|
"drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
|
||||||
|
|
||||||
|
"electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="],
|
||||||
|
|
||||||
|
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
|
||||||
|
|
||||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||||
|
|
||||||
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
||||||
|
|
||||||
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="],
|
||||||
|
|
||||||
|
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||||
|
|
||||||
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
||||||
|
|
||||||
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="],
|
||||||
|
|
||||||
|
"iota-array": ["iota-array@1.0.0", "", {}, "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA=="],
|
||||||
|
|
||||||
|
"is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="],
|
||||||
|
|
||||||
|
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||||
|
|
||||||
|
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
|
|
||||||
|
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||||
|
|
||||||
|
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||||
|
|
||||||
|
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
||||||
|
|
||||||
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
|
||||||
|
|
||||||
|
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
||||||
|
|
||||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||||
|
|
||||||
|
"lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
|
||||||
|
|
||||||
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
|
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
|
||||||
|
|
||||||
|
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||||
|
|
||||||
|
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
|
"lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="],
|
||||||
|
|
||||||
"magic-bytes.js": ["magic-bytes.js@1.12.1", "", {}, "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA=="],
|
"magic-bytes.js": ["magic-bytes.js@1.12.1", "", {}, "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA=="],
|
||||||
|
|
||||||
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"ndarray": ["ndarray@1.0.19", "", { "dependencies": { "iota-array": "^1.0.0", "is-buffer": "^1.0.2" } }, "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ=="],
|
||||||
|
|
||||||
|
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||||
|
|
||||||
|
"onnxruntime-common": ["onnxruntime-common@1.21.0", "", {}, "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ=="],
|
||||||
|
|
||||||
|
"onnxruntime-web": ["onnxruntime-web@1.21.0", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.21.0", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-adzOe+7uI7lKz6pQNbAsLMQd2Fq5Jhmoxd8LZjJr8m3KvbFyiYyRxRiC57/XXD+jb18voppjeGAjoZmskXG+7A=="],
|
||||||
|
|
||||||
|
"panel": ["panel@workspace:panel"],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|
||||||
|
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
|
|
||||||
|
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||||
|
|
||||||
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
|
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
|
||||||
|
|
||||||
|
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
||||||
|
|
||||||
|
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||||
|
|
||||||
|
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||||
|
|
||||||
|
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||||
|
|
||||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||||
|
|
||||||
|
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
|
||||||
|
|
||||||
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
|
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||||
|
|
||||||
|
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||||
|
|
||||||
|
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||||
|
|
||||||
|
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
|
||||||
|
|
||||||
|
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
||||||
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
|
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
|
||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
@@ -180,8 +505,14 @@
|
|||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
|
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||||
|
|
||||||
|
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||||
|
|
||||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||||
|
|
||||||
|
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
|
|
||||||
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||||
@@ -190,6 +521,20 @@
|
|||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||||
|
|
||||||
|
"@imgly/background-removal/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||||
|
|||||||
@@ -34,9 +34,11 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.prod
|
dockerfile: Dockerfile
|
||||||
target: production
|
target: production
|
||||||
image: aurora-app:latest
|
image: aurora-app:latest
|
||||||
|
volumes:
|
||||||
|
- ./bot/assets/graphics/items:/app/bot/assets/graphics/items
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:3000"
|
- "127.0.0.1:3000:3000"
|
||||||
|
|
||||||
@@ -53,6 +55,10 @@ services:
|
|||||||
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
|
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
|
||||||
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
||||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
||||||
|
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
|
||||||
|
- SESSION_SECRET=${SESSION_SECRET}
|
||||||
|
- ADMIN_USER_IDS=${ADMIN_USER_IDS}
|
||||||
|
- PANEL_BASE_URL=${PANEL_BASE_URL:-https://aurora.syntaxbullet.com}
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -76,6 +82,8 @@ services:
|
|||||||
studio:
|
studio:
|
||||||
container_name: aurora_studio
|
container_name: aurora_studio
|
||||||
image: aurora-app:latest
|
image: aurora-app:latest
|
||||||
|
volumes:
|
||||||
|
- ./bot/assets/graphics/items:/app/bot/assets/graphics/items
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -90,6 +98,10 @@ services:
|
|||||||
- DB_PORT=5432
|
- DB_PORT=5432
|
||||||
- DB_HOST=db
|
- DB_HOST=db
|
||||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
||||||
|
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
|
||||||
|
- SESSION_SECRET=${SESSION_SECRET}
|
||||||
|
- ADMIN_USER_IDS=${ADMIN_USER_IDS}
|
||||||
|
- PANEL_BASE_URL=${PANEL_BASE_URL:-https://aurora.syntaxbullet.com}
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
- web
|
- web
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ services:
|
|||||||
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
|
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
|
||||||
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
||||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
||||||
|
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
|
||||||
|
- SESSION_SECRET=${SESSION_SECRET}
|
||||||
|
- ADMIN_USER_IDS=${ADMIN_USER_IDS}
|
||||||
|
- PANEL_BASE_URL=${PANEL_BASE_URL:-http://localhost:3000}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
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.*
|
||||||
27
package.json
27
package.json
@@ -4,6 +4,7 @@
|
|||||||
"module": "bot/index.ts",
|
"module": "bot/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"workspaces": ["panel"],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"drizzle-kit": "^0.31.8"
|
"drizzle-kit": "^0.31.8"
|
||||||
@@ -12,19 +13,29 @@
|
|||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate": "docker compose run --rm app drizzle-kit generate",
|
"dev": "bun --watch bot/index.ts",
|
||||||
"migrate": "docker compose run --rm app drizzle-kit migrate",
|
"logs": "bash shared/scripts/logs.sh",
|
||||||
|
"remote": "bash shared/scripts/remote.sh",
|
||||||
|
"db:generate": "docker compose run --rm app drizzle-kit generate",
|
||||||
|
"db:migrate": "docker compose run --rm app drizzle-kit migrate",
|
||||||
|
"generate": "bun run db:generate",
|
||||||
|
"migrate": "bun run db:migrate",
|
||||||
"db:push": "docker compose run --rm app drizzle-kit push",
|
"db:push": "docker compose run --rm app drizzle-kit push",
|
||||||
"db:push:local": "drizzle-kit push",
|
"db:push:local": "drizzle-kit push",
|
||||||
"dev": "bun --watch bot/index.ts",
|
|
||||||
"db:studio": "drizzle-kit studio --port 4983 --host 0.0.0.0",
|
"db:studio": "drizzle-kit studio --port 4983 --host 0.0.0.0",
|
||||||
|
"db:backup": "bash shared/scripts/db-backup.sh",
|
||||||
|
"db:restore": "bash shared/scripts/db-restore.sh",
|
||||||
"db:migrate-config": "docker compose run --rm app bun shared/scripts/migrate-config-to-db.ts",
|
"db:migrate-config": "docker compose run --rm app bun shared/scripts/migrate-config-to-db.ts",
|
||||||
"db:migrate-game-config": "docker compose run --rm app bun shared/scripts/migrate-game-settings-to-db.ts",
|
"db:migrate-game-config": "docker compose run --rm app bun shared/scripts/migrate-game-settings-to-db.ts",
|
||||||
"db:migrate-all": "docker compose run --rm app sh -c 'bun shared/scripts/migrate-config-to-db.ts && bun shared/scripts/migrate-game-settings-to-db.ts'",
|
"db:migrate-all": "docker compose run --rm app sh -c 'bun shared/scripts/migrate-config-to-db.ts && bun shared/scripts/migrate-game-settings-to-db.ts'",
|
||||||
"remote": "bash shared/scripts/remote.sh",
|
"test": "bash shared/scripts/test-sequential.sh",
|
||||||
"logs": "bash shared/scripts/logs.sh",
|
"test:ci": "bash shared/scripts/test-sequential.sh --integration",
|
||||||
"db:backup": "bash shared/scripts/db-backup.sh",
|
"test:simulate-ci": "bash shared/scripts/simulate-ci.sh",
|
||||||
"test": "bun test",
|
"panel:dev": "cd panel && bun run dev",
|
||||||
|
"panel:build": "cd panel && bun run build",
|
||||||
|
"deploy": "bash shared/scripts/deploy.sh",
|
||||||
|
"deploy:remote": "bash shared/scripts/deploy-remote.sh",
|
||||||
|
"setup-server": "bash shared/scripts/setup-server.sh",
|
||||||
"docker:cleanup": "bash shared/scripts/docker-cleanup.sh"
|
"docker:cleanup": "bash shared/scripts/docker-cleanup.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -35,4 +46,4 @@
|
|||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
12
panel/index.html
Normal file
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>
|
||||||
|
);
|
||||||
882
panel/src/pages/BackgroundRemoval.tsx
Normal file
882
panel/src/pages/BackgroundRemoval.tsx
Normal file
@@ -0,0 +1,882 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
import { Upload, Download, X, Wand2, ImageIcon, Loader2 } from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CHECKERBOARD: React.CSSProperties = {
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(45deg, #444 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #444 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #444 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #444 75%)
|
||||||
|
`,
|
||||||
|
backgroundSize: "16px 16px",
|
||||||
|
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
};
|
||||||
|
|
||||||
|
type BgPreset = { label: string; style: React.CSSProperties };
|
||||||
|
|
||||||
|
const BG_PRESETS: BgPreset[] = [
|
||||||
|
{ label: "Checker", style: CHECKERBOARD },
|
||||||
|
{ label: "White", style: { backgroundColor: "#ffffff" } },
|
||||||
|
{ label: "Black", style: { backgroundColor: "#000000" } },
|
||||||
|
{ label: "Red", style: { backgroundColor: "#e53e3e" } },
|
||||||
|
{ label: "Green", style: { backgroundColor: "#38a169" } },
|
||||||
|
{ label: "Blue", style: { backgroundColor: "#3182ce" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Max normalised distances for each keying space
|
||||||
|
const MAX_RGB = Math.sqrt(3); // ≈ 1.732
|
||||||
|
const MAX_HSV = 1.5; // sqrt(0.5² × 4 + 1² + 1² × 0.25)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function rgbToHex(r: number, g: number, b: number): string {
|
||||||
|
return `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex: string): [number, number, number] | null {
|
||||||
|
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
return [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WebGL — shaders
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const VERT = `
|
||||||
|
attribute vec2 aPos;
|
||||||
|
varying vec2 vUV;
|
||||||
|
void main() {
|
||||||
|
vUV = aPos * 0.5 + 0.5;
|
||||||
|
gl_Position = vec4(aPos, 0.0, 1.0);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const KEY_FRAG = `
|
||||||
|
precision mediump float;
|
||||||
|
uniform sampler2D uImage;
|
||||||
|
uniform vec3 uKey;
|
||||||
|
uniform float uTol;
|
||||||
|
uniform float uFeather;
|
||||||
|
uniform float uSatMin;
|
||||||
|
uniform float uSpill;
|
||||||
|
uniform float uHueMode;
|
||||||
|
varying vec2 vUV;
|
||||||
|
|
||||||
|
vec3 rgbToHsv(vec3 c) {
|
||||||
|
float cmax = max(c.r, max(c.g, c.b));
|
||||||
|
float cmin = min(c.r, min(c.g, c.b));
|
||||||
|
float delta = cmax - cmin;
|
||||||
|
float h = 0.0;
|
||||||
|
float s = (cmax < 0.0001) ? 0.0 : delta / cmax;
|
||||||
|
if (delta > 0.0001) {
|
||||||
|
if (cmax == c.r) h = mod((c.g - c.b) / delta, 6.0);
|
||||||
|
else if (cmax == c.g) h = (c.b - c.r) / delta + 2.0;
|
||||||
|
else h = (c.r - c.g) / delta + 4.0;
|
||||||
|
h /= 6.0;
|
||||||
|
}
|
||||||
|
return vec3(h, s, cmax);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 c = texture2D(uImage, vUV);
|
||||||
|
vec3 hsv = rgbToHsv(c.rgb);
|
||||||
|
vec3 keyHsv = rgbToHsv(uKey);
|
||||||
|
|
||||||
|
float d;
|
||||||
|
if (uHueMode > 0.5) {
|
||||||
|
float dh = abs(hsv.x - keyHsv.x);
|
||||||
|
if (dh > 0.5) dh = 1.0 - dh;
|
||||||
|
float ds = abs(hsv.y - keyHsv.y);
|
||||||
|
float dv = abs(hsv.z - keyHsv.z);
|
||||||
|
d = sqrt(dh * dh * 4.0 + ds * ds + dv * dv * 0.25);
|
||||||
|
} else {
|
||||||
|
d = distance(c.rgb, uKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
float a = c.a;
|
||||||
|
if (hsv.y >= uSatMin) {
|
||||||
|
if (d <= uTol) {
|
||||||
|
a = 0.0;
|
||||||
|
} else if (uFeather > 0.0 && d <= uTol + uFeather) {
|
||||||
|
a = (d - uTol) / uFeather * c.a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 rgb = c.rgb;
|
||||||
|
if (uSpill > 0.0) {
|
||||||
|
float edgeDist = max(0.0, d - uTol);
|
||||||
|
float spillZone = max(uFeather + uTol * 0.5, 0.01);
|
||||||
|
float spillFact = clamp(1.0 - edgeDist / spillZone, 0.0, 1.0) * uSpill;
|
||||||
|
|
||||||
|
if (uKey.g >= uKey.r && uKey.g >= uKey.b) {
|
||||||
|
float excess = rgb.g - max(rgb.r, rgb.b);
|
||||||
|
if (excess > 0.0) rgb.g -= excess * spillFact;
|
||||||
|
} else if (uKey.b >= uKey.r && uKey.b >= uKey.g) {
|
||||||
|
float excess = rgb.b - max(rgb.r, rgb.g);
|
||||||
|
if (excess > 0.0) rgb.b -= excess * spillFact;
|
||||||
|
} else {
|
||||||
|
float excess = rgb.r - max(rgb.g, rgb.b);
|
||||||
|
if (excess > 0.0) rgb.r -= excess * spillFact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gl_FragColor = vec4(rgb, a);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const HALO_FRAG = `
|
||||||
|
precision mediump float;
|
||||||
|
uniform sampler2D uKeyed;
|
||||||
|
uniform float uHaloStr;
|
||||||
|
uniform float uHaloRadius;
|
||||||
|
uniform vec2 uTexelSize;
|
||||||
|
varying vec2 vUV;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 c = texture2D(uKeyed, vUV);
|
||||||
|
|
||||||
|
if (uHaloStr <= 0.0) {
|
||||||
|
gl_FragColor = c;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec2 r = uTexelSize * uHaloRadius;
|
||||||
|
vec2 rd = r * 0.7071;
|
||||||
|
|
||||||
|
float minA = c.a;
|
||||||
|
minA = min(minA, texture2D(uKeyed, vUV + vec2( r.x, 0.0 )).a);
|
||||||
|
minA = min(minA, texture2D(uKeyed, vUV + vec2(-r.x, 0.0 )).a);
|
||||||
|
minA = min(minA, texture2D(uKeyed, vUV + vec2( 0.0, r.y )).a);
|
||||||
|
minA = min(minA, texture2D(uKeyed, vUV + vec2( 0.0, -r.y )).a);
|
||||||
|
minA = min(minA, texture2D(uKeyed, vUV + vec2( rd.x, rd.y)).a);
|
||||||
|
minA = min(minA, texture2D(uKeyed, vUV + vec2(-rd.x, rd.y)).a);
|
||||||
|
minA = min(minA, texture2D(uKeyed, vUV + vec2( rd.x, -rd.y)).a);
|
||||||
|
minA = min(minA, texture2D(uKeyed, vUV + vec2(-rd.x, -rd.y)).a);
|
||||||
|
|
||||||
|
gl_FragColor = vec4(c.rgb, mix(c.a, minA, uHaloStr));
|
||||||
|
}`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WebGL — types + init
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type GlState = {
|
||||||
|
gl: WebGLRenderingContext;
|
||||||
|
kProg: WebGLProgram;
|
||||||
|
srcTex: WebGLTexture;
|
||||||
|
fbo: WebGLFramebuffer;
|
||||||
|
fboTex: WebGLTexture;
|
||||||
|
uKey: WebGLUniformLocation;
|
||||||
|
uTol: WebGLUniformLocation;
|
||||||
|
uFeather: WebGLUniformLocation;
|
||||||
|
uSatMin: WebGLUniformLocation;
|
||||||
|
uSpill: WebGLUniformLocation;
|
||||||
|
uHueMode: WebGLUniformLocation;
|
||||||
|
hProg: WebGLProgram;
|
||||||
|
uHaloStr: WebGLUniformLocation;
|
||||||
|
uHaloRadius: WebGLUniformLocation;
|
||||||
|
uTexelSize: WebGLUniformLocation;
|
||||||
|
};
|
||||||
|
|
||||||
|
function compileShader(gl: WebGLRenderingContext, type: number, src: string): WebGLShader {
|
||||||
|
const s = gl.createShader(type)!;
|
||||||
|
gl.shaderSource(s, src);
|
||||||
|
gl.compileShader(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProgram(gl: WebGLRenderingContext, vs: WebGLShader, fs: WebGLShader): WebGLProgram {
|
||||||
|
const p = gl.createProgram()!;
|
||||||
|
gl.attachShader(p, vs);
|
||||||
|
gl.attachShader(p, fs);
|
||||||
|
gl.bindAttribLocation(p, 0, "aPos");
|
||||||
|
gl.linkProgram(p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTexture(gl: WebGLRenderingContext): WebGLTexture {
|
||||||
|
const t = gl.createTexture()!;
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, t);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initGl(canvas: HTMLCanvasElement): GlState | null {
|
||||||
|
const gl = canvas.getContext("webgl", {
|
||||||
|
premultipliedAlpha: false,
|
||||||
|
preserveDrawingBuffer: true,
|
||||||
|
}) as WebGLRenderingContext | null;
|
||||||
|
if (!gl) return null;
|
||||||
|
|
||||||
|
const vs = compileShader(gl, gl.VERTEX_SHADER, VERT);
|
||||||
|
const kFrag = compileShader(gl, gl.FRAGMENT_SHADER, KEY_FRAG);
|
||||||
|
const hFrag = compileShader(gl, gl.FRAGMENT_SHADER, HALO_FRAG);
|
||||||
|
const kProg = makeProgram(gl, vs, kFrag);
|
||||||
|
const hProg = makeProgram(gl, vs, hFrag);
|
||||||
|
|
||||||
|
const buf = gl.createBuffer()!;
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
|
||||||
|
gl.enableVertexAttribArray(0);
|
||||||
|
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
const srcTex = makeTexture(gl);
|
||||||
|
const fboTex = makeTexture(gl);
|
||||||
|
|
||||||
|
const fbo = gl.createFramebuffer()!;
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
|
||||||
|
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, fboTex, 0);
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
gl, kProg, hProg, srcTex, fbo, fboTex,
|
||||||
|
uKey: gl.getUniformLocation(kProg, "uKey")!,
|
||||||
|
uTol: gl.getUniformLocation(kProg, "uTol")!,
|
||||||
|
uFeather: gl.getUniformLocation(kProg, "uFeather")!,
|
||||||
|
uSatMin: gl.getUniformLocation(kProg, "uSatMin")!,
|
||||||
|
uSpill: gl.getUniformLocation(kProg, "uSpill")!,
|
||||||
|
uHueMode: gl.getUniformLocation(kProg, "uHueMode")!,
|
||||||
|
uHaloStr: gl.getUniformLocation(hProg, "uHaloStr")!,
|
||||||
|
uHaloRadius: gl.getUniformLocation(hProg, "uHaloRadius")!,
|
||||||
|
uTexelSize: gl.getUniformLocation(hProg, "uTexelSize")!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AI remove tab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type AiStatus = "idle" | "loading" | "done" | "error";
|
||||||
|
|
||||||
|
function AiRemoveTab({ imageFile, imageSrc, onClear }: {
|
||||||
|
imageFile: File;
|
||||||
|
imageSrc: string;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
const [status, setStatus] = useState<AiStatus>("idle");
|
||||||
|
const [resultUrl, setResultUrl] = useState<string | null>(null);
|
||||||
|
const [bgPreset, setBgPreset] = useState(0);
|
||||||
|
const [progress, setProgress] = useState("");
|
||||||
|
|
||||||
|
const handleRemove = async () => {
|
||||||
|
setStatus("loading");
|
||||||
|
setProgress("Loading AI model…");
|
||||||
|
try {
|
||||||
|
const { removeBackground } = await import("@imgly/background-removal");
|
||||||
|
setProgress("Removing background…");
|
||||||
|
const blob = await removeBackground(imageSrc, {
|
||||||
|
progress: (_key: string, current: number, total: number) => {
|
||||||
|
if (total > 0) {
|
||||||
|
setProgress(`Downloading model… ${Math.round((current / total) * 100)}%`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
setResultUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return url; });
|
||||||
|
setStatus("done");
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (!resultUrl) return;
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.download = imageFile.name.replace(/\.[^.]+$/, "") + "_nobg.png";
|
||||||
|
a.href = resultUrl;
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
|
||||||
|
{imageFile.name}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
||||||
|
"hover:text-destructive hover:border-destructive transition-colors",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" /> Clear
|
||||||
|
</button>
|
||||||
|
{status !== "done" ? (
|
||||||
|
<button
|
||||||
|
onClick={handleRemove}
|
||||||
|
disabled={status === "loading"}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
||||||
|
status === "loading"
|
||||||
|
? "bg-raised border border-border text-text-tertiary cursor-not-allowed"
|
||||||
|
: "bg-primary text-white hover:bg-primary/90",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status === "loading" ? (
|
||||||
|
<><Loader2 className="w-3.5 h-3.5 animate-spin" /> {progress}</>
|
||||||
|
) : (
|
||||||
|
<><Wand2 className="w-3.5 h-3.5" /> Remove Background</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold bg-primary text-white hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" /> Download PNG
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === "error" && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
Something went wrong. Check the console for details.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Side-by-side */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Original</p>
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
<img src={imageSrc} className="w-full block" alt="Original" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex-1">
|
||||||
|
Result — transparent background
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{BG_PRESETS.map((preset, i) => (
|
||||||
|
<button
|
||||||
|
key={preset.label}
|
||||||
|
title={preset.label}
|
||||||
|
onClick={() => setBgPreset(i)}
|
||||||
|
className={cn(
|
||||||
|
"w-5 h-5 rounded border transition-all",
|
||||||
|
i === bgPreset
|
||||||
|
? "ring-2 ring-primary ring-offset-1 ring-offset-background border-transparent"
|
||||||
|
: "border-border hover:border-primary/50",
|
||||||
|
)}
|
||||||
|
style={preset.style}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
{resultUrl ? (
|
||||||
|
<div style={BG_PRESETS[bgPreset].style}>
|
||||||
|
<img src={resultUrl} className="w-full block" alt="Result" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="aspect-square flex flex-col items-center justify-center text-text-tertiary gap-3 p-8">
|
||||||
|
{status === "loading" ? (
|
||||||
|
<><Loader2 className="w-10 h-10 opacity-40 animate-spin" /><span className="text-xs opacity-40 text-center">{progress}</span></>
|
||||||
|
) : (
|
||||||
|
<><ImageIcon className="w-10 h-10 opacity-20" /><span className="text-xs opacity-40 text-center">Click Remove Background to process</span></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BackgroundRemoval component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Mode = "chroma" | "ai";
|
||||||
|
|
||||||
|
export function BackgroundRemoval() {
|
||||||
|
const [mode, setMode] = useState<Mode>("chroma");
|
||||||
|
|
||||||
|
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||||
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||||
|
const [imageReady, setImageReady] = useState(false);
|
||||||
|
const [keyColor, setKeyColor] = useState<[number, number, number] | null>(null);
|
||||||
|
const [hexInput, setHexInput] = useState("");
|
||||||
|
const [tolerance, setTolerance] = useState(30);
|
||||||
|
const [feather, setFeather] = useState(10);
|
||||||
|
const [satMin, setSatMin] = useState(0);
|
||||||
|
const [spillStr, setSpillStr] = useState(0);
|
||||||
|
const [hueMode, setHueMode] = useState(false);
|
||||||
|
const [haloStr, setHaloStr] = useState(0);
|
||||||
|
const [haloRadius, setHaloRadius] = useState(2);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [bgPreset, setBgPreset] = useState(0);
|
||||||
|
|
||||||
|
const sourceCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const glCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const glRef = useRef<GlState | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (keyColor) setHexInput(rgbToHex(...keyColor));
|
||||||
|
}, [keyColor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageSrc) return;
|
||||||
|
setImageReady(false);
|
||||||
|
setKeyColor(null);
|
||||||
|
setHexInput("");
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const src = sourceCanvasRef.current;
|
||||||
|
if (!src) return;
|
||||||
|
src.width = img.naturalWidth;
|
||||||
|
src.height = img.naturalHeight;
|
||||||
|
src.getContext("2d")!.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
const glCanvas = glCanvasRef.current;
|
||||||
|
if (!glCanvas) return;
|
||||||
|
glCanvas.width = img.naturalWidth;
|
||||||
|
glCanvas.height = img.naturalHeight;
|
||||||
|
|
||||||
|
if (!glRef.current) {
|
||||||
|
glRef.current = initGl(glCanvas);
|
||||||
|
if (!glRef.current) {
|
||||||
|
console.error("BackgroundRemoval: WebGL not supported");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { gl, srcTex, fboTex } = glRef.current;
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, srcTex);
|
||||||
|
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
||||||
|
gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
|
||||||
|
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
||||||
|
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
||||||
|
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, fboTex);
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, img.naturalWidth, img.naturalHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||||||
|
gl.viewport(0, 0, img.naturalWidth, img.naturalHeight);
|
||||||
|
setImageReady(true);
|
||||||
|
};
|
||||||
|
img.src = imageSrc;
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const state = glRef.current;
|
||||||
|
if (!state || !imageReady || !keyColor) return;
|
||||||
|
|
||||||
|
const w = glCanvasRef.current!.width;
|
||||||
|
const h = glCanvasRef.current!.height;
|
||||||
|
const { gl } = state;
|
||||||
|
const MAX = hueMode ? MAX_HSV : MAX_RGB;
|
||||||
|
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, state.fbo);
|
||||||
|
gl.viewport(0, 0, w, h);
|
||||||
|
gl.useProgram(state.kProg);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, state.srcTex);
|
||||||
|
gl.uniform3f(state.uKey, keyColor[0] / 255, keyColor[1] / 255, keyColor[2] / 255);
|
||||||
|
gl.uniform1f(state.uTol, (tolerance / 100) * MAX);
|
||||||
|
gl.uniform1f(state.uFeather, (feather / 100) * MAX);
|
||||||
|
gl.uniform1f(state.uSatMin, satMin / 100);
|
||||||
|
gl.uniform1f(state.uSpill, spillStr / 100);
|
||||||
|
gl.uniform1f(state.uHueMode, hueMode ? 1.0 : 0.0);
|
||||||
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||||
|
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||||
|
gl.viewport(0, 0, w, h);
|
||||||
|
gl.useProgram(state.hProg);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, state.fboTex);
|
||||||
|
gl.uniform1f(state.uHaloStr, haloStr / 100);
|
||||||
|
gl.uniform1f(state.uHaloRadius, haloRadius);
|
||||||
|
gl.uniform2f(state.uTexelSize, 1 / w, 1 / h);
|
||||||
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||||
|
}, [keyColor, tolerance, feather, satMin, spillStr, hueMode, haloStr, haloRadius, imageReady]);
|
||||||
|
|
||||||
|
const loadFile = useCallback((file: File) => {
|
||||||
|
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||||
|
setImageSrc(URL.createObjectURL(file));
|
||||||
|
setImageFile(file);
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file?.type.startsWith("image/")) loadFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = sourceCanvasRef.current;
|
||||||
|
if (!canvas || !imageReady) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const scaleX = canvas.width / rect.width;
|
||||||
|
const scaleY = canvas.height / rect.height;
|
||||||
|
const x = Math.floor((e.clientX - rect.left) * scaleX);
|
||||||
|
const y = Math.floor((e.clientY - rect.top) * scaleY);
|
||||||
|
const px = canvas.getContext("2d")!.getImageData(x, y, 1, 1).data;
|
||||||
|
setKeyColor([px[0], px[1], px[2]]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHexInput = (v: string) => {
|
||||||
|
setHexInput(v);
|
||||||
|
const parsed = hexToRgb(v);
|
||||||
|
if (parsed) setKeyColor(parsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
const glCanvas = glCanvasRef.current;
|
||||||
|
if (!glCanvas || !keyColor || !imageFile) return;
|
||||||
|
glCanvas.toBlob((blob) => {
|
||||||
|
if (!blob) return;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.download = imageFile.name.replace(/\.[^.]+$/, "") + "_transparent.png";
|
||||||
|
a.href = url;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, "image/png");
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||||
|
setImageSrc(null);
|
||||||
|
setImageFile(null);
|
||||||
|
setImageReady(false);
|
||||||
|
setKeyColor(null);
|
||||||
|
setHexInput("");
|
||||||
|
glRef.current = null;
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
// ── Upload screen ──────────────────────────────────────────────────────────
|
||||||
|
if (!imageSrc) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-8 space-y-4">
|
||||||
|
{/* Page header + mode toggle */}
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-1">
|
||||||
|
Background Removal
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Upload an image then remove its background — either by selecting a
|
||||||
|
key color (chroma key) or using the AI model.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ModeToggle mode={mode} onChange={setMode} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className={cn(
|
||||||
|
"border-2 border-dashed rounded-xl p-16 flex flex-col items-center gap-4 cursor-pointer transition-all select-none",
|
||||||
|
dragOver
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-primary/50 hover:bg-primary/3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Upload className={cn("w-10 h-10 transition-colors", dragOver ? "text-primary" : "text-text-tertiary")} />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-text-secondary">
|
||||||
|
{dragOver ? "Drop to upload" : "Drop an image here"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-text-tertiary mt-1">
|
||||||
|
or click to browse · PNG, JPEG, WebP · max 15 MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) loadFile(f); }}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Editor screen ──────────────────────────────────────────────────────────
|
||||||
|
const hasResult = imageReady && keyColor !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 pb-8">
|
||||||
|
{/* Page header + mode toggle */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground flex-1">
|
||||||
|
Background Removal
|
||||||
|
</h2>
|
||||||
|
<ModeToggle mode={mode} onChange={setMode} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI tab */}
|
||||||
|
{mode === "ai" && (
|
||||||
|
<AiRemoveTab imageFile={imageFile!} imageSrc={imageSrc} onClear={clearAll} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chroma key tab */}
|
||||||
|
{mode === "chroma" && (
|
||||||
|
<>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
|
||||||
|
{imageFile?.name}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
||||||
|
"hover:text-destructive hover:border-destructive transition-colors",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" /> Clear
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={!hasResult}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
||||||
|
hasResult
|
||||||
|
? "bg-primary text-white hover:bg-primary/90"
|
||||||
|
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" /> Download PNG
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4 space-y-4">
|
||||||
|
{/* Row 1 — Key color + mode */}
|
||||||
|
<div className="flex flex-wrap gap-6 items-center">
|
||||||
|
<div className="space-y-1.5 shrink-0">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Key Color</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-7 h-7 rounded-md border border-border shadow-inner shrink-0"
|
||||||
|
style={
|
||||||
|
keyColor
|
||||||
|
? { backgroundColor: rgbToHex(...keyColor) }
|
||||||
|
: {
|
||||||
|
backgroundImage:
|
||||||
|
"linear-gradient(45deg,#444 25%,transparent 25%),linear-gradient(-45deg,#444 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#444 75%),linear-gradient(-45deg,transparent 75%,#444 75%)",
|
||||||
|
backgroundSize: "8px 8px",
|
||||||
|
backgroundPosition: "0 0,0 4px,4px -4px,-4px 0",
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={hexInput}
|
||||||
|
onChange={(e) => handleHexInput(e.target.value)}
|
||||||
|
placeholder="#rrggbb"
|
||||||
|
maxLength={7}
|
||||||
|
spellCheck={false}
|
||||||
|
className={cn(
|
||||||
|
"w-[76px] text-xs font-mono bg-transparent border rounded px-1.5 py-0.5",
|
||||||
|
"text-text-secondary focus:outline-none transition-colors",
|
||||||
|
hexInput && !hexToRgb(hexInput)
|
||||||
|
? "border-destructive focus:border-destructive"
|
||||||
|
: "border-border focus:border-primary",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 shrink-0">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Keying Mode</p>
|
||||||
|
<div className="flex rounded-md border border-border overflow-hidden text-xs font-medium">
|
||||||
|
<button
|
||||||
|
onClick={() => setHueMode(false)}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 transition-colors",
|
||||||
|
!hueMode ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
RGB
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setHueMode(true)}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 transition-colors border-l border-border",
|
||||||
|
hueMode ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
HSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!keyColor && (
|
||||||
|
<span className="text-xs text-text-tertiary flex items-center gap-1.5 ml-auto">
|
||||||
|
<Wand2 className="w-3.5 h-3.5" /> Click the image to pick a key color
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2 — Matte */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-text-tertiary mb-2">Matte</p>
|
||||||
|
<div className="flex flex-wrap gap-6">
|
||||||
|
<div className="space-y-1.5 flex-1 min-w-[130px]">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Tolerance</p>
|
||||||
|
<span className="text-xs font-mono text-text-tertiary">{tolerance}%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min="0" max="100" value={tolerance} onChange={(e) => setTolerance(Number(e.target.value))} className="w-full accent-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 flex-1 min-w-[130px]">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Sat. Gate</p>
|
||||||
|
<span className="text-xs font-mono text-text-tertiary">{satMin}%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min="0" max="100" value={satMin} onChange={(e) => setSatMin(Number(e.target.value))} className="w-full accent-primary" />
|
||||||
|
<p className="text-[10px] text-text-tertiary leading-tight">Skip pixels below this saturation — preserves neutral tones</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 flex-1 min-w-[130px]">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Edge Feather</p>
|
||||||
|
<span className="text-xs font-mono text-text-tertiary">{feather}%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min="0" max="50" value={feather} onChange={(e) => setFeather(Number(e.target.value))} className="w-full accent-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3 — Cleanup */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-text-tertiary mb-2">Cleanup</p>
|
||||||
|
<div className="flex flex-wrap gap-6">
|
||||||
|
<div className="space-y-1.5 flex-1 min-w-[130px]">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Despill</p>
|
||||||
|
<span className="text-xs font-mono text-text-tertiary">{spillStr}%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min="0" max="100" value={spillStr} onChange={(e) => setSpillStr(Number(e.target.value))} className="w-full accent-primary" />
|
||||||
|
<p className="text-[10px] text-text-tertiary leading-tight">Suppress key-color fringing on subject edges</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 flex-1 min-w-[130px]">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Halo Remove</p>
|
||||||
|
<span className="text-xs font-mono text-text-tertiary">{haloStr}%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min="0" max="100" value={haloStr} onChange={(e) => setHaloStr(Number(e.target.value))} className="w-full accent-primary" />
|
||||||
|
<p className="text-[10px] text-text-tertiary leading-tight">Erode the matte inward to eliminate bright rim pixels</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 flex-1 min-w-[130px]">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Halo Radius</p>
|
||||||
|
<span className="text-xs font-mono text-text-tertiary">{haloRadius} px</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min="1" max="8" step="1" value={haloRadius} onChange={(e) => setHaloRadius(Number(e.target.value))} className="w-full accent-primary" disabled={haloStr === 0} />
|
||||||
|
<p className="text-[10px] text-text-tertiary leading-tight">How far to look for transparent neighbours</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Side-by-side view */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||||
|
Original — click to pick key color
|
||||||
|
</p>
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
<canvas ref={sourceCanvasRef} className="w-full cursor-crosshair block" onClick={handleCanvasClick} title="Click a pixel to set it as the chroma key color" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex-1">
|
||||||
|
Result — transparent background
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{BG_PRESETS.map((preset, i) => (
|
||||||
|
<button
|
||||||
|
key={preset.label}
|
||||||
|
title={preset.label}
|
||||||
|
onClick={() => setBgPreset(i)}
|
||||||
|
className={cn(
|
||||||
|
"w-5 h-5 rounded border transition-all",
|
||||||
|
i === bgPreset
|
||||||
|
? "ring-2 ring-primary ring-offset-1 ring-offset-background border-transparent"
|
||||||
|
: "border-border hover:border-primary/50",
|
||||||
|
)}
|
||||||
|
style={preset.style}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
<div style={BG_PRESETS[bgPreset].style} className={cn("w-full", !hasResult && "hidden")}>
|
||||||
|
<canvas ref={glCanvasRef} className="w-full block" />
|
||||||
|
</div>
|
||||||
|
{!hasResult && (
|
||||||
|
<div className="aspect-square flex flex-col items-center justify-center text-text-tertiary gap-3 p-8">
|
||||||
|
<ImageIcon className="w-10 h-10 opacity-20" />
|
||||||
|
<span className="text-xs opacity-40 text-center leading-relaxed">
|
||||||
|
{imageReady ? "Click the image on the left to pick a key color" : "Loading image…"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ModeToggle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex rounded-md border border-border overflow-hidden text-xs font-medium shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => onChange("chroma")}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 transition-colors",
|
||||||
|
mode === "chroma" ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Chroma Key
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onChange("ai")}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 transition-colors border-l border-border",
|
||||||
|
mode === "ai" ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
AI Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
509
panel/src/pages/CanvasTool.tsx
Normal file
509
panel/src/pages/CanvasTool.tsx
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
import { Upload, Download, X, Lock, Unlock, ImageIcon, Maximize2 } from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CHECKERBOARD: React.CSSProperties = {
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(45deg, #444 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #444 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #444 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #444 75%)
|
||||||
|
`,
|
||||||
|
backgroundSize: "16px 16px",
|
||||||
|
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
};
|
||||||
|
|
||||||
|
type BgPreset = { label: string; style: React.CSSProperties };
|
||||||
|
|
||||||
|
const BG_PRESETS: BgPreset[] = [
|
||||||
|
{ label: "Checker", style: CHECKERBOARD },
|
||||||
|
{ label: "White", style: { backgroundColor: "#ffffff" } },
|
||||||
|
{ label: "Black", style: { backgroundColor: "#000000" } },
|
||||||
|
{ label: "Red", style: { backgroundColor: "#e53e3e" } },
|
||||||
|
{ label: "Green", style: { backgroundColor: "#38a169" } },
|
||||||
|
{ label: "Blue", style: { backgroundColor: "#3182ce" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
type ScaleMode = "fit" | "fill" | "stretch" | "original";
|
||||||
|
|
||||||
|
const SCALE_MODES: { id: ScaleMode; label: string; desc: string }[] = [
|
||||||
|
{ id: "fit", label: "Fit", desc: "Scale to fit canvas, preserve ratio (letterbox)" },
|
||||||
|
{ id: "fill", label: "Fill", desc: "Scale to fill canvas, preserve ratio (crop edges)" },
|
||||||
|
{ id: "stretch", label: "Stretch", desc: "Stretch to exact dimensions, ignore ratio" },
|
||||||
|
{ id: "original", label: "Original", desc: "No scaling — align/center only" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Align = [-1 | 0 | 1, -1 | 0 | 1];
|
||||||
|
|
||||||
|
const ALIGN_GRID: Align[] = [
|
||||||
|
[-1, -1], [0, -1], [1, -1],
|
||||||
|
[-1, 0], [0, 0], [1, 0],
|
||||||
|
[-1, 1], [0, 1], [1, 1],
|
||||||
|
];
|
||||||
|
|
||||||
|
const SIZE_PRESETS: { label: string; w: number; h: number }[] = [
|
||||||
|
{ label: "64", w: 64, h: 64 },
|
||||||
|
{ label: "128", w: 128, h: 128 },
|
||||||
|
{ label: "256", w: 256, h: 256 },
|
||||||
|
{ label: "512", w: 512, h: 512 },
|
||||||
|
{ label: "1024", w: 1024, h: 1024 },
|
||||||
|
{ label: "960×540", w: 960, h: 540 },
|
||||||
|
{ label: "1920×1080", w: 1920, h: 1080 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CanvasTool
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function CanvasTool() {
|
||||||
|
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||||
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||||
|
const [imageReady, setImageReady] = useState(false);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
|
||||||
|
const [naturalW, setNaturalW] = useState(1);
|
||||||
|
const [naturalH, setNaturalH] = useState(1);
|
||||||
|
|
||||||
|
const [outW, setOutW] = useState("256");
|
||||||
|
const [outH, setOutH] = useState("256");
|
||||||
|
const [aspectLock, setAspectLock] = useState(true);
|
||||||
|
const [scaleMode, setScaleMode] = useState<ScaleMode>("fit");
|
||||||
|
const [alignment, setAlignment] = useState<Align>([0, 0]);
|
||||||
|
const [bgPreset, setBgPreset] = useState(0);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const sourceCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
// previewCanvasRef — drawn to directly on every setting change; no toDataURL
|
||||||
|
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
// ── Load image onto hidden source canvas ──────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageSrc) return;
|
||||||
|
setImageReady(false);
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = sourceCanvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
canvas.width = img.naturalWidth;
|
||||||
|
canvas.height = img.naturalHeight;
|
||||||
|
canvas.getContext("2d")!.drawImage(img, 0, 0);
|
||||||
|
setNaturalW(img.naturalWidth);
|
||||||
|
setNaturalH(img.naturalHeight);
|
||||||
|
setOutW(String(img.naturalWidth));
|
||||||
|
setOutH(String(img.naturalHeight));
|
||||||
|
setImageReady(true);
|
||||||
|
};
|
||||||
|
img.src = imageSrc;
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
// ── Draw output directly into previewCanvasRef whenever settings change ────
|
||||||
|
// drawImage is GPU-accelerated; skipping toDataURL eliminates the main bottleneck.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageReady) return;
|
||||||
|
const src = sourceCanvasRef.current;
|
||||||
|
const prev = previewCanvasRef.current;
|
||||||
|
if (!src || !prev) return;
|
||||||
|
|
||||||
|
const w = parseInt(outW);
|
||||||
|
const h = parseInt(outH);
|
||||||
|
if (!w || !h || w <= 0 || h <= 0) return;
|
||||||
|
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
prev.width = w;
|
||||||
|
prev.height = h;
|
||||||
|
const ctx = prev.getContext("2d")!;
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
const srcW = src.width;
|
||||||
|
const srcH = src.height;
|
||||||
|
|
||||||
|
let drawW: number;
|
||||||
|
let drawH: number;
|
||||||
|
|
||||||
|
if (scaleMode === "fit") {
|
||||||
|
const scale = Math.min(w / srcW, h / srcH);
|
||||||
|
drawW = srcW * scale;
|
||||||
|
drawH = srcH * scale;
|
||||||
|
} else if (scaleMode === "fill") {
|
||||||
|
const scale = Math.max(w / srcW, h / srcH);
|
||||||
|
drawW = srcW * scale;
|
||||||
|
drawH = srcH * scale;
|
||||||
|
} else if (scaleMode === "stretch") {
|
||||||
|
drawW = w;
|
||||||
|
drawH = h;
|
||||||
|
} else {
|
||||||
|
drawW = srcW;
|
||||||
|
drawH = srcH;
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = (w - drawW) * (alignment[0] + 1) / 2;
|
||||||
|
const y = (h - drawH) * (alignment[1] + 1) / 2;
|
||||||
|
|
||||||
|
ctx.drawImage(src, x, y, drawW, drawH);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [imageReady, outW, outH, scaleMode, alignment]);
|
||||||
|
|
||||||
|
// ── Width / height with optional aspect lock ───────────────────────────────
|
||||||
|
const handleWChange = (v: string) => {
|
||||||
|
setOutW(v);
|
||||||
|
if (aspectLock) {
|
||||||
|
const n = parseInt(v);
|
||||||
|
if (!isNaN(n) && n > 0) setOutH(String(Math.round(n * naturalH / naturalW)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHChange = (v: string) => {
|
||||||
|
setOutH(v);
|
||||||
|
if (aspectLock) {
|
||||||
|
const n = parseInt(v);
|
||||||
|
if (!isNaN(n) && n > 0) setOutW(String(Math.round(n * naturalW / naturalH)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── File loading ───────────────────────────────────────────────────────────
|
||||||
|
const loadFile = useCallback((file: File) => {
|
||||||
|
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||||
|
setImageSrc(URL.createObjectURL(file));
|
||||||
|
setImageFile(file);
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file?.type.startsWith("image/")) loadFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Download: toBlob from previewCanvasRef — only at explicit user request
|
||||||
|
const handleDownload = () => {
|
||||||
|
const prev = previewCanvasRef.current;
|
||||||
|
if (!prev || !imageReady || !imageFile) return;
|
||||||
|
prev.toBlob((blob) => {
|
||||||
|
if (!blob) return;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.download = imageFile.name.replace(/\.[^.]+$/, "") + `_${outW}x${outH}.png`;
|
||||||
|
a.href = url;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, "image/png");
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||||
|
setImageSrc(null);
|
||||||
|
setImageFile(null);
|
||||||
|
setImageReady(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
// ── Upload screen ──────────────────────────────────────────────────────────
|
||||||
|
if (!imageSrc) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-8 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-1">Canvas Tool</h2>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Upload an image to resize, scale, or center it on a canvas of any size.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className={cn(
|
||||||
|
"border-2 border-dashed rounded-xl p-16 flex flex-col items-center gap-4 cursor-pointer transition-all select-none",
|
||||||
|
dragOver
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-primary/50 hover:bg-primary/3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Upload className={cn("w-10 h-10 transition-colors", dragOver ? "text-primary" : "text-text-tertiary")} />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-text-secondary">
|
||||||
|
{dragOver ? "Drop to upload" : "Drop an image here"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-text-tertiary mt-1">
|
||||||
|
or click to browse · PNG, JPEG, WebP · max 15 MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) loadFile(f); }}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Editor screen ──────────────────────────────────────────────────────────
|
||||||
|
const alignDisabled = scaleMode === "stretch";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 pb-8">
|
||||||
|
{/* Hidden source canvas */}
|
||||||
|
<canvas ref={sourceCanvasRef} className="hidden" />
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground flex-1">Canvas Tool</h2>
|
||||||
|
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
|
||||||
|
{imageFile?.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-text-tertiary font-mono">
|
||||||
|
{naturalW}×{naturalH}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
||||||
|
"hover:text-destructive hover:border-destructive transition-colors",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" /> Clear
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={!imageReady}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
||||||
|
imageReady
|
||||||
|
? "bg-primary text-white hover:bg-primary/90"
|
||||||
|
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" /> Download PNG
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4 space-y-4">
|
||||||
|
{/* Row 1: output size */}
|
||||||
|
<div className="flex flex-wrap gap-4 items-end">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Output Size</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number" min="1" max="4096" value={outW}
|
||||||
|
onChange={(e) => handleWChange(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
||||||
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
|
)}
|
||||||
|
placeholder="W"
|
||||||
|
/>
|
||||||
|
<span className="text-text-tertiary text-xs">×</span>
|
||||||
|
<input
|
||||||
|
type="number" min="1" max="4096" value={outH}
|
||||||
|
onChange={(e) => handleHChange(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
||||||
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
|
)}
|
||||||
|
placeholder="H"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setAspectLock((l) => !l)}
|
||||||
|
title={aspectLock ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
||||||
|
className={cn(
|
||||||
|
"p-1.5 rounded-md border transition-colors",
|
||||||
|
aspectLock
|
||||||
|
? "border-primary text-primary bg-primary/10"
|
||||||
|
: "border-border text-text-tertiary hover:border-primary/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{aspectLock ? <Lock className="w-3.5 h-3.5" /> : <Unlock className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Size presets */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Presets</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => { setOutW(String(naturalW)); setOutH(String(naturalH)); }}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 rounded text-xs border transition-colors",
|
||||||
|
outW === String(naturalW) && outH === String(naturalH)
|
||||||
|
? "border-primary text-primary bg-primary/10"
|
||||||
|
: "border-border text-text-tertiary hover:border-primary/50 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Original
|
||||||
|
</button>
|
||||||
|
{SIZE_PRESETS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.label}
|
||||||
|
onClick={() => {
|
||||||
|
if (aspectLock) {
|
||||||
|
const scale = Math.min(p.w / naturalW, p.h / naturalH);
|
||||||
|
setOutW(String(Math.round(naturalW * scale)));
|
||||||
|
setOutH(String(Math.round(naturalH * scale)));
|
||||||
|
} else {
|
||||||
|
setOutW(String(p.w));
|
||||||
|
setOutH(String(p.h));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 rounded text-xs border transition-colors",
|
||||||
|
outW === String(p.w) && outH === String(p.h)
|
||||||
|
? "border-primary text-primary bg-primary/10"
|
||||||
|
: "border-border text-text-tertiary hover:border-primary/50 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: scale mode + alignment */}
|
||||||
|
<div className="flex flex-wrap gap-6 items-start">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Scale Mode</p>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{SCALE_MODES.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => setScaleMode(m.id)}
|
||||||
|
title={m.desc}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 rounded-md text-xs font-medium border transition-colors",
|
||||||
|
scaleMode === m.id
|
||||||
|
? "border-primary text-primary bg-primary/10"
|
||||||
|
: "border-border text-text-tertiary hover:border-primary/50 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-tertiary">
|
||||||
|
{SCALE_MODES.find((m) => m.id === scaleMode)?.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn("space-y-1.5", alignDisabled && "opacity-40 pointer-events-none")}>
|
||||||
|
<p className="text-xs font-medium text-text-secondary">
|
||||||
|
{scaleMode === "fill" ? "Crop Position" : "Alignment"}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-0.5 w-fit">
|
||||||
|
{ALIGN_GRID.map(([col, row], i) => {
|
||||||
|
const active = alignment[0] === col && alignment[1] === row;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setAlignment([col, row])}
|
||||||
|
title={`${row === -1 ? "Top" : row === 0 ? "Middle" : "Bottom"}-${col === -1 ? "Left" : col === 0 ? "Center" : "Right"}`}
|
||||||
|
className={cn(
|
||||||
|
"w-7 h-7 rounded flex items-center justify-center border transition-colors",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary/20"
|
||||||
|
: "border-border hover:border-primary/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn(
|
||||||
|
"w-2 h-2 rounded-full transition-colors",
|
||||||
|
active ? "bg-primary" : "bg-text-tertiary/40",
|
||||||
|
)} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Side-by-side preview */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Original */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||||
|
Original — {naturalW}×{naturalH}
|
||||||
|
</p>
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
<div style={CHECKERBOARD}>
|
||||||
|
<img src={imageSrc} alt="Source" className="w-full block" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Result — canvas drawn directly, no toDataURL */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex-1">
|
||||||
|
Result — {outW || "?"}×{outH || "?"}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{BG_PRESETS.map((preset, i) => (
|
||||||
|
<button
|
||||||
|
key={preset.label}
|
||||||
|
title={preset.label}
|
||||||
|
onClick={() => setBgPreset(i)}
|
||||||
|
className={cn(
|
||||||
|
"w-5 h-5 rounded border transition-all",
|
||||||
|
i === bgPreset
|
||||||
|
? "ring-2 ring-primary ring-offset-1 ring-offset-background border-transparent"
|
||||||
|
: "border-border hover:border-primary/50",
|
||||||
|
)}
|
||||||
|
style={preset.style}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
<div style={BG_PRESETS[bgPreset].style} className={cn("w-full", !imageReady && "hidden")}>
|
||||||
|
<canvas ref={previewCanvasRef} className="w-full block" />
|
||||||
|
</div>
|
||||||
|
{!imageReady && (
|
||||||
|
<div className="aspect-square flex flex-col items-center justify-center text-text-tertiary gap-3 p-8">
|
||||||
|
<ImageIcon className="w-10 h-10 opacity-20" />
|
||||||
|
<span className="text-xs opacity-40 text-center leading-relaxed">
|
||||||
|
Loading image…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info strip */}
|
||||||
|
{imageReady && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-text-tertiary">
|
||||||
|
<Maximize2 className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Output: <span className="font-mono text-foreground">{outW}×{outH} px</span>
|
||||||
|
{" · "}
|
||||||
|
Mode: <span className="text-foreground capitalize">{scaleMode}</span>
|
||||||
|
{scaleMode !== "stretch" && (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
Align:{" "}
|
||||||
|
<span className="text-foreground">
|
||||||
|
{alignment[1] === -1 ? "Top" : alignment[1] === 0 ? "Middle" : "Bottom"}
|
||||||
|
-{alignment[0] === -1 ? "Left" : alignment[0] === 0 ? "Center" : "Right"}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
653
panel/src/pages/CropTool.tsx
Normal file
653
panel/src/pages/CropTool.tsx
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
import { Upload, Download, X, ImageIcon, Crosshair, Maximize2 } from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CHECKERBOARD: React.CSSProperties = {
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(45deg, #444 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #444 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #444 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #444 75%)
|
||||||
|
`,
|
||||||
|
backgroundSize: "16px 16px",
|
||||||
|
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extra space around the output rect in the editor so you can see the image
|
||||||
|
// when it extends (or will be panned) outside the output boundaries.
|
||||||
|
const PAD = 120;
|
||||||
|
const HANDLE_HIT = 8;
|
||||||
|
const MIN_CROP = 4;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Module-level checkerboard tile cache
|
||||||
|
// Avoids recreating a canvas element on every drawEditor call.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let _checkerTile: HTMLCanvasElement | null = null;
|
||||||
|
|
||||||
|
function checkerTile(): HTMLCanvasElement {
|
||||||
|
if (_checkerTile) return _checkerTile;
|
||||||
|
const c = document.createElement("canvas");
|
||||||
|
c.width = 16; c.height = 16;
|
||||||
|
const ctx = c.getContext("2d")!;
|
||||||
|
ctx.fillStyle = "#2a2a2a"; ctx.fillRect(0, 0, 16, 16);
|
||||||
|
ctx.fillStyle = "#3d3d3d"; ctx.fillRect(0, 0, 8, 8); ctx.fillRect(8, 8, 8, 8);
|
||||||
|
_checkerTile = c;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// "pan" = drag inside output rect to move the image
|
||||||
|
type DragHandle =
|
||||||
|
| "nw" | "n" | "ne"
|
||||||
|
| "w" | "e"
|
||||||
|
| "sw" | "s" | "se"
|
||||||
|
| "pan";
|
||||||
|
|
||||||
|
type DragState = {
|
||||||
|
handle: DragHandle;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
origW: number;
|
||||||
|
origH: number;
|
||||||
|
origImgX: number;
|
||||||
|
origImgY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getHandlePoints(
|
||||||
|
cx0: number, cy0: number, cx1: number, cy1: number
|
||||||
|
): [DragHandle, number, number][] {
|
||||||
|
const mx = (cx0 + cx1) / 2;
|
||||||
|
const my = (cy0 + cy1) / 2;
|
||||||
|
return [
|
||||||
|
["nw", cx0, cy0], ["n", mx, cy0], ["ne", cx1, cy0],
|
||||||
|
["w", cx0, my], ["e", cx1, my],
|
||||||
|
["sw", cx0, cy1], ["s", mx, cy1], ["se", cx1, cy1],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitHandle(
|
||||||
|
px: number, py: number,
|
||||||
|
cx0: number, cy0: number, cx1: number, cy1: number
|
||||||
|
): DragHandle | null {
|
||||||
|
for (const [name, hx, hy] of getHandlePoints(cx0, cy0, cx1, cy1)) {
|
||||||
|
if (Math.abs(px - hx) <= HANDLE_HIT && Math.abs(py - hy) <= HANDLE_HIT) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (px >= cx0 && px <= cx1 && py >= cy0 && py <= cy1) return "pan";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCursor(h: DragHandle | null, dragging = false): string {
|
||||||
|
if (!h) return "default";
|
||||||
|
if (h === "pan") return dragging ? "grabbing" : "grab";
|
||||||
|
if (h === "nw" || h === "se") return "nwse-resize";
|
||||||
|
if (h === "ne" || h === "sw") return "nesw-resize";
|
||||||
|
if (h === "n" || h === "s") return "ns-resize";
|
||||||
|
return "ew-resize";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// drawEditor
|
||||||
|
//
|
||||||
|
// Mental model: the output canvas (cropW × cropH) is pinned at (PAD, PAD).
|
||||||
|
// The source image sits at (PAD + imgX, PAD + imgY) and can extend outside
|
||||||
|
// the output in any direction. Areas inside the output not covered by the
|
||||||
|
// image are transparent (shown as checkerboard).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function drawEditor(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
img: HTMLImageElement,
|
||||||
|
imgW: number, imgH: number,
|
||||||
|
cropW: number, cropH: number,
|
||||||
|
imgX: number, imgY: number,
|
||||||
|
) {
|
||||||
|
const cW = Math.max(cropW, imgW) + 2 * PAD;
|
||||||
|
const cH = Math.max(cropH, imgH) + 2 * PAD;
|
||||||
|
if (canvas.width !== cW || canvas.height !== cH) {
|
||||||
|
canvas.width = cW;
|
||||||
|
canvas.height = cH;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
|
||||||
|
// 1. Checkerboard background — use cached tile to avoid createElement every frame
|
||||||
|
ctx.fillStyle = ctx.createPattern(checkerTile(), "repeat")!;
|
||||||
|
ctx.fillRect(0, 0, cW, cH);
|
||||||
|
|
||||||
|
// 2. Faint image boundary indicator (dashed border around full source image)
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = "rgba(255,255,255,0.2)";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.strokeRect(PAD + imgX + 0.5, PAD + imgY + 0.5, imgW - 1, imgH - 1);
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// 3. Draw source image
|
||||||
|
ctx.drawImage(img, PAD + imgX, PAD + imgY);
|
||||||
|
|
||||||
|
// 4. Dark overlay outside the output rect
|
||||||
|
const ox0 = PAD, oy0 = PAD, ox1 = PAD + cropW, oy1 = PAD + cropH;
|
||||||
|
ctx.fillStyle = "rgba(0,0,0,0.55)";
|
||||||
|
ctx.fillRect(0, 0, cW, oy0); // top
|
||||||
|
ctx.fillRect(0, oy1, cW, cH - oy1); // bottom
|
||||||
|
ctx.fillRect(0, oy0, ox0, oy1 - oy0); // left
|
||||||
|
ctx.fillRect(ox1, oy0, cW - ox1, oy1 - oy0); // right
|
||||||
|
|
||||||
|
// 5. Rule-of-thirds grid
|
||||||
|
ctx.strokeStyle = "rgba(255,255,255,0.18)";
|
||||||
|
ctx.lineWidth = 0.75;
|
||||||
|
for (const t of [1 / 3, 2 / 3]) {
|
||||||
|
ctx.beginPath(); ctx.moveTo(ox0 + cropW * t, oy0); ctx.lineTo(ox0 + cropW * t, oy1); ctx.stroke();
|
||||||
|
ctx.beginPath(); ctx.moveTo(ox0, oy0 + cropH * t); ctx.lineTo(ox1, oy0 + cropH * t); ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Output rect border
|
||||||
|
ctx.strokeStyle = "#fff";
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.setLineDash([5, 3]);
|
||||||
|
ctx.strokeRect(ox0 + 0.5, oy0 + 0.5, cropW - 1, cropH - 1);
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// 7. Resize handles
|
||||||
|
ctx.fillStyle = "#fff";
|
||||||
|
ctx.strokeStyle = "#555";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (const [, hx, hy] of getHandlePoints(ox0, oy0, ox1, oy1)) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(hx - 4, hy - 4, 8, 8);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CropTool
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function CropTool() {
|
||||||
|
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||||
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||||
|
const [imgW, setImgW] = useState(0);
|
||||||
|
const [imgH, setImgH] = useState(0);
|
||||||
|
const [imageReady, setImageReady] = useState(false);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
|
||||||
|
// Output canvas dimensions
|
||||||
|
const [cropW, setCropW] = useState(128);
|
||||||
|
const [cropH, setCropH] = useState(128);
|
||||||
|
|
||||||
|
// Where the source image's top-left sits within the output canvas.
|
||||||
|
const [imgX, setImgX] = useState(0);
|
||||||
|
const [imgY, setImgY] = useState(0);
|
||||||
|
|
||||||
|
// Padding used by "Fit to Content"
|
||||||
|
const [padding, setPadding] = useState(0);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const displayCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const sourceCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
// Direct canvas preview — avoids toDataURL on every drag frame
|
||||||
|
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const imgElRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
const dragStateRef = useRef<DragState | null>(null);
|
||||||
|
|
||||||
|
// Always-fresh values for use inside stable callbacks
|
||||||
|
const outputRef = useRef({ w: cropW, h: cropH, imgX, imgY, padding });
|
||||||
|
useEffect(() => { outputRef.current = { w: cropW, h: cropH, imgX, imgY, padding }; });
|
||||||
|
|
||||||
|
// ── Load image onto hidden source canvas ────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageSrc) return;
|
||||||
|
setImageReady(false);
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const src = sourceCanvasRef.current!;
|
||||||
|
src.width = img.naturalWidth;
|
||||||
|
src.height = img.naturalHeight;
|
||||||
|
src.getContext("2d")!.drawImage(img, 0, 0);
|
||||||
|
imgElRef.current = img;
|
||||||
|
setImgW(img.naturalWidth);
|
||||||
|
setImgH(img.naturalHeight);
|
||||||
|
setCropW(img.naturalWidth);
|
||||||
|
setCropH(img.naturalHeight);
|
||||||
|
setImgX(0);
|
||||||
|
setImgY(0);
|
||||||
|
setImageReady(true);
|
||||||
|
};
|
||||||
|
img.src = imageSrc;
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
// ── Redraw editor whenever state changes ─────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageReady || !displayCanvasRef.current || !imgElRef.current) return;
|
||||||
|
drawEditor(displayCanvasRef.current, imgElRef.current, imgW, imgH, cropW, cropH, imgX, imgY);
|
||||||
|
}, [imageReady, imgW, imgH, cropW, cropH, imgX, imgY]);
|
||||||
|
|
||||||
|
// ── Live preview — draw directly into previewCanvasRef (no toDataURL) ────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageReady || !previewCanvasRef.current) return;
|
||||||
|
const src = sourceCanvasRef.current!;
|
||||||
|
const prev = previewCanvasRef.current;
|
||||||
|
prev.width = Math.max(1, Math.round(cropW));
|
||||||
|
prev.height = Math.max(1, Math.round(cropH));
|
||||||
|
// Clear to transparent, then composite the cropped region
|
||||||
|
const ctx = prev.getContext("2d")!;
|
||||||
|
ctx.clearRect(0, 0, prev.width, prev.height);
|
||||||
|
ctx.drawImage(src, Math.round(imgX), Math.round(imgY));
|
||||||
|
}, [imageReady, cropW, cropH, imgX, imgY]);
|
||||||
|
|
||||||
|
// ── Auto-center ──────────────────────────────────────────────────────────────
|
||||||
|
const autoCenter = useCallback(() => {
|
||||||
|
if (!imageReady) return;
|
||||||
|
const src = sourceCanvasRef.current!;
|
||||||
|
const { data, width, height } = src.getContext("2d")!.getImageData(0, 0, src.width, src.height);
|
||||||
|
|
||||||
|
let minX = width, minY = height, maxX = -1, maxY = -1;
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
if (data[(y * width + x) * 4 + 3] > 0) {
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxX === -1) return;
|
||||||
|
|
||||||
|
const contentCX = (minX + maxX) / 2;
|
||||||
|
const contentCY = (minY + maxY) / 2;
|
||||||
|
const { w, h } = outputRef.current;
|
||||||
|
setImgX(Math.round(w / 2 - contentCX));
|
||||||
|
setImgY(Math.round(h / 2 - contentCY));
|
||||||
|
}, [imageReady]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ── Fit to content ───────────────────────────────────────────────────────────
|
||||||
|
const fitToContent = useCallback(() => {
|
||||||
|
if (!imageReady) return;
|
||||||
|
const src = sourceCanvasRef.current!;
|
||||||
|
const { data, width, height } = src.getContext("2d")!.getImageData(0, 0, src.width, src.height);
|
||||||
|
|
||||||
|
let minX = width, minY = height, maxX = -1, maxY = -1;
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
if (data[(y * width + x) * 4 + 3] > 0) {
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxX === -1) return;
|
||||||
|
|
||||||
|
const pad = outputRef.current.padding;
|
||||||
|
const contentW = maxX - minX + 1;
|
||||||
|
const contentH = maxY - minY + 1;
|
||||||
|
setCropW(contentW + 2 * pad);
|
||||||
|
setCropH(contentH + 2 * pad);
|
||||||
|
setImgX(pad - minX);
|
||||||
|
setImgY(pad - minY);
|
||||||
|
}, [imageReady]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ── File loading ─────────────────────────────────────────────────────────────
|
||||||
|
const loadFile = useCallback((file: File) => {
|
||||||
|
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||||
|
setImageSrc(URL.createObjectURL(file));
|
||||||
|
setImageFile(file);
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||||
|
setImageSrc(null);
|
||||||
|
setImageFile(null);
|
||||||
|
setImageReady(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
// Download: toBlob from previewCanvasRef — only at explicit user request
|
||||||
|
const handleDownload = () => {
|
||||||
|
const prev = previewCanvasRef.current;
|
||||||
|
if (!prev || !imageReady || !imageFile) return;
|
||||||
|
prev.toBlob((blob) => {
|
||||||
|
if (!blob) return;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.download = imageFile.name.replace(/\.[^.]+$/, "") + "_cropped.png";
|
||||||
|
a.href = url;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, "image/png");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Canvas interaction ───────────────────────────────────────────────────────
|
||||||
|
const getCanvasXY = (e: React.MouseEvent<HTMLCanvasElement>): [number, number] => {
|
||||||
|
const canvas = displayCanvasRef.current!;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
return [
|
||||||
|
(e.clientX - rect.left) * (canvas.width / rect.width),
|
||||||
|
(e.clientY - rect.top) * (canvas.height / rect.height),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
const [px, py] = getCanvasXY(e);
|
||||||
|
const { w, h, imgX: ix, imgY: iy } = outputRef.current;
|
||||||
|
const handle = hitHandle(px, py, PAD, PAD, PAD + w, PAD + h);
|
||||||
|
if (!handle) return;
|
||||||
|
dragStateRef.current = {
|
||||||
|
handle,
|
||||||
|
startX: px, startY: py,
|
||||||
|
origW: w, origH: h,
|
||||||
|
origImgX: ix, origImgY: iy,
|
||||||
|
};
|
||||||
|
if (displayCanvasRef.current) {
|
||||||
|
displayCanvasRef.current.style.cursor = handleCursor(handle, true);
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const onMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = displayCanvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const [px, py] = getCanvasXY(e);
|
||||||
|
|
||||||
|
if (!dragStateRef.current) {
|
||||||
|
const { w, h } = outputRef.current;
|
||||||
|
canvas.style.cursor = handleCursor(hitHandle(px, py, PAD, PAD, PAD + w, PAD + h));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { handle, startX, startY, origW, origH, origImgX, origImgY } = dragStateRef.current;
|
||||||
|
const dx = Math.round(px - startX);
|
||||||
|
const dy = Math.round(py - startY);
|
||||||
|
|
||||||
|
let nw = origW, nh = origH, nix = origImgX, niy = origImgY;
|
||||||
|
|
||||||
|
if (handle === "pan") {
|
||||||
|
nix = origImgX + dx;
|
||||||
|
niy = origImgY + dy;
|
||||||
|
} else {
|
||||||
|
if (handle.includes("e")) nw = Math.max(MIN_CROP, origW + dx);
|
||||||
|
if (handle.includes("s")) nh = Math.max(MIN_CROP, origH + dy);
|
||||||
|
if (handle.includes("w")) {
|
||||||
|
const d = Math.min(dx, origW - MIN_CROP);
|
||||||
|
nw = origW - d;
|
||||||
|
nix = origImgX - d;
|
||||||
|
}
|
||||||
|
if (handle.includes("n")) {
|
||||||
|
const d = Math.min(dy, origH - MIN_CROP);
|
||||||
|
nh = origH - d;
|
||||||
|
niy = origImgY - d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCropW(nw);
|
||||||
|
setCropH(nh);
|
||||||
|
setImgX(nix);
|
||||||
|
setImgY(niy);
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const onMouseUp = useCallback(() => {
|
||||||
|
dragStateRef.current = null;
|
||||||
|
if (displayCanvasRef.current) displayCanvasRef.current.style.cursor = "default";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const parseIntSafe = (v: string, fallback: number) => {
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
return isNaN(n) ? fallback : n;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Upload screen ────────────────────────────────────────────────────────────
|
||||||
|
if (!imageSrc) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-8 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-1">Crop Tool</h2>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Upload an image, then define an output canvas. Drag inside the canvas
|
||||||
|
to pan the image within it, or drag the edge handles to resize. The
|
||||||
|
output canvas can extend beyond the image to add transparent padding.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const f = e.dataTransfer.files[0];
|
||||||
|
if (f?.type.startsWith("image/")) loadFile(f);
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className={cn(
|
||||||
|
"border-2 border-dashed rounded-xl p-16 flex flex-col items-center gap-4 cursor-pointer transition-all select-none",
|
||||||
|
dragOver
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-primary/50 hover:bg-primary/3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Upload className={cn("w-10 h-10 transition-colors", dragOver ? "text-primary" : "text-text-tertiary")} />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-text-secondary">
|
||||||
|
{dragOver ? "Drop to upload" : "Drop an image here"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-text-tertiary mt-1">
|
||||||
|
or click to browse · PNG, JPEG, WebP · max 15 MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) loadFile(f); }}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Editor screen ────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 pb-8">
|
||||||
|
<canvas ref={sourceCanvasRef} className="hidden" />
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground flex-1">Crop Tool</h2>
|
||||||
|
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
|
||||||
|
{imageFile?.name}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
||||||
|
"hover:text-destructive hover:border-destructive transition-colors",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" /> Clear
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={!imageReady}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
||||||
|
imageReady
|
||||||
|
? "bg-primary text-white hover:bg-primary/90"
|
||||||
|
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" /> Download PNG
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4 space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-4 items-end">
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<span className="text-xs font-medium text-text-secondary block">Output W (px)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={MIN_CROP}
|
||||||
|
value={cropW}
|
||||||
|
onChange={(e) => setCropW(Math.max(MIN_CROP, parseIntSafe(e.target.value, MIN_CROP)))}
|
||||||
|
className={cn(
|
||||||
|
"w-24 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
||||||
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<span className="text-xs font-medium text-text-secondary block">Output H (px)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={MIN_CROP}
|
||||||
|
value={cropH}
|
||||||
|
onChange={(e) => setCropH(Math.max(MIN_CROP, parseIntSafe(e.target.value, MIN_CROP)))}
|
||||||
|
className={cn(
|
||||||
|
"w-24 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
||||||
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<span className="text-xs font-medium text-text-secondary block">Image X</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={imgX}
|
||||||
|
onChange={(e) => setImgX(parseIntSafe(e.target.value, 0))}
|
||||||
|
className={cn(
|
||||||
|
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
||||||
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<span className="text-xs font-medium text-text-secondary block">Image Y</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={imgY}
|
||||||
|
onChange={(e) => setImgY(parseIntSafe(e.target.value, 0))}
|
||||||
|
className={cn(
|
||||||
|
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
||||||
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span className="text-xs text-text-tertiary self-center font-mono pb-1">
|
||||||
|
src: {imgW} × {imgH}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 pt-1 border-t border-border">
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<span className="text-xs font-medium text-text-secondary block">Padding (px)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={padding}
|
||||||
|
onChange={(e) => setPadding(Math.max(0, parseIntSafe(e.target.value, 0)))}
|
||||||
|
className={cn(
|
||||||
|
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
||||||
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 self-end pb-0.5">
|
||||||
|
<button
|
||||||
|
onClick={autoCenter}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-2 rounded-md border border-border text-xs font-medium transition-colors",
|
||||||
|
"bg-input text-text-secondary hover:text-primary hover:border-primary/60",
|
||||||
|
)}
|
||||||
|
title="Pan the image so non-transparent content is centered within the current output canvas"
|
||||||
|
>
|
||||||
|
<Crosshair className="w-3.5 h-3.5" /> Auto-center
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={fitToContent}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-2 rounded-md border border-border text-xs font-medium transition-colors",
|
||||||
|
"bg-input text-text-secondary hover:text-primary hover:border-primary/60",
|
||||||
|
)}
|
||||||
|
title="Resize output to the non-transparent content bounding box + padding, then center"
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-3.5 h-3.5" /> Fit to Content
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-tertiary self-end pb-1 ml-auto">
|
||||||
|
Drag inside the canvas to pan · drag handles to resize
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor + Preview */}
|
||||||
|
<div className="grid grid-cols-[1fr_260px] gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||||
|
Editor
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="bg-card border border-border rounded-lg overflow-auto"
|
||||||
|
style={{ maxHeight: "62vh" }}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref={displayCanvasRef}
|
||||||
|
className="block"
|
||||||
|
style={{ maxWidth: "100%" }}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
onMouseMove={onMouseMove}
|
||||||
|
onMouseUp={onMouseUp}
|
||||||
|
onMouseLeave={onMouseUp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||||
|
Preview
|
||||||
|
</p>
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
{imageReady ? (
|
||||||
|
<div style={CHECKERBOARD}>
|
||||||
|
<canvas
|
||||||
|
ref={previewCanvasRef}
|
||||||
|
className="w-full block"
|
||||||
|
style={{ imageRendering: (cropW < 64 || cropH < 64) ? "pixelated" : "auto" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="aspect-square flex items-center justify-center">
|
||||||
|
<ImageIcon className="w-10 h-10 text-text-tertiary opacity-20" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{imageReady && (
|
||||||
|
<p className="text-xs text-text-tertiary text-center font-mono">
|
||||||
|
{Math.round(cropW)} × {Math.round(cropH)} px
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
297
panel/src/pages/Dashboard.tsx
Normal file
297
panel/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Coins,
|
||||||
|
TrendingUp,
|
||||||
|
Gift,
|
||||||
|
Loader2,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Info,
|
||||||
|
Clock,
|
||||||
|
Wifi,
|
||||||
|
Trophy,
|
||||||
|
Crown,
|
||||||
|
Gem,
|
||||||
|
Wrench,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { useDashboard, type DashboardStats } from "../lib/useDashboard";
|
||||||
|
|
||||||
|
function formatNumber(n: number | string): string {
|
||||||
|
const num = typeof n === "string" ? parseFloat(n) : n;
|
||||||
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(ms: number): string {
|
||||||
|
const hours = Math.floor(ms / 3_600_000);
|
||||||
|
const minutes = Math.floor((ms % 3_600_000) / 60_000);
|
||||||
|
if (hours >= 24) {
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ${hours % 24}h`;
|
||||||
|
}
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(ts: string | Date): string {
|
||||||
|
const diff = Date.now() - new Date(ts).getTime();
|
||||||
|
const mins = Math.floor(diff / 60_000);
|
||||||
|
if (mins < 1) return "just now";
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
return `${Math.floor(hours / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventIcons = {
|
||||||
|
success: CheckCircle,
|
||||||
|
error: XCircle,
|
||||||
|
warn: AlertTriangle,
|
||||||
|
info: Info,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const eventColors = {
|
||||||
|
success: "text-success",
|
||||||
|
error: "text-destructive",
|
||||||
|
warn: "text-warning",
|
||||||
|
info: "text-info",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
accent = "border-primary",
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sub?: string;
|
||||||
|
accent?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-gradient-to-br from-card to-surface rounded-lg border border-border p-6 border-l-4",
|
||||||
|
accent
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Icon className="w-5 h-5 text-primary" />
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-text-tertiary">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold font-display tracking-tight">{value}</div>
|
||||||
|
{sub && <div className="text-sm text-text-tertiary mt-1">{sub}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LeaderboardColumn({
|
||||||
|
title,
|
||||||
|
icon: Icon,
|
||||||
|
entries,
|
||||||
|
valueKey,
|
||||||
|
valuePrefix,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
entries: Array<{ username: string; [k: string]: unknown }>;
|
||||||
|
valueKey: string;
|
||||||
|
valuePrefix?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card rounded-lg border border-border">
|
||||||
|
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
|
||||||
|
<Icon className="w-4 h-4 text-primary" />
|
||||||
|
<h3 className="text-sm font-semibold">{title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<div className="px-5 py-4 text-sm text-text-tertiary">No data</div>
|
||||||
|
)}
|
||||||
|
{entries.slice(0, 10).map((entry, i) => (
|
||||||
|
<div
|
||||||
|
key={entry.username}
|
||||||
|
className="flex items-center justify-between px-5 py-3 hover:bg-raised/40 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"w-6 text-xs font-mono font-medium text-right",
|
||||||
|
i === 0
|
||||||
|
? "text-gold"
|
||||||
|
: i === 1
|
||||||
|
? "text-text-secondary"
|
||||||
|
: i === 2
|
||||||
|
? "text-warning"
|
||||||
|
: "text-text-tertiary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
#{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm">{entry.username}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-mono text-text-secondary">
|
||||||
|
{valuePrefix}
|
||||||
|
{formatNumber(entry[valueKey] as string | number)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { data, loading, error } = useDashboard();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-32">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-32">
|
||||||
|
<div className="text-center">
|
||||||
|
<AlertTriangle className="w-8 h-8 text-warning mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-text-tertiary">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return <DashboardContent data={data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardContent({ data }: { data: DashboardStats }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Maintenance banner */}
|
||||||
|
{data.maintenanceMode && (
|
||||||
|
<div className="flex items-center gap-3 bg-warning/10 border border-warning/30 rounded-lg px-5 py-3">
|
||||||
|
<Wrench className="w-4 h-4 text-warning shrink-0" />
|
||||||
|
<span className="text-sm text-warning font-medium">
|
||||||
|
Maintenance mode is active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stat cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
icon={Users}
|
||||||
|
label="Total Users"
|
||||||
|
value={formatNumber(data.users.total)}
|
||||||
|
sub={`${formatNumber(data.users.active)} active`}
|
||||||
|
accent="border-info"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Coins}
|
||||||
|
label="Total Wealth"
|
||||||
|
value={formatNumber(data.economy.totalWealth)}
|
||||||
|
accent="border-gold"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={TrendingUp}
|
||||||
|
label="Avg Level"
|
||||||
|
value={data.economy.avgLevel.toFixed(1)}
|
||||||
|
sub={`Top streak: ${data.economy.topStreak}`}
|
||||||
|
accent="border-success"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Gift}
|
||||||
|
label="Active Lootdrops"
|
||||||
|
value={String(data.activeLootdrops?.length ?? 0)}
|
||||||
|
accent="border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Leaderboards */}
|
||||||
|
{data.leaderboards && (
|
||||||
|
<section>
|
||||||
|
<h2 className="font-display text-lg font-semibold mb-4">Leaderboards</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<LeaderboardColumn
|
||||||
|
title="Top Levels"
|
||||||
|
icon={Trophy}
|
||||||
|
entries={data.leaderboards.topLevels}
|
||||||
|
valueKey="level"
|
||||||
|
valuePrefix="Lv. "
|
||||||
|
/>
|
||||||
|
<LeaderboardColumn
|
||||||
|
title="Top Wealth"
|
||||||
|
icon={Crown}
|
||||||
|
entries={data.leaderboards.topWealth}
|
||||||
|
valueKey="balance"
|
||||||
|
/>
|
||||||
|
<LeaderboardColumn
|
||||||
|
title="Top Net Worth"
|
||||||
|
icon={Gem}
|
||||||
|
entries={data.leaderboards.topNetWorth}
|
||||||
|
valueKey="netWorth"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent Events */}
|
||||||
|
{data.recentEvents.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="font-display text-lg font-semibold mb-4">Recent Events</h2>
|
||||||
|
<div className="bg-card rounded-lg border border-border divide-y divide-border">
|
||||||
|
{data.recentEvents.slice(0, 20).map((event, i) => {
|
||||||
|
const Icon = eventIcons[event.type];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-start gap-3 px-5 py-3 hover:bg-raised/40 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={cn("w-4 h-4 mt-0.5 shrink-0", eventColors[event.type])}
|
||||||
|
/>
|
||||||
|
<span className="text-sm flex-1">
|
||||||
|
{event.icon && <span className="mr-1.5">{event.icon}</span>}
|
||||||
|
{event.message}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-text-tertiary font-mono whitespace-nowrap">
|
||||||
|
{timeAgo(event.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bot status footer */}
|
||||||
|
<footer className="flex flex-wrap items-center gap-x-6 gap-y-2 text-xs text-text-tertiary border-t border-border pt-6">
|
||||||
|
<span className="font-medium text-text-secondary">{data.bot.name}</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Wifi className="w-3 h-3" />
|
||||||
|
{Math.round(data.ping.avg)}ms
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{formatUptime(data.uptime)}
|
||||||
|
</span>
|
||||||
|
{data.bot.status && (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-success" />
|
||||||
|
{data.bot.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
470
panel/src/pages/HueShifter.tsx
Normal file
470
panel/src/pages/HueShifter.tsx
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
import { Upload, Download, X, ImageIcon } from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CHECKERBOARD: React.CSSProperties = {
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(45deg, #444 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #444 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #444 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #444 75%)
|
||||||
|
`,
|
||||||
|
backgroundSize: "16px 16px",
|
||||||
|
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WebGL — shaders + init
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const VERT = `
|
||||||
|
attribute vec2 aPos;
|
||||||
|
varying vec2 vUV;
|
||||||
|
void main() {
|
||||||
|
vUV = aPos * 0.5 + 0.5;
|
||||||
|
vUV.y = 1.0 - vUV.y;
|
||||||
|
gl_Position = vec4(aPos, 0.0, 1.0);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
// RGB↔HSL math in GLSL — runs massively parallel on GPU
|
||||||
|
const FRAG = `
|
||||||
|
precision mediump float;
|
||||||
|
uniform sampler2D uImage;
|
||||||
|
uniform float uHueShift;
|
||||||
|
uniform float uSaturation;
|
||||||
|
uniform float uLightness;
|
||||||
|
varying vec2 vUV;
|
||||||
|
|
||||||
|
vec3 rgb2hsl(vec3 c) {
|
||||||
|
float maxC = max(c.r, max(c.g, c.b));
|
||||||
|
float minC = min(c.r, min(c.g, c.b));
|
||||||
|
float l = (maxC + minC) * 0.5;
|
||||||
|
float d = maxC - minC;
|
||||||
|
if (d < 0.001) return vec3(0.0, 0.0, l);
|
||||||
|
float s = l > 0.5 ? d / (2.0 - maxC - minC) : d / (maxC + minC);
|
||||||
|
float h;
|
||||||
|
if (maxC == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
|
||||||
|
else if (maxC == c.g) h = (c.b - c.r) / d + 2.0;
|
||||||
|
else h = (c.r - c.g) / d + 4.0;
|
||||||
|
return vec3(h / 6.0, s, l);
|
||||||
|
}
|
||||||
|
|
||||||
|
float hue2rgb(float p, float q, float t) {
|
||||||
|
if (t < 0.0) t += 1.0;
|
||||||
|
if (t > 1.0) t -= 1.0;
|
||||||
|
if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t;
|
||||||
|
if (t < 0.5) return q;
|
||||||
|
if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 hsl2rgb(vec3 hsl) {
|
||||||
|
float h = hsl.x, s = hsl.y, l = hsl.z;
|
||||||
|
if (s < 0.001) return vec3(l);
|
||||||
|
float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s;
|
||||||
|
float p = 2.0 * l - q;
|
||||||
|
return vec3(
|
||||||
|
hue2rgb(p, q, h + 1.0 / 3.0),
|
||||||
|
hue2rgb(p, q, h),
|
||||||
|
hue2rgb(p, q, h - 1.0 / 3.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 c = texture2D(uImage, vUV);
|
||||||
|
if (c.a < 0.001) { gl_FragColor = c; return; }
|
||||||
|
vec3 hsl = rgb2hsl(c.rgb);
|
||||||
|
hsl.x = fract(hsl.x + uHueShift + 1.0);
|
||||||
|
hsl.y = clamp(hsl.y + uSaturation, 0.0, 1.0);
|
||||||
|
hsl.z = clamp(hsl.z + uLightness, 0.0, 1.0);
|
||||||
|
gl_FragColor = vec4(hsl2rgb(hsl), c.a);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
type GlState = {
|
||||||
|
gl: WebGLRenderingContext;
|
||||||
|
tex: WebGLTexture;
|
||||||
|
uHueShift: WebGLUniformLocation;
|
||||||
|
uSaturation: WebGLUniformLocation;
|
||||||
|
uLightness: WebGLUniformLocation;
|
||||||
|
};
|
||||||
|
|
||||||
|
function initGl(canvas: HTMLCanvasElement): GlState | null {
|
||||||
|
const gl = canvas.getContext("webgl", {
|
||||||
|
premultipliedAlpha: false,
|
||||||
|
preserveDrawingBuffer: true,
|
||||||
|
}) as WebGLRenderingContext | null;
|
||||||
|
if (!gl) return null;
|
||||||
|
|
||||||
|
const vert = gl.createShader(gl.VERTEX_SHADER)!;
|
||||||
|
gl.shaderSource(vert, VERT);
|
||||||
|
gl.compileShader(vert);
|
||||||
|
|
||||||
|
const frag = gl.createShader(gl.FRAGMENT_SHADER)!;
|
||||||
|
gl.shaderSource(frag, FRAG);
|
||||||
|
gl.compileShader(frag);
|
||||||
|
|
||||||
|
const prog = gl.createProgram()!;
|
||||||
|
gl.attachShader(prog, vert);
|
||||||
|
gl.attachShader(prog, frag);
|
||||||
|
gl.linkProgram(prog);
|
||||||
|
gl.useProgram(prog);
|
||||||
|
|
||||||
|
const buf = gl.createBuffer()!;
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||||
|
gl.bufferData(
|
||||||
|
gl.ARRAY_BUFFER,
|
||||||
|
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
|
||||||
|
gl.STATIC_DRAW,
|
||||||
|
);
|
||||||
|
const aPos = gl.getAttribLocation(prog, "aPos");
|
||||||
|
gl.enableVertexAttribArray(aPos);
|
||||||
|
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
const tex = gl.createTexture()!;
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||||
|
|
||||||
|
return {
|
||||||
|
gl,
|
||||||
|
tex,
|
||||||
|
uHueShift: gl.getUniformLocation(prog, "uHueShift")!,
|
||||||
|
uSaturation: gl.getUniformLocation(prog, "uSaturation")!,
|
||||||
|
uLightness: gl.getUniformLocation(prog, "uLightness")!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Slider
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Slider({
|
||||||
|
label, value, min, max, unit, onChange, gradient,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
unit: string;
|
||||||
|
onChange: (v: number) => void;
|
||||||
|
gradient?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-baseline">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">{label}</p>
|
||||||
|
<span className="text-xs font-mono text-text-tertiary tabular-nums w-16 text-right">
|
||||||
|
{`${value > 0 ? "+" : ""}${value}${unit}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative h-5 flex items-center">
|
||||||
|
{gradient && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 h-2 rounded-full pointer-events-none"
|
||||||
|
style={{ background: gradient }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 w-full appearance-none bg-transparent cursor-pointer",
|
||||||
|
"[&::-webkit-slider-thumb]:appearance-none",
|
||||||
|
"[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4",
|
||||||
|
"[&::-webkit-slider-thumb]:-mt-1",
|
||||||
|
"[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white",
|
||||||
|
"[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-border",
|
||||||
|
"[&::-webkit-slider-thumb]:shadow-sm",
|
||||||
|
gradient
|
||||||
|
? "[&::-webkit-slider-runnable-track]:h-2 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-transparent"
|
||||||
|
: "accent-primary",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HueShifter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function HueShifter() {
|
||||||
|
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||||
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||||
|
const [imageReady, setImageReady] = useState(false);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
|
||||||
|
const [hueShift, setHueShift] = useState(0);
|
||||||
|
const [saturation, setSaturation] = useState(0);
|
||||||
|
const [lightness, setLightness] = useState(0);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
// glCanvasRef — WebGL result canvas (also the preview)
|
||||||
|
const glCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const glRef = useRef<GlState | null>(null);
|
||||||
|
|
||||||
|
// ── Load image → upload texture once ──────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageSrc) return;
|
||||||
|
setImageReady(false);
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const glCanvas = glCanvasRef.current;
|
||||||
|
if (!glCanvas) return;
|
||||||
|
glCanvas.width = img.naturalWidth;
|
||||||
|
glCanvas.height = img.naturalHeight;
|
||||||
|
|
||||||
|
if (!glRef.current) {
|
||||||
|
glRef.current = initGl(glCanvas);
|
||||||
|
if (!glRef.current) {
|
||||||
|
console.error("HueShifter: WebGL not supported");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { gl, tex } = glRef.current;
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||||
|
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
||||||
|
gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
||||||
|
gl.viewport(0, 0, img.naturalWidth, img.naturalHeight);
|
||||||
|
|
||||||
|
setImageReady(true);
|
||||||
|
};
|
||||||
|
img.src = imageSrc;
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
// ── Re-render on GPU whenever sliders change ───────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const state = glRef.current;
|
||||||
|
if (!state || !imageReady) return;
|
||||||
|
|
||||||
|
const { gl, uHueShift, uSaturation, uLightness } = state;
|
||||||
|
gl.uniform1f(uHueShift, hueShift / 360);
|
||||||
|
gl.uniform1f(uSaturation, saturation / 100);
|
||||||
|
gl.uniform1f(uLightness, lightness / 100);
|
||||||
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||||
|
}, [imageReady, hueShift, saturation, lightness]);
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
const loadFile = useCallback((file: File) => {
|
||||||
|
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||||
|
setImageSrc(URL.createObjectURL(file));
|
||||||
|
setImageFile(file);
|
||||||
|
setHueShift(0);
|
||||||
|
setSaturation(0);
|
||||||
|
setLightness(0);
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||||
|
setImageSrc(null);
|
||||||
|
setImageFile(null);
|
||||||
|
setImageReady(false);
|
||||||
|
glRef.current = null;
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file?.type.startsWith("image/")) loadFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
const glCanvas = glCanvasRef.current;
|
||||||
|
if (!glCanvas || !imageFile || !imageReady) return;
|
||||||
|
glCanvas.toBlob((blob) => {
|
||||||
|
if (!blob) return;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.download = imageFile.name.replace(/\.[^.]+$/, "") + "_recolored.png";
|
||||||
|
a.href = url;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, "image/png");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setHueShift(0);
|
||||||
|
setSaturation(0);
|
||||||
|
setLightness(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDefault = hueShift === 0 && saturation === 0 && lightness === 0;
|
||||||
|
|
||||||
|
// ── Upload screen ──────────────────────────────────────────────────────────
|
||||||
|
if (!imageSrc) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-8 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-1">Hue Shifter</h2>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Upload an image and shift its hue, saturation, and lightness to create colour
|
||||||
|
variants. Fully transparent pixels are preserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className={cn(
|
||||||
|
"border-2 border-dashed rounded-xl p-16 flex flex-col items-center gap-4 cursor-pointer transition-all select-none",
|
||||||
|
dragOver
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-primary/50 hover:bg-primary/3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Upload
|
||||||
|
className={cn(
|
||||||
|
"w-10 h-10 transition-colors",
|
||||||
|
dragOver ? "text-primary" : "text-text-tertiary",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-text-secondary">
|
||||||
|
{dragOver ? "Drop to upload" : "Drop an image here"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-text-tertiary mt-1">
|
||||||
|
or click to browse · PNG, JPEG, WebP · max 15 MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) loadFile(f); }}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Editor screen ──────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 pb-8">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground flex-1">Hue Shifter</h2>
|
||||||
|
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
|
||||||
|
{imageFile?.name}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={isDefault}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary transition-colors",
|
||||||
|
isDefault
|
||||||
|
? "opacity-40 cursor-not-allowed"
|
||||||
|
: "hover:text-foreground hover:border-primary/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
||||||
|
"hover:text-destructive hover:border-destructive transition-colors",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" /> Clear
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={!imageReady || isDefault}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
||||||
|
imageReady && !isDefault
|
||||||
|
? "bg-primary text-white hover:bg-primary/90"
|
||||||
|
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" /> Download PNG
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="bg-card border border-border rounded-xl p-5 space-y-5">
|
||||||
|
<Slider
|
||||||
|
label="Hue Shift"
|
||||||
|
value={hueShift}
|
||||||
|
min={-180}
|
||||||
|
max={180}
|
||||||
|
unit="°"
|
||||||
|
onChange={setHueShift}
|
||||||
|
gradient="linear-gradient(to right, hsl(0,80%,55%), hsl(60,80%,55%), hsl(120,80%,55%), hsl(180,80%,55%), hsl(240,80%,55%), hsl(300,80%,55%), hsl(360,80%,55%))"
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
label="Saturation"
|
||||||
|
value={saturation}
|
||||||
|
min={-100}
|
||||||
|
max={100}
|
||||||
|
unit="%"
|
||||||
|
onChange={setSaturation}
|
||||||
|
gradient="linear-gradient(to right, hsl(210,0%,50%), hsl(210,0%,55%) 50%, hsl(210,90%,55%))"
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
label="Lightness"
|
||||||
|
value={lightness}
|
||||||
|
min={-100}
|
||||||
|
max={100}
|
||||||
|
unit="%"
|
||||||
|
onChange={setLightness}
|
||||||
|
gradient="linear-gradient(to right, hsl(0,0%,10%), hsl(0,0%,50%) 50%, hsl(0,0%,92%))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Side-by-side */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||||
|
Original
|
||||||
|
</p>
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
<div style={CHECKERBOARD}>
|
||||||
|
<img src={imageSrc} alt="Original" className="w-full block" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||||
|
Result
|
||||||
|
</p>
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
{/* WebGL canvas always in DOM; hidden until image is ready */}
|
||||||
|
<div style={CHECKERBOARD}>
|
||||||
|
<canvas
|
||||||
|
ref={glCanvasRef}
|
||||||
|
className={cn("w-full block", !imageReady && "hidden")}
|
||||||
|
/>
|
||||||
|
{!imageReady && (
|
||||||
|
<div className="aspect-square flex items-center justify-center">
|
||||||
|
<ImageIcon className="w-10 h-10 opacity-20 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1826
panel/src/pages/ItemStudio.tsx
Normal file
1826
panel/src/pages/ItemStudio.tsx
Normal file
File diff suppressed because it is too large
Load Diff
569
panel/src/pages/Items.tsx
Normal file
569
panel/src/pages/Items.tsx
Normal file
@@ -0,0 +1,569 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Package,
|
||||||
|
AlertTriangle,
|
||||||
|
Sparkles,
|
||||||
|
Scissors,
|
||||||
|
Crop,
|
||||||
|
Palette,
|
||||||
|
Maximize2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { useItems, type Item } from "../lib/useItems";
|
||||||
|
import { ItemStudio } from "./ItemStudio";
|
||||||
|
import { BackgroundRemoval } from "./BackgroundRemoval";
|
||||||
|
import { CropTool } from "./CropTool";
|
||||||
|
import { HueShifter } from "./HueShifter";
|
||||||
|
import { CanvasTool } from "./CanvasTool";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatNumber(num: number | string): string {
|
||||||
|
const n = typeof num === "string" ? parseInt(num) : num;
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const RARITY_COLORS: Record<string, string> = {
|
||||||
|
C: "bg-gray-500/20 text-gray-400",
|
||||||
|
R: "bg-blue-500/20 text-blue-400",
|
||||||
|
SR: "bg-purple-500/20 text-purple-400",
|
||||||
|
SSR: "bg-amber-500/20 text-amber-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Tab = "all" | "studio" | "bgremoval" | "crop" | "hue" | "canvas";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SearchFilterBar
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function SearchFilterBar({
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
type,
|
||||||
|
onTypeChange,
|
||||||
|
rarity,
|
||||||
|
onRarityChange,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
search: string;
|
||||||
|
onSearchChange: (v: string) => void;
|
||||||
|
type: string | null;
|
||||||
|
onTypeChange: (v: string | null) => void;
|
||||||
|
rarity: string | null;
|
||||||
|
onRarityChange: (v: string | null) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
placeholder="Search items..."
|
||||||
|
className={cn(
|
||||||
|
"w-full bg-input border border-border rounded-md pl-10 pr-3 py-2 text-sm text-foreground",
|
||||||
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
|
"transition-colors"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={type ?? ""}
|
||||||
|
onChange={(e) => onTypeChange(e.target.value || null)}
|
||||||
|
className={cn(
|
||||||
|
"bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
||||||
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
|
"transition-colors"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="MATERIAL">Material</option>
|
||||||
|
<option value="CONSUMABLE">Consumable</option>
|
||||||
|
<option value="EQUIPMENT">Equipment</option>
|
||||||
|
<option value="QUEST">Quest</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={rarity ?? ""}
|
||||||
|
onChange={(e) => onRarityChange(e.target.value || null)}
|
||||||
|
className={cn(
|
||||||
|
"bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
||||||
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
|
"transition-colors"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="">All Rarities</option>
|
||||||
|
<option value="C">Common (C)</option>
|
||||||
|
<option value="R">Rare (R)</option>
|
||||||
|
<option value="SR">Super Rare (SR)</option>
|
||||||
|
<option value="SSR">SSR</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
className={cn(
|
||||||
|
"bg-input border border-border rounded-md px-3 py-2 text-sm text-text-secondary",
|
||||||
|
"hover:bg-destructive hover:text-white hover:border-destructive transition-colors"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ItemTable
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ItemTable({
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
onItemClick,
|
||||||
|
}: {
|
||||||
|
items: Item[];
|
||||||
|
loading: boolean;
|
||||||
|
onItemClick: (item: Item) => void;
|
||||||
|
}) {
|
||||||
|
const columns = ["ID", "Icon", "Name", "Type", "Rarity", "Price", "Description"];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-raised border-b border-border">
|
||||||
|
<tr>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<th key={col} className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
|
||||||
|
{col}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<tr key={i} className="border-b border-border">
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td key={col} className="px-4 py-3">
|
||||||
|
<div className="h-4 bg-raised rounded animate-pulse w-20"></div>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card border border-border rounded-lg p-12 text-center">
|
||||||
|
<Package className="w-16 h-16 mx-auto mb-4 text-text-tertiary" />
|
||||||
|
<p className="text-lg font-semibold text-text-secondary mb-2">
|
||||||
|
No items found
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-text-tertiary">
|
||||||
|
Try adjusting your search or filter criteria
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-raised border-b border-border">
|
||||||
|
<tr>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<th key={col} className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
|
||||||
|
{col}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={item.id}
|
||||||
|
className="border-b border-border hover:bg-raised transition-colors cursor-pointer"
|
||||||
|
onClick={() => onItemClick(item)}
|
||||||
|
title="Click to edit"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-sm font-mono text-text-tertiary">
|
||||||
|
{item.id}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<img
|
||||||
|
src={item.iconUrl}
|
||||||
|
alt={item.name}
|
||||||
|
className="w-8 h-8 rounded object-cover bg-raised"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-sm text-foreground capitalize">
|
||||||
|
{item.type.toLowerCase()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center px-2 py-1 rounded-full text-xs font-medium",
|
||||||
|
RARITY_COLORS[item.rarity] ?? "bg-gray-500/20 text-gray-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.rarity}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-sm font-mono text-foreground">
|
||||||
|
{item.price ? formatNumber(item.price) : "—"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 max-w-[200px]">
|
||||||
|
<span className="text-sm text-text-secondary truncate block">
|
||||||
|
{item.description || "—"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pagination
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Pagination({
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
onPageChange,
|
||||||
|
onLimitChange,
|
||||||
|
}: {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onLimitChange: (limit: number) => void;
|
||||||
|
}) {
|
||||||
|
const startItem = (currentPage - 1) * limit + 1;
|
||||||
|
const endItem = Math.min(currentPage * limit, total);
|
||||||
|
|
||||||
|
const getPageNumbers = () => {
|
||||||
|
const pages: (number | string)[] = [];
|
||||||
|
const showPages = 5;
|
||||||
|
const halfShow = Math.floor(showPages / 2);
|
||||||
|
|
||||||
|
let start = Math.max(1, currentPage - halfShow);
|
||||||
|
const end = Math.min(totalPages, start + showPages - 1);
|
||||||
|
|
||||||
|
if (end - start < showPages - 1) {
|
||||||
|
start = Math.max(1, end - showPages + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start > 1) {
|
||||||
|
pages.push(1);
|
||||||
|
if (start > 2) pages.push("...");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end < totalPages) {
|
||||||
|
if (end < totalPages - 1) pages.push("...");
|
||||||
|
pages.push(totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 flex flex-wrap gap-4 items-center justify-between">
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Showing {startItem}–{endItem} of {formatNumber(total)} items
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||||
|
currentPage === 1
|
||||||
|
? "bg-raised text-text-tertiary cursor-not-allowed"
|
||||||
|
: "bg-input border border-border text-foreground hover:bg-raised"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{getPageNumbers().map((page, i) =>
|
||||||
|
typeof page === "number" ? (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onPageChange(page)}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2 rounded-md text-sm font-medium transition-colors min-w-[40px]",
|
||||||
|
page === currentPage
|
||||||
|
? "bg-primary text-white"
|
||||||
|
: "bg-input border border-border text-foreground hover:bg-raised"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span key={i} className="px-2 text-text-tertiary">
|
||||||
|
{page}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||||
|
currentPage === totalPages
|
||||||
|
? "bg-raised text-text-tertiary cursor-not-allowed"
|
||||||
|
: "bg-input border border-border text-foreground hover:bg-raised"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={limit}
|
||||||
|
onChange={(e) => onLimitChange(Number(e.target.value))}
|
||||||
|
className={cn(
|
||||||
|
"ml-2 bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
||||||
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
|
"transition-colors"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="10">10 / page</option>
|
||||||
|
<option value="25">25 / page</option>
|
||||||
|
<option value="50">50 / page</option>
|
||||||
|
<option value="100">100 / page</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main Items Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function Items() {
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
currentPage,
|
||||||
|
limit,
|
||||||
|
setLimit,
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
|
setSearchDebounced,
|
||||||
|
setPage,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useItems();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>("all");
|
||||||
|
const [searchInput, setSearchInput] = useState(filters.search);
|
||||||
|
const [editingItemId, setEditingItemId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchInput(filters.search);
|
||||||
|
}, [filters.search]);
|
||||||
|
|
||||||
|
const handleSearchChange = (value: string) => {
|
||||||
|
setSearchInput(value);
|
||||||
|
setSearchDebounced(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearFilters = () => {
|
||||||
|
setSearchInput("");
|
||||||
|
setFilters({ search: "", type: null, rarity: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="border-b border-border p-6 space-y-4">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Items</h1>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 border-b border-border -mb-4 pb-px">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("all")}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
|
||||||
|
activeTab === "all"
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Package className="w-4 h-4" />
|
||||||
|
All Items
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingItemId(null); setActiveTab("studio"); }}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
|
||||||
|
activeTab === "studio"
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
Item Studio
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("bgremoval")}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
|
||||||
|
activeTab === "bgremoval"
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Scissors className="w-4 h-4" />
|
||||||
|
Background Removal
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("crop")}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
|
||||||
|
activeTab === "crop"
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Crop className="w-4 h-4" />
|
||||||
|
Crop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("hue")}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
|
||||||
|
activeTab === "hue"
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Palette className="w-4 h-4" />
|
||||||
|
Hue Shifter
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("canvas")}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
|
||||||
|
activeTab === "canvas"
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-4 h-4" />
|
||||||
|
Canvas
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="mx-6 mt-4 bg-destructive/10 border border-destructive/30 rounded-lg p-4 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-semibold text-destructive">Error</p>
|
||||||
|
<p className="text-sm text-destructive/90">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
{activeTab === "all" ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SearchFilterBar
|
||||||
|
search={searchInput}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
type={filters.type}
|
||||||
|
onTypeChange={(v) => setFilters({ type: v })}
|
||||||
|
rarity={filters.rarity}
|
||||||
|
onRarityChange={(v) => setFilters({ rarity: v })}
|
||||||
|
onClear={handleClearFilters}
|
||||||
|
/>
|
||||||
|
<ItemTable
|
||||||
|
items={items}
|
||||||
|
loading={loading}
|
||||||
|
onItemClick={(item) => {
|
||||||
|
setEditingItemId(item.id);
|
||||||
|
setActiveTab("studio");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!loading && items.length > 0 && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
limit={limit}
|
||||||
|
total={total}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onLimitChange={setLimit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : activeTab === "studio" ? (
|
||||||
|
<ItemStudio
|
||||||
|
editItemId={editingItemId ?? undefined}
|
||||||
|
onSuccess={() => {
|
||||||
|
setEditingItemId(null);
|
||||||
|
refetch();
|
||||||
|
setActiveTab("all");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : activeTab === "bgremoval" ? (
|
||||||
|
<BackgroundRemoval />
|
||||||
|
) : activeTab === "crop" ? (
|
||||||
|
<CropTool />
|
||||||
|
) : activeTab === "hue" ? (
|
||||||
|
<HueShifter />
|
||||||
|
) : (
|
||||||
|
<CanvasTool />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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
23
panel/tsconfig.json
Normal file
23
panel/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
29
panel/vite.config.ts
Normal file
29
panel/vite.config.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:3000",
|
||||||
|
"/auth": "http://localhost:3000",
|
||||||
|
"/assets": "http://localhost:3000",
|
||||||
|
"/ws": {
|
||||||
|
target: "ws://localhost:3000",
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
assetsDir: "static",
|
||||||
|
},
|
||||||
|
});
|
||||||
13
shared/db/migrations/0005_wealthy_golden_guardian.sql
Normal file
13
shared/db/migrations/0005_wealthy_golden_guardian.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE "game_settings" (
|
||||||
|
"id" text PRIMARY KEY DEFAULT 'default' NOT NULL,
|
||||||
|
"leveling" jsonb NOT NULL,
|
||||||
|
"economy" jsonb NOT NULL,
|
||||||
|
"inventory" jsonb NOT NULL,
|
||||||
|
"lootdrop" jsonb NOT NULL,
|
||||||
|
"trivia" jsonb NOT NULL,
|
||||||
|
"moderation" jsonb NOT NULL,
|
||||||
|
"commands" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"system" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
1397
shared/db/migrations/meta/0005_snapshot.json
Normal file
1397
shared/db/migrations/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
|||||||
"when": 1770904612078,
|
"when": 1770904612078,
|
||||||
"tag": "0004_bored_kat_farrell",
|
"tag": "0004_bored_kat_farrell",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1771010684586,
|
||||||
|
"tag": "0005_wealthy_golden_guardian",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,26 @@ import { relations, type InferSelectModel, type InferInsertModel } from 'drizzle
|
|||||||
export type GuildSettings = InferSelectModel<typeof guildSettings>;
|
export type GuildSettings = InferSelectModel<typeof guildSettings>;
|
||||||
export type GuildSettingsInsert = InferInsertModel<typeof guildSettings>;
|
export type GuildSettingsInsert = InferInsertModel<typeof guildSettings>;
|
||||||
|
|
||||||
|
export interface GuildConfig {
|
||||||
|
studentRole?: string;
|
||||||
|
visitorRole?: string;
|
||||||
|
colorRoles: string[];
|
||||||
|
welcomeChannelId?: string;
|
||||||
|
welcomeMessage?: string;
|
||||||
|
feedbackChannelId?: string;
|
||||||
|
terminal?: {
|
||||||
|
channelId: string;
|
||||||
|
messageId: string;
|
||||||
|
};
|
||||||
|
moderation: {
|
||||||
|
cases: {
|
||||||
|
dmOnWarn: boolean;
|
||||||
|
logChannelId?: string;
|
||||||
|
autoTimeoutThreshold?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const guildSettings = pgTable('guild_settings', {
|
export const guildSettings = pgTable('guild_settings', {
|
||||||
guildId: bigint('guild_id', { mode: 'bigint' }).primaryKey(),
|
guildId: bigint('guild_id', { mode: 'bigint' }).primaryKey(),
|
||||||
studentRoleId: bigint('student_role_id', { mode: 'bigint' }),
|
studentRoleId: bigint('student_role_id', { mode: 'bigint' }),
|
||||||
|
|||||||
@@ -1,29 +1,12 @@
|
|||||||
import { jsonReplacer } from './utils';
|
import type {
|
||||||
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
LevelingConfig,
|
||||||
import { join } from 'node:path';
|
EconomyConfig as EconomyConfigDB,
|
||||||
import { z } from 'zod';
|
InventoryConfig as InventoryConfigDB,
|
||||||
|
LootdropConfig,
|
||||||
const configPath = join(import.meta.dir, '..', 'config', 'config.json');
|
TriviaConfig as TriviaConfigDB,
|
||||||
|
ModerationConfig
|
||||||
export interface GuildConfig {
|
} from "@db/schema/game-settings";
|
||||||
studentRole?: string;
|
import type { GuildConfig } from "@db/schema/guild-settings";
|
||||||
visitorRole?: string;
|
|
||||||
colorRoles: string[];
|
|
||||||
welcomeChannelId?: string;
|
|
||||||
welcomeMessage?: string;
|
|
||||||
feedbackChannelId?: string;
|
|
||||||
terminal?: {
|
|
||||||
channelId: string;
|
|
||||||
messageId: string;
|
|
||||||
};
|
|
||||||
moderation: {
|
|
||||||
cases: {
|
|
||||||
dmOnWarn: boolean;
|
|
||||||
logChannelId?: string;
|
|
||||||
autoTimeoutThreshold?: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const guildConfigCache = new Map<string, { config: GuildConfig; timestamp: number }>();
|
const guildConfigCache = new Map<string, { config: GuildConfig; timestamp: number }>();
|
||||||
const CACHE_TTL_MS = 60000;
|
const CACHE_TTL_MS = 60000;
|
||||||
@@ -82,16 +65,10 @@ export function invalidateGuildConfigCache(guildId: string) {
|
|||||||
guildConfigCache.delete(guildId);
|
guildConfigCache.delete(guildId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LevelingConfig {
|
// Re-export DB types
|
||||||
base: number;
|
export type { LevelingConfig, LootdropConfig, ModerationConfig };
|
||||||
exponent: number;
|
|
||||||
chat: {
|
|
||||||
cooldownMs: number;
|
|
||||||
minXp: number;
|
|
||||||
maxXp: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Runtime config types with BigInt for numeric fields
|
||||||
export interface EconomyConfig {
|
export interface EconomyConfig {
|
||||||
daily: {
|
daily: {
|
||||||
amount: bigint;
|
amount: bigint;
|
||||||
@@ -114,18 +91,6 @@ export interface InventoryConfig {
|
|||||||
maxSlots: number;
|
maxSlots: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LootdropConfig {
|
|
||||||
activityWindowMs: number;
|
|
||||||
minMessages: number;
|
|
||||||
spawnChance: number;
|
|
||||||
cooldownMs: number;
|
|
||||||
reward: {
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
currency: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TriviaConfig {
|
export interface TriviaConfig {
|
||||||
entryFee: bigint;
|
entryFee: bigint;
|
||||||
rewardMultiplier: number;
|
rewardMultiplier: number;
|
||||||
@@ -135,20 +100,6 @@ export interface TriviaConfig {
|
|||||||
difficulty: 'easy' | 'medium' | 'hard' | 'random';
|
difficulty: 'easy' | 'medium' | 'hard' | 'random';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModerationConfig {
|
|
||||||
prune: {
|
|
||||||
maxAmount: number;
|
|
||||||
confirmThreshold: number;
|
|
||||||
batchSize: number;
|
|
||||||
batchDelayMs: number;
|
|
||||||
};
|
|
||||||
cases: {
|
|
||||||
dmOnWarn: boolean;
|
|
||||||
logChannelId?: string;
|
|
||||||
autoTimeoutThreshold?: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameConfigType {
|
export interface GameConfigType {
|
||||||
leveling: LevelingConfig;
|
leveling: LevelingConfig;
|
||||||
economy: EconomyConfig;
|
economy: EconomyConfig;
|
||||||
@@ -158,160 +109,11 @@ export interface GameConfigType {
|
|||||||
trivia: TriviaConfig;
|
trivia: TriviaConfig;
|
||||||
moderation: ModerationConfig;
|
moderation: ModerationConfig;
|
||||||
system: Record<string, unknown>;
|
system: Record<string, unknown>;
|
||||||
studentRole: string;
|
|
||||||
visitorRole: string;
|
|
||||||
colorRoles: string[];
|
|
||||||
welcomeChannelId?: string;
|
|
||||||
welcomeMessage?: string;
|
|
||||||
feedbackChannelId?: string;
|
|
||||||
terminal?: {
|
|
||||||
channelId: string;
|
|
||||||
messageId: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config: GameConfigType = {} as GameConfigType;
|
export const config: GameConfigType = {} as GameConfigType;
|
||||||
|
|
||||||
const bigIntSchema = z.union([z.string(), z.number(), z.bigint()])
|
export const GameConfig = config;
|
||||||
.refine((val) => {
|
|
||||||
try {
|
|
||||||
BigInt(val);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, { message: "Must be a valid integer" })
|
|
||||||
.transform((val) => BigInt(val));
|
|
||||||
|
|
||||||
const fileConfigSchema = z.object({
|
|
||||||
leveling: z.object({
|
|
||||||
base: z.number(),
|
|
||||||
exponent: z.number(),
|
|
||||||
chat: z.object({
|
|
||||||
cooldownMs: z.number(),
|
|
||||||
minXp: z.number(),
|
|
||||||
maxXp: z.number(),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
economy: z.object({
|
|
||||||
daily: z.object({
|
|
||||||
amount: bigIntSchema,
|
|
||||||
streakBonus: bigIntSchema,
|
|
||||||
weeklyBonus: bigIntSchema.default(50n),
|
|
||||||
cooldownMs: z.number(),
|
|
||||||
}),
|
|
||||||
transfers: z.object({
|
|
||||||
allowSelfTransfer: z.boolean(),
|
|
||||||
minAmount: bigIntSchema,
|
|
||||||
}),
|
|
||||||
exam: z.object({
|
|
||||||
multMin: z.number(),
|
|
||||||
multMax: z.number(),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
inventory: z.object({
|
|
||||||
maxStackSize: bigIntSchema,
|
|
||||||
maxSlots: z.number(),
|
|
||||||
}),
|
|
||||||
commands: z.record(z.string(), z.boolean()),
|
|
||||||
lootdrop: z.object({
|
|
||||||
activityWindowMs: z.number(),
|
|
||||||
minMessages: z.number(),
|
|
||||||
spawnChance: z.number(),
|
|
||||||
cooldownMs: z.number(),
|
|
||||||
reward: z.object({
|
|
||||||
min: z.number(),
|
|
||||||
max: z.number(),
|
|
||||||
currency: z.string(),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
studentRole: z.string(),
|
|
||||||
visitorRole: z.string(),
|
|
||||||
colorRoles: z.array(z.string()).default([]),
|
|
||||||
welcomeChannelId: z.string().optional(),
|
|
||||||
welcomeMessage: z.string().optional(),
|
|
||||||
feedbackChannelId: z.string().optional(),
|
|
||||||
terminal: z.object({
|
|
||||||
channelId: z.string(),
|
|
||||||
messageId: z.string()
|
|
||||||
}).optional(),
|
|
||||||
moderation: z.object({
|
|
||||||
prune: z.object({
|
|
||||||
maxAmount: z.number().default(100),
|
|
||||||
confirmThreshold: z.number().default(50),
|
|
||||||
batchSize: z.number().default(100),
|
|
||||||
batchDelayMs: z.number().default(1000)
|
|
||||||
}),
|
|
||||||
cases: z.object({
|
|
||||||
dmOnWarn: z.boolean().default(true),
|
|
||||||
logChannelId: z.string().optional(),
|
|
||||||
autoTimeoutThreshold: z.number().optional()
|
|
||||||
})
|
|
||||||
}).default({
|
|
||||||
prune: {
|
|
||||||
maxAmount: 100,
|
|
||||||
confirmThreshold: 50,
|
|
||||||
batchSize: 100,
|
|
||||||
batchDelayMs: 1000
|
|
||||||
},
|
|
||||||
cases: {
|
|
||||||
dmOnWarn: true
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
trivia: z.object({
|
|
||||||
entryFee: bigIntSchema,
|
|
||||||
rewardMultiplier: z.number().min(0).max(10),
|
|
||||||
timeoutSeconds: z.number().min(5).max(300),
|
|
||||||
cooldownMs: z.number().min(0),
|
|
||||||
categories: z.array(z.number()).default([]),
|
|
||||||
difficulty: z.enum(['easy', 'medium', 'hard', 'random']).default('random'),
|
|
||||||
}).default({
|
|
||||||
entryFee: 50n,
|
|
||||||
rewardMultiplier: 1.8,
|
|
||||||
timeoutSeconds: 30,
|
|
||||||
cooldownMs: 60000,
|
|
||||||
categories: [],
|
|
||||||
difficulty: 'random'
|
|
||||||
}),
|
|
||||||
system: z.record(z.string(), z.any()).default({}),
|
|
||||||
});
|
|
||||||
|
|
||||||
type FileConfig = z.infer<typeof fileConfigSchema>;
|
|
||||||
|
|
||||||
function loadFromFile(): FileConfig | null {
|
|
||||||
if (!existsSync(configPath)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const raw = readFileSync(configPath, 'utf-8');
|
|
||||||
const rawConfig = JSON.parse(raw);
|
|
||||||
return fileConfigSchema.parse(rawConfig);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load config from file:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFileConfig(fileConfig: FileConfig) {
|
|
||||||
Object.assign(config, {
|
|
||||||
leveling: fileConfig.leveling,
|
|
||||||
economy: fileConfig.economy,
|
|
||||||
inventory: fileConfig.inventory,
|
|
||||||
commands: fileConfig.commands,
|
|
||||||
lootdrop: fileConfig.lootdrop,
|
|
||||||
trivia: fileConfig.trivia,
|
|
||||||
moderation: fileConfig.moderation,
|
|
||||||
system: fileConfig.system,
|
|
||||||
studentRole: fileConfig.studentRole,
|
|
||||||
visitorRole: fileConfig.visitorRole,
|
|
||||||
colorRoles: fileConfig.colorRoles,
|
|
||||||
welcomeChannelId: fileConfig.welcomeChannelId,
|
|
||||||
welcomeMessage: fileConfig.welcomeMessage,
|
|
||||||
feedbackChannelId: fileConfig.feedbackChannelId,
|
|
||||||
terminal: fileConfig.terminal,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFromDatabase(): Promise<boolean> {
|
async function loadFromDatabase(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
@@ -358,88 +160,48 @@ async function loadFromDatabase(): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadDefaults(): Promise<void> {
|
||||||
|
console.warn("⚠️ No game config found in database. Using defaults.");
|
||||||
|
const { gameSettingsService } = await import('@shared/modules/game-settings/game-settings.service');
|
||||||
|
const defaults = gameSettingsService.getDefaults();
|
||||||
|
Object.assign(config, {
|
||||||
|
leveling: defaults.leveling,
|
||||||
|
economy: {
|
||||||
|
...defaults.economy,
|
||||||
|
daily: {
|
||||||
|
...defaults.economy.daily,
|
||||||
|
amount: BigInt(defaults.economy.daily.amount),
|
||||||
|
streakBonus: BigInt(defaults.economy.daily.streakBonus),
|
||||||
|
weeklyBonus: BigInt(defaults.economy.daily.weeklyBonus),
|
||||||
|
},
|
||||||
|
transfers: {
|
||||||
|
...defaults.economy.transfers,
|
||||||
|
minAmount: BigInt(defaults.economy.transfers.minAmount),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
inventory: {
|
||||||
|
...defaults.inventory,
|
||||||
|
maxStackSize: BigInt(defaults.inventory.maxStackSize),
|
||||||
|
},
|
||||||
|
commands: defaults.commands,
|
||||||
|
lootdrop: defaults.lootdrop,
|
||||||
|
trivia: {
|
||||||
|
...defaults.trivia,
|
||||||
|
entryFee: BigInt(defaults.trivia.entryFee),
|
||||||
|
},
|
||||||
|
moderation: defaults.moderation,
|
||||||
|
system: defaults.system,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function reloadConfig(): Promise<void> {
|
export async function reloadConfig(): Promise<void> {
|
||||||
const dbLoaded = await loadFromDatabase();
|
const dbLoaded = await loadFromDatabase();
|
||||||
|
|
||||||
if (!dbLoaded) {
|
if (!dbLoaded) {
|
||||||
const fileConfig = loadFromFile();
|
await loadDefaults();
|
||||||
if (fileConfig) {
|
|
||||||
applyFileConfig(fileConfig);
|
|
||||||
console.log("📄 Game config loaded from file (database not available).");
|
|
||||||
} else {
|
|
||||||
console.warn("⚠️ No game config found in database or file. Using defaults.");
|
|
||||||
const { gameSettingsService } = await import('@shared/modules/game-settings/game-settings.service');
|
|
||||||
const defaults = gameSettingsService.getDefaults();
|
|
||||||
Object.assign(config, {
|
|
||||||
leveling: defaults.leveling,
|
|
||||||
economy: {
|
|
||||||
...defaults.economy,
|
|
||||||
daily: {
|
|
||||||
...defaults.economy.daily,
|
|
||||||
amount: BigInt(defaults.economy.daily.amount),
|
|
||||||
streakBonus: BigInt(defaults.economy.daily.streakBonus),
|
|
||||||
weeklyBonus: BigInt(defaults.economy.daily.weeklyBonus),
|
|
||||||
},
|
|
||||||
transfers: {
|
|
||||||
...defaults.economy.transfers,
|
|
||||||
minAmount: BigInt(defaults.economy.transfers.minAmount),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
inventory: {
|
|
||||||
...defaults.inventory,
|
|
||||||
maxStackSize: BigInt(defaults.inventory.maxStackSize),
|
|
||||||
},
|
|
||||||
commands: defaults.commands,
|
|
||||||
lootdrop: defaults.lootdrop,
|
|
||||||
trivia: {
|
|
||||||
...defaults.trivia,
|
|
||||||
entryFee: BigInt(defaults.trivia.entryFee),
|
|
||||||
},
|
|
||||||
moderation: defaults.moderation,
|
|
||||||
system: defaults.system,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadFileSync(): void {
|
|
||||||
const fileConfig = loadFromFile();
|
|
||||||
if (fileConfig) {
|
|
||||||
applyFileConfig(fileConfig);
|
|
||||||
console.log("📄 Game config loaded from file (sync).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GameConfig = config;
|
|
||||||
|
|
||||||
export function saveConfig(newConfig: unknown) {
|
|
||||||
const validatedConfig = fileConfigSchema.parse(newConfig);
|
|
||||||
const jsonString = JSON.stringify(validatedConfig, jsonReplacer, 4);
|
|
||||||
writeFileSync(configPath, jsonString, 'utf-8');
|
|
||||||
applyFileConfig(validatedConfig);
|
|
||||||
console.log("🔄 Config saved to file.");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toggleCommand(commandName: string, enabled: boolean) {
|
|
||||||
const fileConfig = loadFromFile();
|
|
||||||
if (!fileConfig) {
|
|
||||||
console.error("Cannot toggle command: no file config available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newConfig = {
|
|
||||||
...fileConfig,
|
|
||||||
commands: {
|
|
||||||
...fileConfig.commands,
|
|
||||||
[commandName]: enabled
|
|
||||||
}
|
|
||||||
};
|
|
||||||
saveConfig(newConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function initializeConfig(): Promise<void> {
|
export async function initializeConfig(): Promise<void> {
|
||||||
loadFileSync();
|
|
||||||
await reloadConfig();
|
await reloadConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFileSync();
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user