diff --git a/bot/assets/graphics/items/.gitkeep b/bot/assets/graphics/items/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/bot/modules/economy/shop.view.ts b/bot/modules/economy/shop.view.ts index 3be9afe..a50271b 100644 --- a/bot/modules/economy/shop.view.ts +++ b/bot/modules/economy/shop.view.ts @@ -1,11 +1,16 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js"; import { createBaseEmbed } from "@/lib/embeds"; +import { resolveAssetUrl } from "@shared/lib/assets"; export function getShopListingMessage(item: { id: number; name: string; description: string | null; formattedPrice: string; iconUrl: string | null; imageUrl: string | null; price: number | bigint }) { + // Resolve asset URLs to full URLs for Discord embeds + const resolvedIconUrl = resolveAssetUrl(item.iconUrl); + const resolvedImageUrl = resolveAssetUrl(item.imageUrl); + const embed = createBaseEmbed(`Shop: ${item.name}`, item.description || "No description available.", "Green") .addFields({ name: "Price", value: item.formattedPrice, inline: true }) - .setThumbnail(item.iconUrl || null) - .setImage(item.imageUrl || null) + .setThumbnail(resolvedIconUrl) + .setImage(resolvedImageUrl) .setFooter({ text: "Click the button below to purchase instantly." }); const buyButton = new ButtonBuilder() diff --git a/bot/modules/inventory/inventory.view.ts b/bot/modules/inventory/inventory.view.ts index c4f806e..4a1adff 100644 --- a/bot/modules/inventory/inventory.view.ts +++ b/bot/modules/inventory/inventory.view.ts @@ -1,6 +1,7 @@ import { EmbedBuilder } from "discord.js"; import type { ItemUsageData } from "@shared/lib/types"; import { EffectType } from "@shared/lib/constants"; +import { resolveAssetUrl } from "@shared/lib/assets"; /** * Inventory entry with item details @@ -43,8 +44,9 @@ export function getItemUseResultEmbed(results: string[], item?: { name: string, if (isLootbox && item) { embed.setTitle(`🎁 ${item.name} Opened!`); - if (item.iconUrl) { - embed.setThumbnail(item.iconUrl); + const resolvedIconUrl = resolveAssetUrl(item.iconUrl); + if (resolvedIconUrl) { + embed.setThumbnail(resolvedIconUrl); } } else { embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!"); diff --git a/scripts/migrate-item-assets.ts b/scripts/migrate-item-assets.ts new file mode 100644 index 0000000..7454503 --- /dev/null +++ b/scripts/migrate-item-assets.ts @@ -0,0 +1,239 @@ +#!/usr/bin/env bun +/** + * Item Asset Migration Script + * + * Downloads images from existing Discord CDN URLs and saves them locally. + * Updates database records to use local asset paths. + * + * Usage: + * bun run scripts/migrate-item-assets.ts # Dry run (no changes) + * bun run scripts/migrate-item-assets.ts --execute # Actually perform migration + */ + +import { resolve, join } from "path"; +import { mkdir } from "node:fs/promises"; + +// Initialize database connection +const { DrizzleClient } = await import("../shared/db/DrizzleClient"); +const { items } = await import("../shared/db/schema"); + +const ASSETS_DIR = resolve(import.meta.dir, "../bot/assets/graphics/items"); +const DRY_RUN = !process.argv.includes("--execute"); + +interface MigrationResult { + itemId: number; + itemName: string; + originalUrl: string; + newPath: string; + status: "success" | "skipped" | "failed"; + error?: string; +} + +/** + * Check if a URL is an external URL (not a local asset path) + */ +function isExternalUrl(url: string | null): boolean { + if (!url) return false; + return url.startsWith("http://") || url.startsWith("https://"); +} + +/** + * Check if a URL is likely a Discord CDN URL + */ +function isDiscordCdnUrl(url: string): boolean { + return url.includes("cdn.discordapp.com") || + url.includes("media.discordapp.net") || + url.includes("discord.gg"); +} + +/** + * Download an image from a URL and save it locally + */ +async function downloadImage(url: string, destPath: string): Promise { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const contentType = response.headers.get("content-type") || ""; + if (!contentType.startsWith("image/")) { + throw new Error(`Invalid content type: ${contentType}`); + } + + const buffer = await response.arrayBuffer(); + await Bun.write(destPath, buffer); +} + +/** + * Migrate a single item's images + */ +async function migrateItem(item: { + id: number; + name: string; + iconUrl: string | null; + imageUrl: string | null; +}): Promise { + const result: MigrationResult = { + itemId: item.id, + itemName: item.name, + originalUrl: item.iconUrl || item.imageUrl || "", + newPath: `/assets/items/${item.id}.png`, + status: "skipped" + }; + + // Check if either URL needs migration + const hasExternalIcon = isExternalUrl(item.iconUrl); + const hasExternalImage = isExternalUrl(item.imageUrl); + + if (!hasExternalIcon && !hasExternalImage) { + result.status = "skipped"; + return result; + } + + // Prefer iconUrl, fall back to imageUrl + const urlToDownload = item.iconUrl || item.imageUrl; + + if (!urlToDownload || !isExternalUrl(urlToDownload)) { + result.status = "skipped"; + return result; + } + + result.originalUrl = urlToDownload; + const destPath = join(ASSETS_DIR, `${item.id}.png`); + + if (DRY_RUN) { + console.log(` [DRY RUN] Would download: ${urlToDownload}`); + console.log(` -> ${destPath}`); + result.status = "success"; + return result; + } + + try { + // Download the image + await downloadImage(urlToDownload, destPath); + + // Update database record + const { eq } = await import("drizzle-orm"); + await DrizzleClient + .update(items) + .set({ + iconUrl: `/assets/items/${item.id}.png`, + imageUrl: `/assets/items/${item.id}.png`, + }) + .where(eq(items.id, item.id)); + + result.status = "success"; + console.log(` ✅ Migrated: ${item.name} (ID: ${item.id})`); + } catch (error) { + result.status = "failed"; + result.error = error instanceof Error ? error.message : String(error); + console.log(` ❌ Failed: ${item.name} (ID: ${item.id}) - ${result.error}`); + } + + return result; +} + +/** + * Main migration function + */ +async function main() { + console.log("═══════════════════════════════════════════════════════════════"); + console.log(" Item Asset Migration Script"); + console.log("═══════════════════════════════════════════════════════════════"); + console.log(); + + if (DRY_RUN) { + console.log(" ⚠️ DRY RUN MODE - No changes will be made"); + console.log(" Run with --execute to perform actual migration"); + console.log(); + } + + // Ensure assets directory exists + await mkdir(ASSETS_DIR, { recursive: true }); + console.log(` 📁 Assets directory: ${ASSETS_DIR}`); + console.log(); + + // Fetch all items + const allItems = await DrizzleClient.select({ + id: items.id, + name: items.name, + iconUrl: items.iconUrl, + imageUrl: items.imageUrl, + }).from(items); + + console.log(` 📦 Found ${allItems.length} total items`); + + // Filter items that need migration + const itemsToMigrate = allItems.filter(item => + isExternalUrl(item.iconUrl) || isExternalUrl(item.imageUrl) + ); + + console.log(` 🔄 ${itemsToMigrate.length} items have external URLs`); + console.log(); + + if (itemsToMigrate.length === 0) { + console.log(" ✨ No items need migration!"); + return; + } + + // Categorize by URL type + const discordCdnItems = itemsToMigrate.filter(item => + isDiscordCdnUrl(item.iconUrl || "") || isDiscordCdnUrl(item.imageUrl || "") + ); + const otherExternalItems = itemsToMigrate.filter(item => + !isDiscordCdnUrl(item.iconUrl || "") && !isDiscordCdnUrl(item.imageUrl || "") + ); + + console.log(` 📊 Breakdown:`); + console.log(` - Discord CDN URLs: ${discordCdnItems.length}`); + console.log(` - Other external URLs: ${otherExternalItems.length}`); + console.log(); + + // Process migrations + console.log(" Starting migration..."); + console.log(); + + const results: MigrationResult[] = []; + + for (const item of itemsToMigrate) { + const result = await migrateItem(item); + results.push(result); + } + + // Summary + console.log(); + console.log("═══════════════════════════════════════════════════════════════"); + console.log(" Migration Summary"); + console.log("═══════════════════════════════════════════════════════════════"); + + const successful = results.filter(r => r.status === "success").length; + const skipped = results.filter(r => r.status === "skipped").length; + const failed = results.filter(r => r.status === "failed").length; + + console.log(` ✅ Successful: ${successful}`); + console.log(` ⏭️ Skipped: ${skipped}`); + console.log(` ❌ Failed: ${failed}`); + console.log(); + + if (failed > 0) { + console.log(" Failed items:"); + for (const result of results.filter(r => r.status === "failed")) { + console.log(` - ${result.itemName}: ${result.error}`); + } + } + + if (DRY_RUN) { + console.log(); + console.log(" ⚠️ This was a dry run. Run with --execute to apply changes."); + } + + // Exit with error code if any failures + process.exit(failed > 0 ? 1 : 0); +} + +// Run +main().catch(error => { + console.error("Migration failed:", error); + process.exit(1); +}); diff --git a/shared/lib/assets.ts b/shared/lib/assets.ts new file mode 100644 index 0000000..84353f1 --- /dev/null +++ b/shared/lib/assets.ts @@ -0,0 +1,102 @@ +/** + * Asset URL Resolution Utilities + * Provides helpers for constructing full asset URLs from item IDs or relative paths. + * Works for both local development and production environments. + */ + +import { env } from "./env"; + +/** + * Get the base URL for assets. + * In production, this should be the public dashboard URL. + * In development, it defaults to localhost with the configured port. + */ +function getAssetsBaseUrl(): string { + // Check for explicitly configured asset URL first + if (process.env.ASSETS_BASE_URL) { + return process.env.ASSETS_BASE_URL.replace(/\/$/, ""); // Remove trailing slash + } + + // In production, construct from HOST/PORT or use a default + if (process.env.NODE_ENV === "production") { + // If WEB_URL is set, use it (future-proofing) + if (process.env.WEB_URL) { + return process.env.WEB_URL.replace(/\/$/, ""); + } + // Fallback: use the configured host and port + const host = env.HOST === "0.0.0.0" ? "localhost" : env.HOST; + return `http://${host}:${env.PORT}`; + } + + // Development: use localhost with the configured port + return `http://localhost:${env.PORT}`; +} + +/** + * Get the full URL for an item's icon image. + * @param itemId - The item's database ID + * @returns Full URL to the item's icon image + * + * @example + * getItemIconUrl(42) // => "http://localhost:3000/assets/items/42.png" + */ +export function getItemIconUrl(itemId: number): string { + return `${getAssetsBaseUrl()}/assets/items/${itemId}.png`; +} + +/** + * Get the full URL for any asset given its relative path. + * @param relativePath - Path relative to the assets root (e.g., "items/42.png") + * @returns Full URL to the asset + * + * @example + * getAssetUrl("items/42.png") // => "http://localhost:3000/assets/items/42.png" + */ +export function getAssetUrl(relativePath: string): string { + const cleanPath = relativePath.replace(/^\/+/, ""); // Remove leading slashes + return `${getAssetsBaseUrl()}/assets/${cleanPath}`; +} + +/** + * Check if a URL is a local asset URL (relative path starting with /assets/). + * @param url - The URL to check + * @returns True if it's a local asset reference + */ +export function isLocalAssetUrl(url: string | null | undefined): boolean { + if (!url) return false; + return url.startsWith("/assets/") || url.startsWith("assets/"); +} + +/** + * Convert a relative asset path to a full URL. + * If the URL is already absolute (http/https), returns it unchanged. + * If it's a relative asset path, constructs the full URL. + * + * @param url - The URL to resolve (relative or absolute) + * @returns The resolved full URL, or null if input was null/undefined + */ +export function resolveAssetUrl(url: string | null | undefined): string | null { + if (!url) return null; + + // Already absolute + if (url.startsWith("http://") || url.startsWith("https://")) { + return url; + } + + // Relative asset path + if (isLocalAssetUrl(url)) { + const cleanPath = url.replace(/^\/+assets\//, "").replace(/^assets\//, ""); + return getAssetUrl(cleanPath); + } + + // Unknown format, return as-is + return url; +} + +/** + * Get the placeholder image URL for items without an uploaded image. + * @returns Full URL to the placeholder image + */ +export function getPlaceholderIconUrl(): string { + return `${getAssetsBaseUrl()}/assets/items/placeholder.png`; +} diff --git a/shared/lib/env.ts b/shared/lib/env.ts index 1e8368a..7d080e5 100644 --- a/shared/lib/env.ts +++ b/shared/lib/env.ts @@ -8,6 +8,9 @@ const envSchema = z.object({ PORT: z.coerce.number().default(3000), HOST: z.string().default("127.0.0.1"), ADMIN_TOKEN: z.string().min(8, "ADMIN_TOKEN must be at least 8 characters").optional(), + // Asset URL configuration (for production with custom domains) + ASSETS_BASE_URL: z.string().url().optional(), + WEB_URL: z.string().url().optional(), }); const parsedEnv = envSchema.safeParse(process.env); diff --git a/shared/modules/items/items.service.ts b/shared/modules/items/items.service.ts new file mode 100644 index 0000000..8157381 --- /dev/null +++ b/shared/modules/items/items.service.ts @@ -0,0 +1,215 @@ +/** + * Items Service + * Handles CRUD operations for game items. + * Used by both bot commands and web dashboard. + */ + +import { items } from "@db/schema"; +import { eq, ilike, and, or, count, sql } from "drizzle-orm"; +import { DrizzleClient } from "@shared/db/DrizzleClient"; +import { withTransaction } from "@/lib/db"; +import type { Transaction, ItemUsageData } from "@shared/lib/types"; +import type { ItemType } from "@shared/lib/constants"; + +// --- DTOs --- + +export interface CreateItemDTO { + name: string; + description?: string | null; + rarity?: 'Common' | 'Uncommon' | 'Rare' | 'Epic' | 'Legendary'; + type: 'MATERIAL' | 'CONSUMABLE' | 'EQUIPMENT' | 'QUEST'; + price?: bigint | null; + iconUrl: string; + imageUrl: string; + usageData?: ItemUsageData | null; +} + +export interface UpdateItemDTO { + name?: string; + description?: string | null; + rarity?: 'Common' | 'Uncommon' | 'Rare' | 'Epic' | 'Legendary'; + type?: 'MATERIAL' | 'CONSUMABLE' | 'EQUIPMENT' | 'QUEST'; + price?: bigint | null; + iconUrl?: string; + imageUrl?: string; + usageData?: ItemUsageData | null; +} + +export interface ItemFilters { + search?: string; + type?: string; + rarity?: string; + limit?: number; + offset?: number; +} + +// --- Service --- + +export const itemsService = { + /** + * Get all items with optional filtering and pagination. + */ + async getAllItems(filters: ItemFilters = {}) { + const { search, type, rarity, limit = 100, offset = 0 } = filters; + + // Build conditions array + const conditions = []; + + if (search) { + conditions.push( + or( + ilike(items.name, `%${search}%`), + ilike(items.description, `%${search}%`) + ) + ); + } + + if (type) { + conditions.push(eq(items.type, type)); + } + + if (rarity) { + conditions.push(eq(items.rarity, rarity)); + } + + // Execute query with conditions + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const [itemsList, totalResult] = await Promise.all([ + DrizzleClient + .select() + .from(items) + .where(whereClause) + .limit(limit) + .offset(offset) + .orderBy(items.id), + DrizzleClient + .select({ count: count() }) + .from(items) + .where(whereClause) + ]); + + return { + items: itemsList, + total: totalResult[0]?.count ?? 0 + }; + }, + + /** + * Get a single item by ID. + */ + async getItemById(id: number) { + return await DrizzleClient.query.items.findFirst({ + where: eq(items.id, id) + }); + }, + + /** + * Get item by name (for uniqueness checks). + */ + async getItemByName(name: string) { + return await DrizzleClient.query.items.findFirst({ + where: eq(items.name, name) + }); + }, + + /** + * Create a new item. + */ + async createItem(data: CreateItemDTO, tx?: Transaction) { + return await withTransaction(async (txFn) => { + const [item] = await txFn.insert(items) + .values({ + name: data.name, + description: data.description ?? null, + rarity: data.rarity ?? 'Common', + type: data.type, + price: data.price ?? null, + iconUrl: data.iconUrl, + imageUrl: data.imageUrl, + usageData: data.usageData ?? {}, + }) + .returning(); + + return item; + }, tx); + }, + + /** + * Update an existing item. + */ + async updateItem(id: number, data: UpdateItemDTO, tx?: Transaction) { + return await withTransaction(async (txFn) => { + // Build update object dynamically to support partial updates + const updateData: Record = {}; + + if (data.name !== undefined) updateData.name = data.name; + if (data.description !== undefined) updateData.description = data.description; + if (data.rarity !== undefined) updateData.rarity = data.rarity; + if (data.type !== undefined) updateData.type = data.type; + if (data.price !== undefined) updateData.price = data.price; + if (data.iconUrl !== undefined) updateData.iconUrl = data.iconUrl; + if (data.imageUrl !== undefined) updateData.imageUrl = data.imageUrl; + if (data.usageData !== undefined) updateData.usageData = data.usageData; + + if (Object.keys(updateData).length === 0) { + // Nothing to update, just return the existing item + return await txFn.query.items.findFirst({ + where: eq(items.id, id) + }); + } + + const [updatedItem] = await txFn + .update(items) + .set(updateData) + .where(eq(items.id, id)) + .returning(); + + return updatedItem; + }, tx); + }, + + /** + * Delete an item by ID. + */ + async deleteItem(id: number, tx?: Transaction) { + return await withTransaction(async (txFn) => { + const [deletedItem] = await txFn + .delete(items) + .where(eq(items.id, id)) + .returning(); + + return deletedItem; + }, tx); + }, + + /** + * Check if an item name is already taken (for validation). + * Optionally exclude a specific ID (for updates). + */ + async isNameTaken(name: string, excludeId?: number) { + const existing = await DrizzleClient.query.items.findFirst({ + where: excludeId + ? and(eq(items.name, name), sql`${items.id} != ${excludeId}`) + : eq(items.name, name) + }); + + return !!existing; + }, + + /** + * Get items for autocomplete (search by name, limited results). + */ + async getItemsAutocomplete(query: string, limit: number = 25) { + return await DrizzleClient + .select({ + id: items.id, + name: items.name, + rarity: items.rarity, + iconUrl: items.iconUrl, + }) + .from(items) + .where(ilike(items.name, `%${query}%`)) + .limit(limit); + } +}; diff --git a/web/bun.lock b/web/bun.lock index 4845368..12639c9 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -14,6 +14,7 @@ "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", @@ -124,6 +125,8 @@ "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], + "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], diff --git a/web/package.json b/web/package.json index 5d0e10e..799d50c 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", diff --git a/web/src/components/effect-editor.tsx b/web/src/components/effect-editor.tsx new file mode 100644 index 0000000..b14d816 --- /dev/null +++ b/web/src/components/effect-editor.tsx @@ -0,0 +1,397 @@ +/** + * EffectEditor Component + * Dynamic form for adding/editing item effects with all 7 effect types. + */ + +import { useState, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { LootTableBuilder } from "@/components/loot-table-builder"; +import { + Plus, + Trash2, + Sparkles, + Coins, + MessageSquare, + Zap, + Clock, + Palette, + Package, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +// Effect types matching the backend +const EFFECT_TYPES = [ + { value: "ADD_XP", label: "Add XP", icon: Sparkles, color: "text-blue-400" }, + { value: "ADD_BALANCE", label: "Add Balance", icon: Coins, color: "text-amber-400" }, + { value: "REPLY_MESSAGE", label: "Reply Message", icon: MessageSquare, color: "text-green-400" }, + { value: "XP_BOOST", label: "XP Boost", icon: Zap, color: "text-purple-400" }, + { value: "TEMP_ROLE", label: "Temporary Role", icon: Clock, color: "text-orange-400" }, + { value: "COLOR_ROLE", label: "Color Role", icon: Palette, color: "text-pink-400" }, + { value: "LOOTBOX", label: "Lootbox", icon: Package, color: "text-yellow-400" }, +]; + +interface Effect { + type: string; + [key: string]: any; +} + +interface EffectEditorProps { + effects: Effect[]; + onChange: (effects: Effect[]) => void; +} + +const getDefaultEffect = (type: string): Effect => { + switch (type) { + case "ADD_XP": + return { type, amount: 100 }; + case "ADD_BALANCE": + return { type, amount: 100 }; + case "REPLY_MESSAGE": + return { type, message: "" }; + case "XP_BOOST": + return { type, multiplier: 2, durationMinutes: 60 }; + case "TEMP_ROLE": + return { type, roleId: "", durationMinutes: 60 }; + case "COLOR_ROLE": + return { type, roleId: "" }; + case "LOOTBOX": + return { type, pool: [] }; + default: + return { type }; + } +}; + +const getEffectSummary = (effect: Effect): string => { + switch (effect.type) { + case "ADD_XP": + return `+${effect.amount} XP`; + case "ADD_BALANCE": + return `+${effect.amount} coins`; + case "REPLY_MESSAGE": + return effect.message ? `"${effect.message.slice(0, 30)}..."` : "No message"; + case "XP_BOOST": + return `${effect.multiplier}x for ${effect.durationMinutes || effect.durationHours * 60 || effect.durationSeconds / 60}m`; + case "TEMP_ROLE": + return `Role for ${effect.durationMinutes || effect.durationHours * 60 || effect.durationSeconds / 60}m`; + case "COLOR_ROLE": + return effect.roleId ? `Role: ${effect.roleId}` : "No role set"; + case "LOOTBOX": + return `${effect.pool?.length || 0} drops`; + default: + return effect.type; + } +}; + +export function EffectEditor({ effects, onChange }: EffectEditorProps) { + const [expandedItems, setExpandedItems] = useState([]); + + const addEffect = useCallback(() => { + const newEffect = getDefaultEffect("ADD_XP"); + const newEffects = [...effects, newEffect]; + onChange(newEffects); + setExpandedItems(prev => [...prev, `effect-${newEffects.length - 1}`]); + }, [effects, onChange]); + + const removeEffect = useCallback((index: number) => { + const newEffects = effects.filter((_, i) => i !== index); + onChange(newEffects); + }, [effects, onChange]); + + const updateEffect = useCallback((index: number, updates: Partial) => { + const newEffects = effects.map((effect, i) => + i === index ? { ...effect, ...updates } : effect + ); + onChange(newEffects); + }, [effects, onChange]); + + const changeEffectType = useCallback((index: number, newType: string) => { + const newEffects = effects.map((effect, i) => + i === index ? getDefaultEffect(newType) : effect + ); + onChange(newEffects); + }, [effects, onChange]); + + if (effects.length === 0) { + return ( +
+

+ No effects added yet +

+ +
+ ); + } + + return ( +
+ + {effects.map((effect, index) => { + const effectType = EFFECT_TYPES.find(t => t.value === effect.type); + const Icon = effectType?.icon || Sparkles; + + return ( + + +
+ + + {effectType?.label || effect.type} + + + {getEffectSummary(effect)} + +
+
+ +
+ {/* Effect Type Selector */} +
+ + +
+ + {/* Dynamic Fields Based on Effect Type */} + {effect.type === "ADD_XP" && ( +
+ + updateEffect(index, { amount: parseInt(e.target.value) || 0 })} + className="bg-background/50" + /> +
+ )} + + {effect.type === "ADD_BALANCE" && ( +
+ +
+ updateEffect(index, { amount: parseInt(e.target.value) || 0 })} + className="bg-background/50 pr-10" + /> +
+
+ )} + + {effect.type === "REPLY_MESSAGE" && ( +
+ +