diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..076f14f --- /dev/null +++ b/CLAUDE.md @@ -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 => { + 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` | diff --git a/bun.lock b/bun.lock index fad3302..50e64ba 100644 --- a/bun.lock +++ b/bun.lock @@ -24,6 +24,7 @@ "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", @@ -154,6 +155,8 @@ "@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=="], @@ -188,6 +191,26 @@ "@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=="], @@ -348,6 +371,8 @@ "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=="], @@ -358,6 +383,12 @@ "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=="], @@ -392,8 +423,12 @@ "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="], + "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="], @@ -406,20 +441,30 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "ndarray": ["ndarray@1.0.19", "", { "dependencies": { "iota-array": "^1.0.0", "is-buffer": "^1.0.2" } }, "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ=="], + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "onnxruntime-common": ["onnxruntime-common@1.21.0", "", {}, "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ=="], + + "onnxruntime-web": ["onnxruntime-web@1.21.0", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.21.0", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-adzOe+7uI7lKz6pQNbAsLMQd2Fq5Jhmoxd8LZjJr8m3KvbFyiYyRxRiC57/XXD+jb18voppjeGAjoZmskXG+7A=="], + "panel": ["panel@workspace:panel"], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], "postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="], + "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], @@ -476,6 +521,8 @@ "@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=="], diff --git a/panel/package.json b/panel/package.json index f0e38c0..2434b30 100644 --- a/panel/package.json +++ b/panel/package.json @@ -9,6 +9,7 @@ "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", diff --git a/panel/src/pages/BackgroundRemoval.tsx b/panel/src/pages/BackgroundRemoval.tsx new file mode 100644 index 0000000..2d6e384 --- /dev/null +++ b/panel/src/pages/BackgroundRemoval.tsx @@ -0,0 +1,881 @@ +import { useState, useRef, useCallback, useEffect } from "react"; +import { Upload, Download, X, Wand2, ImageIcon, Loader2 } from "lucide-react"; +import { cn } from "../lib/utils"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const CHECKERBOARD: React.CSSProperties = { + backgroundImage: ` + linear-gradient(45deg, #444 25%, transparent 25%), + linear-gradient(-45deg, #444 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #444 75%), + linear-gradient(-45deg, transparent 75%, #444 75%) + `, + backgroundSize: "16px 16px", + backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px", + backgroundColor: "#2a2a2a", +}; + +type BgPreset = { label: string; style: React.CSSProperties }; + +const BG_PRESETS: BgPreset[] = [ + { label: "Checker", style: CHECKERBOARD }, + { label: "White", style: { backgroundColor: "#ffffff" } }, + { label: "Black", style: { backgroundColor: "#000000" } }, + { label: "Red", style: { backgroundColor: "#e53e3e" } }, + { label: "Green", style: { backgroundColor: "#38a169" } }, + { label: "Blue", style: { backgroundColor: "#3182ce" } }, +]; + +// Max normalised distances for each keying space +const MAX_RGB = Math.sqrt(3); // ≈ 1.732 +const MAX_HSV = 1.5; // sqrt(0.5² × 4 + 1² + 1² × 0.25) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function rgbToHex(r: number, g: number, b: number): string { + return `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`; +} + +function hexToRgb(hex: string): [number, number, number] | null { + const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex.trim()); + if (!m) return null; + return [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)]; +} + +// --------------------------------------------------------------------------- +// WebGL — shaders +// --------------------------------------------------------------------------- + +const VERT = ` +attribute vec2 aPos; +varying vec2 vUV; +void main() { + vUV = aPos * 0.5 + 0.5; + vUV.y = 1.0 - vUV.y; + gl_Position = vec4(aPos, 0.0, 1.0); +}`; + +const KEY_FRAG = ` +precision mediump float; +uniform sampler2D uImage; +uniform vec3 uKey; +uniform float uTol; +uniform float uFeather; +uniform float uSatMin; +uniform float uSpill; +uniform float uHueMode; +varying vec2 vUV; + +vec3 rgbToHsv(vec3 c) { + float cmax = max(c.r, max(c.g, c.b)); + float cmin = min(c.r, min(c.g, c.b)); + float delta = cmax - cmin; + float h = 0.0; + float s = (cmax < 0.0001) ? 0.0 : delta / cmax; + if (delta > 0.0001) { + if (cmax == c.r) h = mod((c.g - c.b) / delta, 6.0); + else if (cmax == c.g) h = (c.b - c.r) / delta + 2.0; + else h = (c.r - c.g) / delta + 4.0; + h /= 6.0; + } + return vec3(h, s, cmax); +} + +void main() { + vec4 c = texture2D(uImage, vUV); + vec3 hsv = rgbToHsv(c.rgb); + vec3 keyHsv = rgbToHsv(uKey); + + float d; + if (uHueMode > 0.5) { + float dh = abs(hsv.x - keyHsv.x); + if (dh > 0.5) dh = 1.0 - dh; + float ds = abs(hsv.y - keyHsv.y); + float dv = abs(hsv.z - keyHsv.z); + d = sqrt(dh * dh * 4.0 + ds * ds + dv * dv * 0.25); + } else { + d = distance(c.rgb, uKey); + } + + float a = c.a; + if (hsv.y >= uSatMin) { + if (d <= uTol) { + a = 0.0; + } else if (uFeather > 0.0 && d <= uTol + uFeather) { + a = (d - uTol) / uFeather * c.a; + } + } + + vec3 rgb = c.rgb; + if (uSpill > 0.0) { + float edgeDist = max(0.0, d - uTol); + float spillZone = max(uFeather + uTol * 0.5, 0.01); + float spillFact = clamp(1.0 - edgeDist / spillZone, 0.0, 1.0) * uSpill; + + if (uKey.g >= uKey.r && uKey.g >= uKey.b) { + float excess = rgb.g - max(rgb.r, rgb.b); + if (excess > 0.0) rgb.g -= excess * spillFact; + } else if (uKey.b >= uKey.r && uKey.b >= uKey.g) { + float excess = rgb.b - max(rgb.r, rgb.g); + if (excess > 0.0) rgb.b -= excess * spillFact; + } else { + float excess = rgb.r - max(rgb.g, rgb.b); + if (excess > 0.0) rgb.r -= excess * spillFact; + } + } + + gl_FragColor = vec4(rgb, a); +}`; + +const HALO_FRAG = ` +precision mediump float; +uniform sampler2D uKeyed; +uniform float uHaloStr; +uniform float uHaloRadius; +uniform vec2 uTexelSize; +varying vec2 vUV; + +void main() { + vec4 c = texture2D(uKeyed, vUV); + + if (uHaloStr <= 0.0) { + gl_FragColor = c; + return; + } + + vec2 r = uTexelSize * uHaloRadius; + vec2 rd = r * 0.7071; + + float minA = c.a; + minA = min(minA, texture2D(uKeyed, vUV + vec2( r.x, 0.0 )).a); + minA = min(minA, texture2D(uKeyed, vUV + vec2(-r.x, 0.0 )).a); + minA = min(minA, texture2D(uKeyed, vUV + vec2( 0.0, r.y )).a); + minA = min(minA, texture2D(uKeyed, vUV + vec2( 0.0, -r.y )).a); + minA = min(minA, texture2D(uKeyed, vUV + vec2( rd.x, rd.y)).a); + minA = min(minA, texture2D(uKeyed, vUV + vec2(-rd.x, rd.y)).a); + minA = min(minA, texture2D(uKeyed, vUV + vec2( rd.x, -rd.y)).a); + minA = min(minA, texture2D(uKeyed, vUV + vec2(-rd.x, -rd.y)).a); + + gl_FragColor = vec4(c.rgb, mix(c.a, minA, uHaloStr)); +}`; + +// --------------------------------------------------------------------------- +// WebGL — types + init +// --------------------------------------------------------------------------- + +type GlState = { + gl: WebGLRenderingContext; + kProg: WebGLProgram; + srcTex: WebGLTexture; + fbo: WebGLFramebuffer; + fboTex: WebGLTexture; + uKey: WebGLUniformLocation; + uTol: WebGLUniformLocation; + uFeather: WebGLUniformLocation; + uSatMin: WebGLUniformLocation; + uSpill: WebGLUniformLocation; + uHueMode: WebGLUniformLocation; + hProg: WebGLProgram; + uHaloStr: WebGLUniformLocation; + uHaloRadius: WebGLUniformLocation; + uTexelSize: WebGLUniformLocation; +}; + +function compileShader(gl: WebGLRenderingContext, type: number, src: string): WebGLShader { + const s = gl.createShader(type)!; + gl.shaderSource(s, src); + gl.compileShader(s); + return s; +} + +function makeProgram(gl: WebGLRenderingContext, vs: WebGLShader, fs: WebGLShader): WebGLProgram { + const p = gl.createProgram()!; + gl.attachShader(p, vs); + gl.attachShader(p, fs); + gl.bindAttribLocation(p, 0, "aPos"); + gl.linkProgram(p); + return p; +} + +function makeTexture(gl: WebGLRenderingContext): WebGLTexture { + const t = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, t); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + return t; +} + +function initGl(canvas: HTMLCanvasElement): GlState | null { + const gl = canvas.getContext("webgl", { + premultipliedAlpha: false, + preserveDrawingBuffer: true, + }) as WebGLRenderingContext | null; + if (!gl) return null; + + const vs = compileShader(gl, gl.VERTEX_SHADER, VERT); + const kFrag = compileShader(gl, gl.FRAGMENT_SHADER, KEY_FRAG); + const hFrag = compileShader(gl, gl.FRAGMENT_SHADER, HALO_FRAG); + const kProg = makeProgram(gl, vs, kFrag); + const hProg = makeProgram(gl, vs, hFrag); + + const buf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + + const srcTex = makeTexture(gl); + const fboTex = makeTexture(gl); + + const fbo = gl.createFramebuffer()!; + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, fboTex, 0); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + return { + gl, kProg, hProg, srcTex, fbo, fboTex, + uKey: gl.getUniformLocation(kProg, "uKey")!, + uTol: gl.getUniformLocation(kProg, "uTol")!, + uFeather: gl.getUniformLocation(kProg, "uFeather")!, + uSatMin: gl.getUniformLocation(kProg, "uSatMin")!, + uSpill: gl.getUniformLocation(kProg, "uSpill")!, + uHueMode: gl.getUniformLocation(kProg, "uHueMode")!, + uHaloStr: gl.getUniformLocation(hProg, "uHaloStr")!, + uHaloRadius: gl.getUniformLocation(hProg, "uHaloRadius")!, + uTexelSize: gl.getUniformLocation(hProg, "uTexelSize")!, + }; +} + +// --------------------------------------------------------------------------- +// AI remove tab +// --------------------------------------------------------------------------- + +type AiStatus = "idle" | "loading" | "done" | "error"; + +function AiRemoveTab({ imageFile, imageSrc, onClear }: { + imageFile: File; + imageSrc: string; + onClear: () => void; +}) { + const [status, setStatus] = useState("idle"); + const [resultUrl, setResultUrl] = useState(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 ( +
+ {/* Toolbar */} +
+ + {imageFile.name} + + + {status !== "done" ? ( + + ) : ( + + )} +
+ + {status === "error" && ( +

+ Something went wrong. Check the console for details. +

+ )} + + {/* Side-by-side */} +
+
+

Original

+
+ Original +
+
+ +
+
+

+ Result — transparent background +

+
+ {BG_PRESETS.map((preset, i) => ( +
+
+
+ {resultUrl ? ( +
+ Result +
+ ) : ( +
+ {status === "loading" ? ( + <>{progress} + ) : ( + <>Click Remove Background to process + )} +
+ )} +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// BackgroundRemoval component +// --------------------------------------------------------------------------- + +type Mode = "chroma" | "ai"; + +export function BackgroundRemoval() { + const [mode, setMode] = useState("chroma"); + + const [imageSrc, setImageSrc] = useState(null); + const [imageFile, setImageFile] = useState(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(null); + const glCanvasRef = useRef(null); + const glRef = useRef(null); + const fileInputRef = useRef(null); + + useEffect(() => { + if (keyColor) setHexInput(rgbToHex(...keyColor)); + }, [keyColor]); + + useEffect(() => { + if (!imageSrc) return; + setImageReady(false); + setKeyColor(null); + setHexInput(""); + + const img = new Image(); + img.onload = () => { + const src = sourceCanvasRef.current; + if (!src) return; + src.width = img.naturalWidth; + src.height = img.naturalHeight; + src.getContext("2d")!.drawImage(img, 0, 0); + + const glCanvas = glCanvasRef.current; + if (!glCanvas) return; + glCanvas.width = img.naturalWidth; + glCanvas.height = img.naturalHeight; + + if (!glRef.current) { + glRef.current = initGl(glCanvas); + if (!glRef.current) { + console.error("BackgroundRemoval: WebGL not supported"); + return; + } + } + + const { gl, srcTex, fboTex } = glRef.current; + gl.bindTexture(gl.TEXTURE_2D, srcTex); + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false); + gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img); + + gl.bindTexture(gl.TEXTURE_2D, fboTex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, img.naturalWidth, img.naturalHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + gl.viewport(0, 0, img.naturalWidth, img.naturalHeight); + setImageReady(true); + }; + img.src = imageSrc; + }, [imageSrc]); + + useEffect(() => { + const state = glRef.current; + if (!state || !imageReady || !keyColor) return; + + const w = glCanvasRef.current!.width; + const h = glCanvasRef.current!.height; + const { gl } = state; + const MAX = hueMode ? MAX_HSV : MAX_RGB; + + gl.bindFramebuffer(gl.FRAMEBUFFER, state.fbo); + gl.viewport(0, 0, w, h); + gl.useProgram(state.kProg); + gl.bindTexture(gl.TEXTURE_2D, state.srcTex); + gl.uniform3f(state.uKey, keyColor[0] / 255, keyColor[1] / 255, keyColor[2] / 255); + gl.uniform1f(state.uTol, (tolerance / 100) * MAX); + gl.uniform1f(state.uFeather, (feather / 100) * MAX); + gl.uniform1f(state.uSatMin, satMin / 100); + gl.uniform1f(state.uSpill, spillStr / 100); + gl.uniform1f(state.uHueMode, hueMode ? 1.0 : 0.0); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, w, h); + gl.useProgram(state.hProg); + gl.bindTexture(gl.TEXTURE_2D, state.fboTex); + gl.uniform1f(state.uHaloStr, haloStr / 100); + gl.uniform1f(state.uHaloRadius, haloRadius); + gl.uniform2f(state.uTexelSize, 1 / w, 1 / h); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + }, [keyColor, tolerance, feather, satMin, spillStr, hueMode, haloStr, haloRadius, imageReady]); + + const loadFile = useCallback((file: File) => { + if (imageSrc) URL.revokeObjectURL(imageSrc); + setImageSrc(URL.createObjectURL(file)); + setImageFile(file); + }, [imageSrc]); + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files[0]; + if (file?.type.startsWith("image/")) loadFile(file); + }; + + const handleCanvasClick = (e: React.MouseEvent) => { + 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 ( +
+ {/* Page header + mode toggle */} +
+
+

+ Background Removal +

+

+ Upload an image then remove its background — either by selecting a + key color (chroma key) or using the AI model. +

+
+ +
+ +
{ 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", + )} + > + +
+

+ {dragOver ? "Drop to upload" : "Drop an image here"} +

+

+ or click to browse · PNG, JPEG, WebP · max 15 MB +

+
+
+ + { const f = e.target.files?.[0]; if (f) loadFile(f); }} + className="hidden" + /> +
+ ); + } + + // ── Editor screen ────────────────────────────────────────────────────────── + const hasResult = imageReady && keyColor !== null; + + return ( +
+ {/* Page header + mode toggle */} +
+

+ Background Removal +

+ +
+ + {/* AI tab */} + {mode === "ai" && ( + + )} + + {/* Chroma key tab */} + {mode === "chroma" && ( + <> + {/* Toolbar */} +
+ + {imageFile?.name} + + + +
+ + {/* Controls */} +
+ {/* Row 1 — Key color + mode */} +
+
+

Key Color

+
+
+ 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", + )} + /> +
+
+ +
+

Keying Mode

+
+ + +
+
+ + {!keyColor && ( + + Click the image to pick a key color + + )} +
+ + {/* Row 2 — Matte */} +
+

Matte

+
+
+
+

Tolerance

+ {tolerance}% +
+ setTolerance(Number(e.target.value))} className="w-full accent-primary" /> +
+ +
+
+

Sat. Gate

+ {satMin}% +
+ setSatMin(Number(e.target.value))} className="w-full accent-primary" /> +

Skip pixels below this saturation — preserves neutral tones

+
+ +
+
+

Edge Feather

+ {feather}% +
+ setFeather(Number(e.target.value))} className="w-full accent-primary" /> +
+
+
+ + {/* Row 3 — Cleanup */} +
+

Cleanup

+
+
+
+

Despill

+ {spillStr}% +
+ setSpillStr(Number(e.target.value))} className="w-full accent-primary" /> +

Suppress key-color fringing on subject edges

+
+ +
+
+

Halo Remove

+ {haloStr}% +
+ setHaloStr(Number(e.target.value))} className="w-full accent-primary" /> +

Erode the matte inward to eliminate bright rim pixels

+
+ +
+
+

Halo Radius

+ {haloRadius} px +
+ setHaloRadius(Number(e.target.value))} className="w-full accent-primary" disabled={haloStr === 0} /> +

How far to look for transparent neighbours

+
+
+
+
+ + {/* Side-by-side view */} +
+
+

+ Original — click to pick key color +

+
+ +
+
+ +
+
+

+ Result — transparent background +

+
+ {BG_PRESETS.map((preset, i) => ( +
+
+
+
+ +
+ {!hasResult && ( +
+ + + {imageReady ? "Click the image on the left to pick a key color" : "Loading image…"} + +
+ )} +
+
+
+ + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// ModeToggle +// --------------------------------------------------------------------------- + +function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) { + return ( +
+ + +
+ ); +} diff --git a/panel/src/pages/CanvasTool.tsx b/panel/src/pages/CanvasTool.tsx new file mode 100644 index 0000000..d47e056 --- /dev/null +++ b/panel/src/pages/CanvasTool.tsx @@ -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(null); + const [imageFile, setImageFile] = useState(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("fit"); + const [alignment, setAlignment] = useState([0, 0]); + const [bgPreset, setBgPreset] = useState(0); + + const fileInputRef = useRef(null); + const sourceCanvasRef = useRef(null); + // previewCanvasRef — drawn to directly on every setting change; no toDataURL + const previewCanvasRef = useRef(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 ( +
+
+

Canvas Tool

+

+ Upload an image to resize, scale, or center it on a canvas of any size. +

+
+ +
{ 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", + )} + > + +
+

+ {dragOver ? "Drop to upload" : "Drop an image here"} +

+

+ or click to browse · PNG, JPEG, WebP · max 15 MB +

+
+
+ + { const f = e.target.files?.[0]; if (f) loadFile(f); }} + className="hidden" + /> +
+ ); + } + + // ── Editor screen ────────────────────────────────────────────────────────── + const alignDisabled = scaleMode === "stretch"; + + return ( +
+ {/* Hidden source canvas */} + + + {/* Toolbar */} +
+

Canvas Tool

+ + {imageFile?.name} + + + {naturalW}×{naturalH} + + + +
+ + {/* Controls */} +
+ {/* Row 1: output size */} +
+
+

Output Size

+
+ 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" + /> + × + 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" + /> + +
+
+ + {/* Size presets */} +
+

Presets

+
+ + {SIZE_PRESETS.map((p) => ( + + ))} +
+
+
+ + {/* Row 2: scale mode + alignment */} +
+
+

Scale Mode

+
+ {SCALE_MODES.map((m) => ( + + ))} +
+

+ {SCALE_MODES.find((m) => m.id === scaleMode)?.desc} +

+
+ +
+

+ {scaleMode === "fill" ? "Crop Position" : "Alignment"} +

+
+ {ALIGN_GRID.map(([col, row], i) => { + const active = alignment[0] === col && alignment[1] === row; + return ( + + ); + })} +
+
+
+
+ + {/* Side-by-side preview */} +
+ {/* Original */} +
+

+ Original — {naturalW}×{naturalH} +

+
+
+ Source +
+
+
+ + {/* Result — canvas drawn directly, no toDataURL */} +
+
+

+ Result — {outW || "?"}×{outH || "?"} +

+
+ {BG_PRESETS.map((preset, i) => ( +
+
+
+
+ +
+ {!imageReady && ( +
+ + + Loading image… + +
+ )} +
+
+
+ + {/* Info strip */} + {imageReady && ( +
+ + + Output: {outW}×{outH} px + {" · "} + Mode: {scaleMode} + {scaleMode !== "stretch" && ( + <> + {" · "} + Align:{" "} + + {alignment[1] === -1 ? "Top" : alignment[1] === 0 ? "Middle" : "Bottom"} + -{alignment[0] === -1 ? "Left" : alignment[0] === 0 ? "Center" : "Right"} + + + )} + +
+ )} +
+ ); +} diff --git a/panel/src/pages/CropTool.tsx b/panel/src/pages/CropTool.tsx new file mode 100644 index 0000000..8052812 --- /dev/null +++ b/panel/src/pages/CropTool.tsx @@ -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(null); + const [imageFile, setImageFile] = useState(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(null); + const displayCanvasRef = useRef(null); + const sourceCanvasRef = useRef(null); + // Direct canvas preview — avoids toDataURL on every drag frame + const previewCanvasRef = useRef(null); + const imgElRef = useRef(null); + const dragStateRef = useRef(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): [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) => { + 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) => { + 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 ( +
+
+

Crop Tool

+

+ 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. +

+
+ +
{ + 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", + )} + > + +
+

+ {dragOver ? "Drop to upload" : "Drop an image here"} +

+

+ or click to browse · PNG, JPEG, WebP · max 15 MB +

+
+
+ + { const f = e.target.files?.[0]; if (f) loadFile(f); }} + className="hidden" + /> +
+ ); + } + + // ── Editor screen ──────────────────────────────────────────────────────────── + return ( +
+ + + {/* Toolbar */} +
+

Crop Tool

+ + {imageFile?.name} + + + +
+ + {/* Controls */} +
+
+ + + + + + src: {imgW} × {imgH} + +
+ +
+ +
+ + +
+

+ Drag inside the canvas to pan · drag handles to resize +

+
+
+ + {/* Editor + Preview */} +
+
+

+ Editor +

+
+ +
+
+ +
+

+ Preview +

+
+ {imageReady ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ {imageReady && ( +

+ {Math.round(cropW)} × {Math.round(cropH)} px +

+ )} +
+
+
+ ); +} diff --git a/panel/src/pages/HueShifter.tsx b/panel/src/pages/HueShifter.tsx new file mode 100644 index 0000000..f347a56 --- /dev/null +++ b/panel/src/pages/HueShifter.tsx @@ -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 ( +
+
+

{label}

+ + {`${value > 0 ? "+" : ""}${value}${unit}`} + +
+
+ {gradient && ( +
+ )} + 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", + )} + /> +
+
+ ); +} + +// --------------------------------------------------------------------------- +// HueShifter +// --------------------------------------------------------------------------- + +export function HueShifter() { + const [imageSrc, setImageSrc] = useState(null); + const [imageFile, setImageFile] = useState(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(null); + // glCanvasRef — WebGL result canvas (also the preview) + const glCanvasRef = useRef(null); + const glRef = useRef(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 ( +
+
+

Hue Shifter

+

+ Upload an image and shift its hue, saturation, and lightness to create colour + variants. Fully transparent pixels are preserved. +

+
+ +
{ 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", + )} + > + +
+

+ {dragOver ? "Drop to upload" : "Drop an image here"} +

+

+ or click to browse · PNG, JPEG, WebP · max 15 MB +

+
+
+ + { const f = e.target.files?.[0]; if (f) loadFile(f); }} + className="hidden" + /> +
+ ); + } + + // ── Editor screen ────────────────────────────────────────────────────────── + return ( +
+ {/* Toolbar */} +
+

Hue Shifter

+ + {imageFile?.name} + + + + +
+ + {/* Controls */} +
+ + + +
+ + {/* Side-by-side */} +
+
+

+ Original +

+
+
+ Original +
+
+
+ +
+

+ Result +

+
+ {/* WebGL canvas always in DOM; hidden until image is ready */} +
+ + {!imageReady && ( +
+ +
+ )} +
+
+
+
+
+ ); +} diff --git a/panel/src/pages/ItemStudio.tsx b/panel/src/pages/ItemStudio.tsx new file mode 100644 index 0000000..4b58316 --- /dev/null +++ b/panel/src/pages/ItemStudio.tsx @@ -0,0 +1,1588 @@ +import { useState, useRef, useCallback, useEffect } from "react"; +import { + Upload, + X, + Plus, + Trash2, + Package, + AlertTriangle, + CheckCircle, + Zap, + MessageSquare, + TrendingUp, + Shield, + Palette, + CircleDollarSign, + ImageIcon, + Loader2, + Gift, + Search, +} from "lucide-react"; +import { cn } from "../lib/utils"; +import { get } from "../lib/api"; + +// ===== Types ===== + +type ItemType = "MATERIAL" | "CONSUMABLE" | "EQUIPMENT" | "QUEST"; +type ItemRarity = "C" | "R" | "SR" | "SSR"; +type EffectKind = + | "ADD_XP" + | "ADD_BALANCE" + | "REPLY_MESSAGE" + | "XP_BOOST" + | "TEMP_ROLE" + | "COLOR_ROLE" + | "LOOTBOX"; + +type LootEntryType = "NOTHING" | "CURRENCY" | "XP" | "ITEM"; + +interface LootPoolEntry { + _id: string; + type: LootEntryType; + weight: string; + amountMode: "fixed" | "range"; + amount: string; + minAmount: string; + maxAmount: string; + itemId: string; + selectedItemName: string; + selectedItemRarity: string; + message: string; +} + +interface EffectDraft { + _id: string; + kind: EffectKind; + amount: string; + multiplier: string; + durationSeconds: string; + roleId: string; + message: string; + pool: LootPoolEntry[]; +} + +interface Draft { + name: string; + description: string; + type: ItemType; + rarity: ItemRarity; + price: string; + consume: boolean; + effects: EffectDraft[]; +} + +// ===== Constants ===== + +const TYPE_META: Record< + ItemType, + { label: string; icon: React.ComponentType<{ className?: string }> } +> = { + MATERIAL: { label: "Material", icon: Package }, + CONSUMABLE: { label: "Consumable", icon: Zap }, + EQUIPMENT: { label: "Equipment", icon: Shield }, + QUEST: { label: "Quest", icon: MessageSquare }, +}; + +const RARITY_META: Record< + ItemRarity, + { + label: string; + bg: string; + text: string; + activeBorder: string; + badgeBg: string; + } +> = { + C: { + label: "Common", + bg: "bg-gray-500/15", + text: "text-gray-300", + activeBorder: "border-gray-500", + badgeBg: "bg-gray-500/20", + }, + R: { + label: "Rare", + bg: "bg-blue-500/15", + text: "text-blue-300", + activeBorder: "border-blue-500", + badgeBg: "bg-blue-500/20", + }, + SR: { + label: "Super Rare", + bg: "bg-purple-500/15", + text: "text-purple-300", + activeBorder: "border-purple-500", + badgeBg: "bg-purple-500/20", + }, + SSR: { + label: "SSR", + bg: "bg-amber-500/15", + text: "text-amber-300", + activeBorder: "border-amber-500", + badgeBg: "bg-amber-500/20", + }, +}; + +const RARITY_BADGE: Record = { + 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", +}; + +const EFFECT_META: Record< + EffectKind, + { label: string; icon: React.ComponentType<{ className?: string }> } +> = { + ADD_XP: { label: "Add XP", icon: Zap }, + ADD_BALANCE: { label: "Add Balance", icon: CircleDollarSign }, + REPLY_MESSAGE: { label: "Reply Message", icon: MessageSquare }, + XP_BOOST: { label: "XP Boost", icon: TrendingUp }, + TEMP_ROLE: { label: "Temporary Role", icon: Shield }, + COLOR_ROLE: { label: "Color Role", icon: Palette }, + LOOTBOX: { label: "Lootbox", icon: Gift }, +}; + +const LOOT_TYPE_META: Record< + LootEntryType, + { label: string; barColor: string; textColor: string } +> = { + NOTHING: { + label: "Nothing", + barColor: "bg-text-disabled", + textColor: "text-text-tertiary", + }, + CURRENCY: { + label: "Currency", + barColor: "bg-gold", + textColor: "text-gold", + }, + XP: { + label: "XP", + barColor: "bg-blue-400", + textColor: "text-blue-400", + }, + ITEM: { + label: "Item", + barColor: "bg-purple-400", + textColor: "text-purple-400", + }, +}; + +// ===== Helpers ===== + +function uid() { + return Math.random().toString(36).slice(2); +} + +function makeLootEntry(): LootPoolEntry { + return { + _id: uid(), + type: "CURRENCY", + weight: "1", + amountMode: "fixed", + amount: "", + minAmount: "", + maxAmount: "", + itemId: "", + selectedItemName: "", + selectedItemRarity: "", + message: "", + }; +} + +function makeEffect(kind: EffectKind): EffectDraft { + return { + _id: uid(), + kind, + amount: "", + multiplier: "", + durationSeconds: "", + roleId: "", + message: "", + pool: [], + }; +} + +function defaultDraft(): Draft { + return { + name: "", + description: "", + type: "MATERIAL", + rarity: "C", + price: "", + consume: false, + effects: [], + }; +} + +const inputCls = cn( + "w-full 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", + "placeholder:text-text-tertiary transition-colors" +); + +// ===== Shared UI atoms ===== + +function SectionCard({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +function Field({ + label, + error, + children, +}: { + label: string; + error?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {error &&

{error}

} +
+ ); +} + +// ===== Item Search Picker ===== + +interface ItemResult { + id: number; + name: string; + rarity: string; + iconUrl: string; +} + +function ItemSearchPicker({ + value, + onChange, +}: { + value: { id: number; name: string; rarity: string } | null; + onChange: (item: { id: number; name: string; rarity: string } | null) => void; +}) { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + if (!query.trim()) { + setResults([]); + setOpen(false); + return; + } + const t = setTimeout(async () => { + setLoading(true); + try { + const data = await get<{ items: ItemResult[] }>( + `/api/items?search=${encodeURIComponent(query)}&limit=8` + ); + setResults(data.items); + setOpen(data.items.length > 0); + } catch { + // silently fail – network error shouldn't break the form + } finally { + setLoading(false); + } + }, 300); + return () => clearTimeout(t); + }, [query]); + + useEffect(() => { + function onOutsideClick(e: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + } + document.addEventListener("mousedown", onOutsideClick); + return () => document.removeEventListener("mousedown", onOutsideClick); + }, []); + + if (value) { + return ( +
+ + {value.rarity} + + + {value.name} + + + #{value.id} + + +
+ ); + } + + return ( +
+
+ + setQuery(e.target.value)} + placeholder="Search items by name..." + className={cn(inputCls, "pl-9 pr-9")} + /> + {loading && ( + + )} +
+ {open && results.length > 0 && ( +
+ {results.map((item) => ( + + ))} +
+ )} +
+ ); +} + +// ===== Loot Pool Entry Editor ===== + +function LootPoolEntryEditor({ + entry, + totalWeight, + onChange, + onRemove, +}: { + entry: LootPoolEntry; + totalWeight: number; + onChange: (updated: LootPoolEntry) => void; + onRemove: () => void; +}) { + const upd = (fields: Partial) => + onChange({ ...entry, ...fields }); + + const weight = Number(entry.weight || 0); + const pct = + totalWeight > 0 ? ((weight / totalWeight) * 100).toFixed(1) : "0.0"; + const meta = LOOT_TYPE_META[entry.type]; + + const resetAmounts = { + amount: "", + minAmount: "", + maxAmount: "", + itemId: "", + selectedItemName: "", + selectedItemRarity: "", + }; + + return ( +
+ {/* Header row: type tabs + weight input + percentage + remove */} +
+
+ {(Object.keys(LOOT_TYPE_META) as LootEntryType[]).map((t) => ( + + ))} +
+
+ + upd({ weight: e.target.value })} + className={cn( + "w-14 bg-input border border-border rounded-md px-2 py-1 text-xs text-foreground text-center", + "focus:outline-none focus:border-primary transition-colors" + )} + /> + + {pct}% + +
+ +
+ + {/* NOTHING */} + {entry.type === "NOTHING" && ( + + upd({ message: e.target.value })} + placeholder="You found nothing inside..." + className={inputCls} + /> + + )} + + {/* CURRENCY / XP */} + {(entry.type === "CURRENCY" || entry.type === "XP") && ( +
+
+ {(["fixed", "range"] as const).map((mode) => ( + + ))} +
+ + {entry.amountMode === "fixed" ? ( + + upd({ amount: e.target.value })} + placeholder="e.g. 100" + className={inputCls} + /> + + ) : ( +
+ + upd({ minAmount: e.target.value })} + placeholder="e.g. 50" + className={inputCls} + /> + + + upd({ maxAmount: e.target.value })} + placeholder="e.g. 200" + className={inputCls} + /> + +
+ )} + + + upd({ message: e.target.value })} + placeholder={ + entry.type === "CURRENCY" + ? "You received {amount} coins!" + : "You gained {amount} XP!" + } + className={inputCls} + /> + +
+ )} + + {/* ITEM */} + {entry.type === "ITEM" && ( +
+ + + upd({ + itemId: item ? String(item.id) : "", + selectedItemName: item?.name ?? "", + selectedItemRarity: item?.rarity ?? "", + }) + } + /> + + + upd({ amount: e.target.value })} + placeholder="1" + className={inputCls} + /> + + + upd({ message: e.target.value })} + placeholder="You found a rare item!" + className={inputCls} + /> + +
+ )} +
+ ); +} + +// ===== Lootbox Pool Builder ===== + +function LootboxEditor({ + pool, + onChange, +}: { + pool: LootPoolEntry[]; + onChange: (pool: LootPoolEntry[]) => void; +}) { + const totalWeight = pool.reduce((sum, e) => sum + Number(e.weight || 0), 0); + + const addEntry = () => onChange([...pool, makeLootEntry()]); + const updateEntry = (i: number, updated: LootPoolEntry) => { + const next = [...pool]; + next[i] = updated; + onChange(next); + }; + const removeEntry = (i: number) => + onChange(pool.filter((_, idx) => idx !== i)); + + return ( +
+ {/* Summary bar */} + {pool.length > 0 && ( +
+
+ + {pool.length} entr{pool.length === 1 ? "y" : "ies"} · total + weight {totalWeight} + +
+ {(Object.keys(LOOT_TYPE_META) as LootEntryType[]).map((t) => { + const count = pool.filter((e) => e.type === t).length; + if (!count) return null; + return ( + + {LOOT_TYPE_META[t].label} ×{count} + + ); + })} +
+
+ {/* Stacked probability bar */} +
+ {pool.map((e) => { + const w = Number(e.weight || 0); + const pct = totalWeight > 0 ? (w / totalWeight) * 100 : 0; + return ( +
+ ); + })} +
+
+ )} + + {/* Pool entries */} + {pool.map((entry, i) => ( + updateEntry(i, updated)} + onRemove={() => removeEntry(i)} + /> + ))} + + {/* Empty hint */} + {pool.length === 0 && ( +

+ Add pool entries — each has a type, weight, and reward amount. +

+ )} + + {/* Add entry */} + +
+ ); +} + +// ===== Effect Editor ===== + +function EffectEditor({ + effect, + onChange, + onRemove, +}: { + effect: EffectDraft; + onChange: (updated: EffectDraft) => void; + onRemove: () => void; +}) { + const update = (fields: Partial) => + onChange({ ...effect, ...fields }); + + const resetFields = { + amount: "", + multiplier: "", + durationSeconds: "", + roleId: "", + message: "", + pool: [], + }; + + const EffectIcon = EFFECT_META[effect.kind].icon; + const isLootbox = effect.kind === "LOOTBOX"; + + return ( +
+
+ + + +
+ + {(effect.kind === "ADD_XP" || effect.kind === "ADD_BALANCE") && ( + + update({ amount: e.target.value })} + placeholder="e.g. 100" + className={inputCls} + /> + + )} + + {effect.kind === "REPLY_MESSAGE" && ( + +