From 76d4f57e77378c0272860b5c03af027e26e8b91c Mon Sep 17 00:00:00 2001 From: shni Date: Fri, 3 Oct 2025 18:17:43 -0500 Subject: [PATCH] feat: add resource checker prompts and display component types for enhanced component management --- .github/prompts/resource-checker.prompt.md | 41 + .../alliaces/createDisplayComponent.ts | 1782 +++++------------ .../alliaces/deleteDisplayComponent.ts | 594 ++++-- .../messages/alliaces/displayComponentList.ts | 851 ++++---- .../alliaces/displayComponentsDemo.ts | 784 ++++---- src/core/types/displayComponentEditor.ts | 408 ++++ src/core/types/displayComponents.ts | 101 + 7 files changed, 2380 insertions(+), 2181 deletions(-) create mode 100644 .github/prompts/resource-checker.prompt.md create mode 100644 src/core/types/displayComponentEditor.ts create mode 100644 src/core/types/displayComponents.ts diff --git a/.github/prompts/resource-checker.prompt.md b/.github/prompts/resource-checker.prompt.md new file mode 100644 index 0000000..5c28086 --- /dev/null +++ b/.github/prompts/resource-checker.prompt.md @@ -0,0 +1,41 @@ +# Prompt: Node.js Resource Checker (Memory & CPU) + +Whenever Copilot generates, modifies, or reviews code in Node.js: + +1. **Memory Monitoring** + - Always remind to check memory usage with: + ```js + logger.info(process.memoryUsage()); + ``` + which returns RSS, Heap Used, Heap Total, External, and Array Buffers. + - If `Heap Used` grows continuously without freeing, suggest potential memory leaks (unreleased timers, listeners, or large cached data). + - If `RSS` (Resident Set Size) is unusually higher than `Heap Total`, warn that external/native modules might be consuming memory. + +2. **CPU Usage** + - Remind to check CPU load using: + ```bash + top -p + ``` + or Node.js profiling (`--inspect`, `clinic flame`). + - Flag infinite loops, synchronous blocking calls, or heavy computations running on the main thread as potential CPU overuse. + - Encourage offloading heavy tasks to Workers, child processes, or queues when appropriate. + +3. **Best Practices** + - Always validate memory and CPU usage after implementing new features. + - Suggest garbage collection checks (`global.gc()`) in development when Node is run with `--expose-gc`. + - Warn if long-living references (e.g., large Maps, Sets, caches, or listeners) might not be released. + - Highlight that CPU and memory efficiency are as important as correctness. + +4. **Output Analysis** + - When given memory stats like: + ``` + RSS: 186.9MB + Heap Used: 67.6MB + Heap Total: 71.2MB + External: 5.0MB + ``` + - Compare `Heap Used` vs `Heap Total`: if close to the limit, risk of OOM. + - Compare `RSS` vs `Heap Total`: if RSS is much larger, check for native module or buffer leaks. + - If growth is unbounded, warn about potential memory leaks. + +5. **Always remind** to rerun tests with `npx tsc --noEmit` (for type safety) and memory checks together, ensuring both correctness and performance. diff --git a/src/commands/messages/alliaces/createDisplayComponent.ts b/src/commands/messages/alliaces/createDisplayComponent.ts index ddfe8a6..bf77e06 100644 --- a/src/commands/messages/alliaces/createDisplayComponent.ts +++ b/src/commands/messages/alliaces/createDisplayComponent.ts @@ -1,236 +1,43 @@ -import logger from "../../../core/lib/logger"; -import { CommandMessage } from "../../../core/types/commands"; -// @ts-ignore import { - ComponentType, ButtonStyle, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, Message, MessageFlags, + ActionRowBuilder, + ButtonInteraction, + Message, + MessageComponentInteraction, + MessageFlags, + ModalBuilder, TextChannel, + TextInputBuilder, + TextInputStyle, } from "discord.js"; -import { replaceVars, isValidUrlOrVariable, listVariables } from "../../../core/lib/vars"; +import logger from "../../../core/lib/logger"; +import {CommandMessage} from "../../../core/types/commands"; +import {listVariables} from "../../../core/lib/vars"; +import type Amayo from "../../../core/client"; +import {BlockState, DisplayComponentUtils, EditorActionRow} from "../../../core/types/displayComponentEditor"; +import type {DisplayComponentContainer} from "../../../core/types/displayComponents"; -/** - * Botones de edición - VERSIÓN MEJORADA - */ -const btns = (disabled = false) => ([ - { - type: 1, - components: [ - { style: ButtonStyle.Secondary, type: 2, emoji: "1420535018521886924", label: "📝 Título", disabled, custom_id: "edit_title" }, - { style: ButtonStyle.Secondary, type: 2, emoji: "1420535018521886924", label: "Descripción", disabled, custom_id: "edit_description" }, - { style: ButtonStyle.Secondary, type: 2, emoji: "1420540368503570484", label: "Color", disabled, custom_id: "edit_color" }, - { style: ButtonStyle.Secondary, type: 2, emoji: "1420535511663116368", label: "Contenido", disabled, custom_id: "add_content" }, - { style: ButtonStyle.Secondary, type: 2, emoji: "1420540572715847861", label: "Separador", disabled, custom_id: "add_separator" } - ] - }, - { - type: 1, - components: [ - { style: ButtonStyle.Secondary, type: 2, emoji: "1420539242643193896", label: "Imagen", disabled, custom_id: "add_image" }, - { style: ButtonStyle.Secondary, type: 2, emoji: "1420539242643193896", label: "Portada", disabled, custom_id: "cover_image" }, - { style: ButtonStyle.Secondary, type: 2, emoji: "1420535460773498891", label: "Thumbnail", disabled, custom_id: "edit_thumbnail" }, - { style: ButtonStyle.Secondary, type: 2, emoji: "1420535460773498891", label: "Crear Botón Link", disabled, custom_id: "edit_link_button" }, - { style: ButtonStyle.Primary, type: 2, emoji: "1420539499615752242", label: "Mover", disabled, custom_id: "move_block" } - ] - }, - { - type: 1, - components: [ - { style: ButtonStyle.Secondary, type: 2, emoji: "1420537401692131400", label: "Variables", disabled, custom_id: "show_variables" }, - { style: ButtonStyle.Secondary, type: 2, emoji: "1420535206837747944", label: "Duplicar", disabled, custom_id: "duplicate_block" }, - { style: ButtonStyle.Secondary, type: 2, emoji: "1420518308553167071", label: "Vista Raw", disabled, custom_id: "show_raw" }, - { style: ButtonStyle.Secondary, type: 2, label: "📥 Importar", disabled, custom_id: "import_json" }, - { style: ButtonStyle.Secondary, type: 2, label: "📤 Exportar", disabled, custom_id: "export_json" } - ] - }, - { - type: 1, - components: [ - { style: ButtonStyle.Success, type: 2, emoji: "1420535051162095747", label: "Guardar", disabled, custom_id: "save_block" }, - { style: ButtonStyle.Danger, type: 2, emoji: "1420535096208920576", label: "Cancelar", disabled, custom_id: "cancel_block" }, - { style: ButtonStyle.Danger, type: 2, emoji: "1420535068056748042", label: "Eliminar", disabled, custom_id: "delete_block" } - ] - } -]); +interface EditorData { + content?: string; + flags?: MessageFlags; + display?: DisplayComponentContainer; + components?: EditorActionRow[]; +} -/** - * Validar si una URL es válida o es una variable del sistema - */ -const isValidUrl = isValidUrlOrVariable; - -/** - * Validar y limpiar contenido para Discord - */ -const validateContent = (content: string): string => { - if (!content || typeof content !== 'string') { - return "Sin contenido"; // Contenido por defecto - } - - // Limpiar contenido y asegurar que tenga al menos 1 carácter - const cleaned = content.trim(); - if (cleaned.length === 0) { - return "Sin contenido"; - } - - // Truncar si excede el límite de Discord (4000 caracteres) - if (cleaned.length > 4000) { - return cleaned.substring(0, 3997) + "..."; - } - - return cleaned; -}; - -// Validación y parseo de emoji (unicode o personalizado / <:name:id>) -const parseEmojiInput = (input?: string): any | null => { - if (!input) return null; - const trimmed = input.trim(); - if (!trimmed) return null; - - const match = trimmed.match(/^<(a?):(\w+):(\d+)>$/); - if (match) { - const animated = match[1] === 'a'; - const name = match[2]; - const id = match[3]; - return { id, name, animated }; - } - // Asumimos unicode si no es formato de emoji personalizado - return { name: trimmed }; -}; - -/** - * Construye un accesorio de botón link para Display Components - */ -const buildLinkAccessory = async (link: any, member: any, guild: any) => { - if (!link || !link.url) return null; - // @ts-ignore - const processedUrl = await replaceVars(link.url, member, guild); - if (!isValidUrl(processedUrl)) return null; - - const accessory: any = { - type: 2, - style: ButtonStyle.Link, - url: processedUrl - }; - - if (link.label && typeof link.label === 'string' && link.label.trim().length > 0) { - accessory.label = link.label.trim().slice(0, 80); - } - - if (link.emoji && typeof link.emoji === 'string') { - const parsed = parseEmojiInput(link.emoji); - if (parsed) accessory.emoji = parsed; - } - - // Debe tener al menos label o emoji - if (!accessory.label && !accessory.emoji) { - return null; - } - - return accessory; -}; - -/** - * Generar vista previa - */ -const renderPreview = async (blockState: any, member: any, guild: any) => { - const previewComponents = []; - - // Añadir imagen de portada primero si existe - if (blockState.coverImage && isValidUrl(blockState.coverImage)) { - //@ts-ignore - const processedCoverUrl = await replaceVars(blockState.coverImage, member, guild); - if (isValidUrl(processedCoverUrl)) { - previewComponents.push({ - type: 12, - items: [{ media: { url: processedCoverUrl } }] - }); - } - } - - // Añadir título después de la portada - VALIDAR CONTENIDO - //@ts-ignore - const processedTitle = await replaceVars(blockState.title ?? "Sin título", member, guild); - previewComponents.push({ - type: 10, - content: validateContent(processedTitle) - }); - - // Procesar componentes en orden - for (const c of blockState.components) { - if (c.type === 10) { - // Componente de texto con accessory opcional (thumbnail o botón link) - //@ts-ignore - const processedThumbnail = c.thumbnail ? await replaceVars(c.thumbnail, member, guild) : null; - //@ts-ignore - const processedContent = await replaceVars(c.content || "Sin contenido", member, guild); - const validatedContent = validateContent(processedContent); - - // Construir accessory según prioridad: linkButton > thumbnail - let accessory: any = null; - if (c.linkButton) { - accessory = await buildLinkAccessory(c.linkButton, member, guild); - } - if (!accessory && processedThumbnail && isValidUrl(processedThumbnail)) { - accessory = { - type: 11, - media: { url: processedThumbnail } - }; - } - - if (accessory) { - previewComponents.push({ - type: 9, - components: [ - { - type: 10, - content: validatedContent - } - ], - accessory - }); - } else { - // Sin accessory válido - previewComponents.push({ - type: 10, - content: validatedContent - }); - } - } else if (c.type === 14) { - // Separador - previewComponents.push({ - type: 14, - divider: c.divider ?? true, - spacing: c.spacing ?? 1 - }); - } else if (c.type === 12) { - // Imagen - validar URL también - //@ts-ignore - const processedImageUrl = await replaceVars(c.url, member, guild); - - if (isValidUrl(processedImageUrl)) { - previewComponents.push({ - type: 12, - items: [{ media: { url: processedImageUrl } }] - }); - } - } - } - - return { - type: 17, - accent_color: blockState.color ?? null, - components: previewComponents - }; -}; - -// Helper para actualizar el editor combinando Display Container dentro de components -const updateEditor = async (msg: any, data: any) => { - const container = data?.display; - const rows = Array.isArray(data?.components) ? data.components : []; +// Helper para actualizar el editor combinando Display Container dentro de components (tipado) +async function updateEditor(message: Message, data: EditorData): Promise { + const container = data.display; + const rows = Array.isArray(data.components) ? data.components : []; const components = container ? [container, ...rows] : rows; + const payload: any = { ...data }; delete payload.display; payload.components = components; - if (payload.flags === undefined) payload.flags = 32768; - await msg.edit(payload); -}; + + if (payload.flags === undefined) { + payload.flags = MessageFlags.IsComponentsV2; + } + + await message.edit(payload); +} export const command: CommandMessage = { name: "crear-embed", @@ -246,33 +53,44 @@ export const command: CommandMessage = { return; } - const blockName: string | null = args[0] ?? null; + const blockName = args[0]?.trim(); if (!blockName) { - await message.reply("Debes proporcionar un nombre. Uso: `!blockcreatev2 `"); + await message.reply("Debes proporcionar un nombre. Uso: `!crear-embed `"); return; } - const nameIsValid = await client.prisma.blockV2Config.findFirst({ - where: { guildId: message.guild!.id, name: blockName } + // Check if block name already exists + const existingBlock = await client.prisma.blockV2Config.findFirst({ + where: { + guildId: message.guild!.id, + name: blockName + } }); - if (nameIsValid) { - await message.reply("❌ Nombre ya usado!"); + + if (existingBlock) { + await message.reply("❌ Ya existe un bloque con ese nombre!"); return; } // Estado inicial - let blockState: any = { + let blockState: BlockState = { title: `Editor de Block: ${blockName}`, - color: null, - coverImage: null, // Nueva propiedad para imagen de portada + color: 0x5865f2, + coverImage: undefined, components: [ - { type: 14, divider: false }, + { type: 14, divider: false, spacing: 1 }, { type: 10, content: "Usa los botones para configurar.", thumbnail: null } ] }; //@ts-ignore - const editorMessage = await message.channel.send({ + const channelSend: If = message.channel; + if (!channelSend?.isTextBased()) { + await message.reply("❌ This command can only be used in a text-based channel."); + return; + } + + const editorMessage = await channelSend.send({ content: "⚠️ **IMPORTANTE:** Prepara tus títulos, descripciones y URLs antes de empezar.\n" + "Este editor usa **modales interactivos** y no podrás ver el chat mientras los usas.\n\n" + "📝 **Recomendaciones:**\n" + @@ -286,1025 +104,515 @@ export const command: CommandMessage = { // Esperar 5 segundos para que lean el mensaje await new Promise(resolve => setTimeout(resolve, 5000)); - //@ts-ignore + // Actualizar para mostrar el editor await updateEditor(editorMessage, { - content: null, - flags: 32768, - display: await renderPreview(blockState, message.member, message.guild), - components: btns(false) + content: undefined, + flags: MessageFlags.IsComponentsV2, + display: await DisplayComponentUtils.renderPreview(blockState, message.member!, message.guild!), + components: DisplayComponentUtils.createEditorButtons(false) }); - const collector = editorMessage.createMessageComponentCollector({ - time: 3600000 // 1 hora (60 minutos * 60 segundos * 1000 ms) - }); - - collector.on("collect", async (i: any) => { - if (i.user.id !== message.author.id) { - await i.reply({ content: "No puedes usar este menú.", flags: MessageFlags.Ephemeral }); - return; - } - - // --- BOTONES --- - if (i.isButton()) { - // NO hacer deferUpdate antes de showModal - // await i.deferUpdate(); // <-- Esto causaba el error - - switch (i.customId) { - case "save_block": { - await i.deferUpdate(); - await client.prisma.blockV2Config.upsert({ - where: { guildId_name: { guildId: message.guildId!, name: blockName } }, - update: { config: blockState }, - create: { - name: blockName, - config: blockState, - guild: { - connectOrCreate: { - where: { id: message.guildId! }, - create: { id: message.guildId!, name: message.guild!.name } - } - } - } - }); - await updateEditor(editorMessage, { - display: { - type: 17, - accent_color: blockState.color ?? null, - components: [ - { type: 10, content: `✅ Guardado: ${blockName}` }, - { type: 10, content: "Configuración guardada en la base de datos (JSON)." } - ] - }, - components: [] - }); - collector.stop(); - return; - } - case "cancel_block": { - await i.deferUpdate(); - await editorMessage.delete(); - collector.stop(); - return; - } - case "edit_title": { - // Crear modal para editar título - const modal = new ModalBuilder() - .setCustomId('edit_title_modal') - .setTitle('📝 Editar Título del Block'); - - const titleInput = new TextInputBuilder() - .setCustomId('title_input') - .setLabel('Nuevo Título') - .setStyle(TextInputStyle.Short) - .setPlaceholder('Escribe el nuevo título aquí...') - .setValue(blockState.title || '') - .setMaxLength(256) - .setRequired(true); - - const firstActionRow = new ActionRowBuilder().addComponents(titleInput); - modal.addComponents(firstActionRow); - - //@ts-ignore - await i.showModal(modal); - break; - } - case "edit_description": { - const modal = new ModalBuilder() - .setCustomId('edit_description_modal') - .setTitle('📄 Editar Descripción'); - - const descComp = blockState.components.find((c: any) => c.type === 10); - const currentDesc = descComp ? descComp.content : ''; - - const descInput = new TextInputBuilder() - .setCustomId('description_input') - .setLabel('Nueva Descripción') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Escribe la nueva descripción aquí...') - .setValue(currentDesc || '') - .setMaxLength(2000) - .setRequired(true); - - const firstActionRow = new ActionRowBuilder().addComponents(descInput); - modal.addComponents(firstActionRow); - - //@ts-ignore - await i.showModal(modal); - break; - } - case "edit_color": { - const modal = new ModalBuilder() - .setCustomId('edit_color_modal') - .setTitle('🎨 Editar Color del Block'); - - const currentColor = blockState.color ? `#${blockState.color.toString(16).padStart(6, '0')}` : ''; - - const colorInput = new TextInputBuilder() - .setCustomId('color_input') - .setLabel('Color en formato HEX') - .setStyle(TextInputStyle.Short) - .setPlaceholder('#FF5733 o FF5733') - .setValue(currentColor) - .setMaxLength(7) - .setRequired(false); - - const firstActionRow: ActionRowBuilder = new ActionRowBuilder().addComponents(colorInput); - modal.addComponents(firstActionRow); - - //@ts-ignore - await i.showModal(modal); - break; - } - case "add_content": { - const modal = new ModalBuilder() - .setCustomId('add_content_modal') - .setTitle('➕ Agregar Nuevo Contenido'); - - const contentInput = new TextInputBuilder() - .setCustomId('content_input') - .setLabel('Contenido del Texto') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Escribe el contenido aquí...') - .setMaxLength(2000) - .setRequired(true); - - const firstActionRow = new ActionRowBuilder().addComponents(contentInput); - modal.addComponents(firstActionRow); - - //@ts-ignore - await i.showModal(modal); - break; - } - case "add_image": { - const modal = new ModalBuilder() - .setCustomId('add_image_modal') - .setTitle('🖼️ Agregar Nueva Imagen'); - - const imageUrlInput = new TextInputBuilder() - .setCustomId('image_url_input') - .setLabel('URL de la Imagen') - .setStyle(TextInputStyle.Short) - .setPlaceholder('https://ejemplo.com/imagen.png') - .setMaxLength(2000) - .setRequired(true); - - const firstActionRow = new ActionRowBuilder().addComponents(imageUrlInput); - modal.addComponents(firstActionRow); - - //@ts-ignore - await i.showModal(modal); - break; - } - case "cover_image": { - if (blockState.coverImage) { - // Si ya tiene portada, preguntar si editar o eliminar - //@ts-ignore - await i.reply({ - flags: 64, // MessageFlags.Ephemeral - content: "Ya tienes una imagen de portada. ¿Qué quieres hacer?", - components: [ - { - type: 1, - components: [ - { type: 2, style: ButtonStyle.Primary, label: "✏️ Editar", custom_id: "edit_cover_modal" }, - { type: 2, style: ButtonStyle.Danger, label: "🗑️ Eliminar", custom_id: "delete_cover" } - ] - } - ] - }); - - // @ts-ignore - const replyMsg = await i.fetchReply(); - - //@ts-ignore - const coverCollector = replyMsg.createMessageComponentCollector({ - componentType: ComponentType.Button, - max: 1, - time: 60000, - filter: (b: any) => b.user.id === message.author.id - }); - - coverCollector.on("collect", async (b: any) => { - if (b.customId === "edit_cover_modal") { - // Crear modal para editar portada - const modal = new ModalBuilder() - .setCustomId('edit_cover_modal') - .setTitle('🖼️ Editar Imagen de Portada'); - - const coverInput = new TextInputBuilder() - .setCustomId('cover_input') - .setLabel('URL de la Imagen de Portada') - .setStyle(TextInputStyle.Short) - .setPlaceholder('https://ejemplo.com/portada.png') - .setValue(blockState.coverImage || '') - .setMaxLength(2000) - .setRequired(true); - - const firstActionRow = new ActionRowBuilder().addComponents(coverInput); - modal.addComponents(firstActionRow); - - //@ts-ignore - await b.showModal(modal); - } else if (b.customId === "delete_cover") { - blockState.coverImage = null; - await b.update({ content: "✅ Imagen de portada eliminada.", components: [] }); - await updateEditor(editorMessage, { - display: await renderPreview(blockState, message.member, message.guild), - components: btns(false) - }); - } - coverCollector.stop(); - }); - } else { - // No tiene portada, crear modal para añadir nueva - const modal = new ModalBuilder() - .setCustomId('add_cover_modal') - .setTitle('🖼️ Agregar Imagen de Portada'); - - const coverInput = new TextInputBuilder() - .setCustomId('cover_input') - .setLabel('URL de la Imagen de Portada') - .setStyle(TextInputStyle.Short) - .setPlaceholder('https://ejemplo.com/portada.png') - .setMaxLength(2000) - .setRequired(true); - - const firstActionRow = new ActionRowBuilder().addComponents(coverInput); - modal.addComponents(firstActionRow); - - //@ts-ignore - await i.showModal(modal); - } - break; - } - case "move_block": { - const options = blockState.components.map((c: any, idx: number) => ({ - label: - c.type === 10 - ? `Texto: ${c.content?.slice(0, 30) || "..."}` - : c.type === 14 - ? "Separador" - : c.type === 12 - ? `Imagen: ${c.url?.slice(-30) || "..."}` - : `Componente ${c.type}`, - value: idx.toString(), - description: - c.type === 10 && (c.thumbnail || c.linkButton) - ? (c.thumbnail ? "Con thumbnail" : "Con botón link") - : undefined - })); - - //@ts-ignore - const reply = await i.reply({ - flags: 64, // MessageFlags.Ephemeral - content: "Selecciona el bloque que quieres mover:", - components: [ - { - type: 1, - components: [ - { type: 3, custom_id: "move_block_select", placeholder: "Elige un bloque", options } - ] - } - ] - }); - // Obtener el mensaje asociado (compatibilidad con djs) - // @ts-ignore - const replyMsg = await i.fetchReply(); - - // @ts-ignore - const selCollector = replyMsg.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - max: 1, - time: 60000, - filter: (it: any) => it.user.id === message.author.id - }); - - selCollector.on("collect", async (sel: any) => { - const idx = parseInt(sel.values[0]); - - await sel.update({ - content: "¿Quieres mover este bloque?", - components: [ - { - type: 1, - components: [ - { type: 2, style: ButtonStyle.Secondary, label: "⬆️ Subir", custom_id: `move_up_${idx}`, disabled: idx === 0 }, - { type: 2, style: ButtonStyle.Secondary, label: "⬇️ Bajar", custom_id: `move_down_${idx}`, disabled: idx === blockState.components.length - 1 } - ] - } - ] - }); - - //@ts-ignore - const btnCollector = replyMsg.createMessageComponentCollector({ - componentType: ComponentType.Button, - max: 1, - time: 60000, - filter: (b: any) => b.user.id === message.author.id - }); - - btnCollector.on("collect", async (b: any) => { - if (b.customId.startsWith("move_up_")) { - const i2 = parseInt(b.customId.replace("move_up_", "")); - if (i2 > 0) { - const item = blockState.components[i2]; - blockState.components.splice(i2, 1); - blockState.components.splice(i2 - 1, 0, item); - } - await b.update({ content: "✅ Bloque movido arriba.", components: [] }); - } else if (b.customId.startsWith("move_down_")) { - const i2 = parseInt(b.customId.replace("move_down_", "")); - if (i2 < blockState.components.length - 1) { - const item = blockState.components[i2]; - blockState.components.splice(i2, 1); - blockState.components.splice(i2 + 1, 0, item); - } - await b.update({ content: "✅ Bloque movido abajo.", components: [] }); - } - - await updateEditor(editorMessage, { - display: await renderPreview(blockState, message.member, message.guild), - components: btns(false) - }); - - btnCollector.stop(); - selCollector.stop(); - }); - }); - - break; - } - case "delete_block": { - // Incluir portada en las opciones si existe - const options = [] as any[]; - - // Añadir portada como opción si existe - if (blockState.coverImage) { - options.push({ - label: "🖼️ Imagen de Portada", - value: "cover_image", - description: "Imagen principal del bloque" - }); - } - - // Añadir componentes regulares - blockState.components.forEach((c: any, idx: number) => { - options.push({ - label: - c.type === 10 - ? `Texto: ${c.content?.slice(0, 30) || "..."}` - : c.type === 14 - ? `Separador ${c.divider ? '(Visible)' : '(Invisible)'}` // <-- Arreglado aquí - : c.type === 12 - ? `Imagen: ${c.url?.slice(-30) || "..."}` - : `Componente ${c.type}`, - value: idx.toString(), - description: - c.type === 10 && (c.thumbnail || c.linkButton) - ? (c.thumbnail ? "Con thumbnail" : "Con botón link") - : undefined - }); - }); - - if (options.length === 0) { - await i.deferReply({ flags: 64 }); // MessageFlags.Ephemeral - //@ts-ignore - await i.editReply({ - content: "❌ No hay elementos para eliminar." - }); - break; - } - - //@ts-ignore - const reply = await i.reply({ - flags: 64, // MessageFlags.Ephemeral - content: "Selecciona el elemento que quieres eliminar:", - components: [ - { - type: 1, - components: [ - { type: 3, custom_id: "delete_block_select", placeholder: "Elige un elemento", options } - ] - } - ] - }); - // @ts-ignore - const replyMsg = await i.fetchReply(); - // @ts-ignore - const selCollector = replyMsg.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - max: 1, - time: 60000, - filter: (it: any) => it.user.id === message.author.id - }); - - selCollector.on("collect", async (sel: any) => { - const selectedValue = sel.values[0]; - - if (selectedValue === "cover_image") { - blockState.coverImage = null; - await sel.update({ content: "✅ Imagen de portada eliminada.", components: [] }); - } else { - const idx = parseInt(selectedValue); - blockState.components.splice(idx, 1); - await sel.update({ content: "✅ Elemento eliminado.", components: [] }); - } - - await updateEditor(editorMessage, { - display: await renderPreview(blockState, message.member, message.guild), - components: btns(false) - }); - - selCollector.stop(); - }); - - break; - } - case "show_variables": { - // Construir lista de variables dinámicamente desde var.ts - const vars = listVariables(); - const chunked: string[] = []; - let current = ""; - for (const v of vars) { - const line = `• ${v}\n`; - if ((current + line).length > 1800) { - chunked.push(current); - current = line; - } else { - current += line; - } - } - if (current) chunked.push(current); - - // Responder en uno o varios mensajes efímeros según el tamaño - if (chunked.length === 0) { - await i.deferReply({ flags: 64 }); - //@ts-ignore - await i.editReply({ content: "No hay variables registradas." }); - } else { - // Primer bloque - //@ts-ignore - await i.reply({ flags: 64, content: `📋 **Variables Disponibles:**\n\n${chunked[0]}` }); - // Bloques adicionales si hiciera falta - for (let idx = 1; idx < chunked.length; idx++) { - //@ts-ignore - await i.followUp({ flags: 64, content: chunked[idx] }); - } - } - break; - } - case "duplicate_block": { - const options = blockState.components.map((c: any, idx: number) => ({ - label: c.type === 10 ? `Texto: ${c.content?.slice(0, 30) || "..."}` - : c.type === 14 ? "Separador" - : c.type === 12 ? `Imagen: ${c.url?.slice(-30) || "..."}` - : `Componente ${c.type}`, - value: idx.toString(), - description: c.type === 10 && (c.thumbnail || c.linkButton) ? (c.thumbnail ? "Con thumbnail" : "Con botón link") : undefined - })); - - if (options.length === 0) { - await i.deferReply({ flags: 64 }); // MessageFlags.Ephemeral - //@ts-ignore - await i.editReply({ content: "❌ No hay elementos para duplicar." }); - break; - } - - //@ts-ignore - const reply = await i.reply({ - flags: 64, // MessageFlags.Ephemeral - content: "Selecciona el elemento que quieres duplicar:", - components: [{ - type: 1, - components: [{ - type: 3, - custom_id: "duplicate_select", - placeholder: "Elige un elemento", - options - }] - }] - }); - // @ts-ignore - const replyMsg = await i.fetchReply(); - // @ts-ignore - const selCollector = replyMsg.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - max: 1, - time: 60000, - filter: (sel: any) => sel.user.id === message.author.id - }); - - selCollector.on("collect", async (sel: any) => { - const idx = parseInt(sel.values[0]); - const originalComponent = blockState.components[idx]; - const duplicatedComponent = JSON.parse(JSON.stringify(originalComponent)); - - blockState.components.splice(idx + 1, 0, duplicatedComponent); - - await sel.update({ content: "✅ Elemento duplicado.", components: [] }); - await updateEditor(editorMessage, { - display: await renderPreview(blockState, message.member, message.guild), - components: btns(false) - }); - }); - break; - } - case "show_raw": { - const rawJson = JSON.stringify(blockState, null, 2); - const truncated = rawJson.length > 1900 ? rawJson.slice(0, 1900) + "..." : rawJson; - - //@ts-ignore - await i.reply({ - flags: 64, // MessageFlags.Ephemeral - content: `\`\`\`json\n${truncated}\n\`\`\`` - }); - break; - } - case "import_json": { - const modal = new ModalBuilder() - .setCustomId('import_json_modal') - .setTitle('📥 Importar JSON'); - - const jsonInput = new TextInputBuilder() - .setCustomId('json_input') - .setLabel('Pega tu configuración JSON aquí') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('{"title": "...", "components": [...]}') - .setMaxLength(4000) - .setRequired(true); - - const firstRow = new ActionRowBuilder().addComponents(jsonInput); - modal.addComponents(firstRow); - - //@ts-ignore - await i.showModal(modal); - break; - } - case "export_json": { - const exportJson = JSON.stringify(blockState, null, 2); - - // Truncar si es muy largo para evitar problemas con Discord - const truncatedJson = exportJson.length > 1800 ? exportJson.slice(0, 1800) + "\n..." : exportJson; - - //@ts-ignore - await i.reply({ - flags: 64, // MessageFlags.Ephemeral - content: `📤 **JSON Exportado:**\n\`\`\`json\n${truncatedJson}\n\`\`\`\n\n💡 **Tip:** Copia el JSON de arriba manualmente y pégalo donde necesites.` - }); - break; - } - case "add_separator": { - const modal = new ModalBuilder() - .setCustomId('add_separator_modal') - .setTitle('➖ Agregar Separador'); - - const visibleInput = new TextInputBuilder() - .setCustomId('separator_visible') - .setLabel('¿Separador visible? (true/false)') - .setStyle(TextInputStyle.Short) - .setPlaceholder('true o false') - .setValue('true') - .setMaxLength(5) - .setRequired(true); - - const spacingInput = new TextInputBuilder() - .setCustomId('separator_spacing') - .setLabel('Espaciado (1-3)') - .setStyle(TextInputStyle.Short) - .setPlaceholder('1, 2 o 3') - .setValue('1') - .setMaxLength(1) - .setRequired(false); - - const firstRow = new ActionRowBuilder().addComponents(visibleInput); - const secondRow = new ActionRowBuilder().addComponents(spacingInput); - modal.addComponents(firstRow, secondRow); - - //@ts-ignore - await i.showModal(modal); - break; - } - case "edit_thumbnail": { - // Construir listado de TextDisplays - const textDisplays = blockState.components - .map((c: any, idx: number) => ({ c, idx })) - .filter(({ c }: any) => c.type === 10); - - if (textDisplays.length === 0) { - await i.deferReply({ flags: 64 }); - // @ts-ignore - await i.editReply({ content: "❌ No hay bloques de texto para editar thumbnail." }); - break; - } - - const options = textDisplays.map(({ c, idx }: any) => ({ - label: `Texto #${idx + 1}: ${c.content?.slice(0, 30) || '...'}`, - value: String(idx), - description: c.thumbnail ? 'Con thumbnail' : c.linkButton ? 'Con botón link' : 'Sin accesorio' - })); - - // @ts-ignore - const reply = await i.reply({ - flags: 64, - content: "Elige el TextDisplay a editar su thumbnail:", - components: [ - { type: 1, components: [ { type: 3, custom_id: 'choose_text_for_thumbnail', placeholder: 'Selecciona un bloque de texto', options } ] } - ] - }); - // @ts-ignore - const replyMsg = await i.fetchReply(); - // @ts-ignore - const selCollector = replyMsg.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - max: 1, - time: 60000, - filter: (it: any) => it.user.id === message.author.id - }); - - selCollector.on('collect', async (sel: any) => { - const idx = parseInt(sel.values[0]); - const textComp = blockState.components[idx]; - - const modal = new ModalBuilder() - .setCustomId(`edit_thumbnail_modal_${idx}`) - .setTitle('📎 Editar Thumbnail'); - - const thumbnailInput = new TextInputBuilder() - .setCustomId('thumbnail_input') - .setLabel('URL del Thumbnail') - .setStyle(TextInputStyle.Short) - .setPlaceholder('https://ejemplo.com/thumbnail.png o dejar vacío para eliminar') - .setValue(textComp?.thumbnail || '') - .setMaxLength(2000) - .setRequired(false); - - const firstRow = new ActionRowBuilder().addComponents(thumbnailInput); - modal.addComponents(firstRow); - - // Abrir modal directamente sin update previo - // @ts-ignore - await sel.showModal(modal); - }); - break; - } - case "edit_link_button": { - // Elegir a qué TextDisplay aplicar - const textDisplays = blockState.components - .map((c: any, idx: number) => ({ c, idx })) - .filter(({ c }: any) => c.type === 10); - - if (textDisplays.length === 0) { - await i.deferReply({ flags: 64 }); - // @ts-ignore - await i.editReply({ content: "❌ Necesitas al menos un componente de texto para añadir un botón link." }); - break; - } - - const options = textDisplays.map(({ c, idx }: any) => ({ - label: `Texto #${idx + 1}: ${c.content?.slice(0, 30) || '...'}`, - value: String(idx), - description: c.linkButton ? 'Con botón link' : c.thumbnail ? 'Con thumbnail' : 'Sin accesorio' - })); - - // @ts-ignore - const reply = await i.reply({ - flags: 64, - content: "Elige el TextDisplay donde agregar/editar el botón link:", - components: [ - { type: 1, components: [ { type: 3, custom_id: 'choose_text_for_linkbtn', placeholder: 'Selecciona un bloque de texto', options } ] } - ] - }); - // @ts-ignore - const replyMsg = await i.fetchReply(); - // @ts-ignore - const selCollector = replyMsg.createMessageComponentCollector({ componentType: ComponentType.StringSelect, max: 1, time: 60000, filter: (it: any) => it.user.id === message.author.id }); - - selCollector.on('collect', async (sel: any) => { - const idx = parseInt(sel.values[0]); - const textComp = blockState.components[idx]; - - // Regla de exclusividad - if (textComp.thumbnail) { - await sel.update({ content: '❌ Este bloque ya tiene un thumbnail. Elimínalo antes de añadir un botón link.', components: [] }); - return; - } - - if (textComp.linkButton) { - // @ts-ignore - const sub = await i.followUp({ - flags: 64, - content: `Texto #${idx + 1}: ya tiene botón link. ¿Qué deseas hacer?`, - components: [ - { type: 1, components: [ - { type: 2, style: ButtonStyle.Primary, label: '✏️ Editar', custom_id: `edit_link_button_modal_${idx}` }, - { type: 2, style: ButtonStyle.Danger, label: '🗑️ Eliminar', custom_id: `delete_link_button_${idx}` } - ]} - ] - }); - // @ts-ignore - const btnCollector = sub.createMessageComponentCollector({ componentType: ComponentType.Button, max: 1, time: 60000, filter: (b: any) => b.user.id === message.author.id }); - - btnCollector.on('collect', async (b: any) => { - if (b.customId.startsWith('edit_link_button_modal_')) { - const modal = new ModalBuilder() - .setCustomId(`edit_link_button_modal_${idx}`) - .setTitle('🔗 Editar Botón Link'); - - const urlInput = new TextInputBuilder() - .setCustomId('link_url_input') - .setLabel('URL del botón (obligatoria)') - .setStyle(TextInputStyle.Short) - .setPlaceholder('https://ejemplo.com') - .setValue(textComp.linkButton?.url || '') - .setMaxLength(2000) - .setRequired(true); - - const labelInput = new TextInputBuilder() - .setCustomId('link_label_input') - .setLabel('Etiqueta (opcional)') - .setStyle(TextInputStyle.Short) - .setPlaceholder('Texto del botón o vacío para usar solo emoji') - .setValue(textComp.linkButton?.label || '') - .setMaxLength(80) - .setRequired(false); - - const emojiInput = new TextInputBuilder() - .setCustomId('link_emoji_input') - .setLabel('Emoji (opcional)') - .setStyle(TextInputStyle.Short) - .setPlaceholder('Ej: 🔗 o <:name:id>') - .setValue(textComp.linkButton?.emoji || '') - .setMaxLength(64) - .setRequired(false); - - const r1 = new ActionRowBuilder().addComponents(urlInput); - const r2 = new ActionRowBuilder().addComponents(labelInput); - const r3 = new ActionRowBuilder().addComponents(emojiInput); - modal.addComponents(r1, r2, r3); - - // Abrir modal directamente en la misma interacción del botón - // @ts-ignore - await b.showModal(modal); - } else if (b.customId.startsWith('delete_link_button_')) { - delete textComp.linkButton; - await b.update({ content: '✅ Botón link eliminado.', components: [] }); - await updateEditor(editorMessage, { - display: await renderPreview(blockState, message.member, message.guild), - components: btns(false) - }); - } - }); - } else { - const modal = new ModalBuilder() - .setCustomId(`create_link_button_modal_${idx}`) - .setTitle('🔗 Crear Botón Link'); - - const urlInput = new TextInputBuilder() - .setCustomId('link_url_input') - .setLabel('URL del botón (obligatoria)') - .setStyle(TextInputStyle.Short) - .setPlaceholder('https://ejemplo.com') - .setMaxLength(2000) - .setRequired(true); - - const labelInput = new TextInputBuilder() - .setCustomId('link_label_input') - .setLabel('Etiqueta (opcional)') - .setStyle(TextInputStyle.Short) - .setPlaceholder('Texto del botón o vacío para usar solo emoji') - .setMaxLength(80) - .setRequired(false); - - const emojiInput = new TextInputBuilder() - .setCustomId('link_emoji_input') - .setLabel('Emoji (opcional)') - .setStyle(TextInputStyle.Short) - .setPlaceholder('Ej: 🔗 o <:name:id>') - .setMaxLength(64) - .setRequired(false); - - const r1 = new ActionRowBuilder().addComponents(urlInput); - const r2 = new ActionRowBuilder().addComponents(labelInput); - const r3 = new ActionRowBuilder().addComponents(emojiInput); - modal.addComponents(r1, r2, r3); - - // Abrir modal directamente sin update previo - // @ts-ignore - await sel.showModal(modal); - } - }); - - break; - } - } - - await updateEditor(editorMessage, { - display: await renderPreview(blockState, message.member, message.guild), - components: btns(false) + await handleEditorInteractions(editorMessage, message, client, blockName, blockState); + }, +}; + +async function handleEditorInteractions( + editorMessage: Message, + originalMessage: Message, + client: Amayo, + blockName: string, + blockState: BlockState +): Promise { + const collector = editorMessage.createMessageComponentCollector({ + time: 3600000, // 1 hour + filter: (interaction: MessageComponentInteraction) => interaction.user.id === originalMessage.author.id + }); + + collector.on("collect", async (interaction: ButtonInteraction) => { + try { + await handleButtonInteraction( + interaction, + editorMessage, + originalMessage, + client, + blockName, + blockState + ); + } catch (error) { + //@ts-ignore + logger.error("Error handling editor interaction:", error); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + content: "❌ Ocurrió un error al procesar la interacción.", + flags: MessageFlags.Ephemeral }); } + } + }); + + collector.on("end", async (_collected, reason) => { + if (reason === "time") { + await handleEditorTimeout(editorMessage); + } + }); +} + +async function handleButtonInteraction( + interaction: ButtonInteraction, + editorMessage: Message, + originalMessage: Message, + client: Amayo, + blockName: string, + blockState: BlockState +): Promise { + const { customId } = interaction; + + switch (customId) { + case "edit_title": + await handleEditTitle(interaction, editorMessage, originalMessage, blockState); + break; + + case "edit_description": + await handleEditDescription(interaction, editorMessage, originalMessage, blockState); + break; + + case "edit_color": + await handleEditColor(interaction, editorMessage, originalMessage, blockState); + break; + + case "add_content": + await handleAddContent(interaction, editorMessage, originalMessage, blockState); + break; + + case "add_separator": + await handleAddSeparator(interaction, editorMessage, originalMessage, blockState); + break; + + case "add_image": + await handleAddImage(interaction, editorMessage, originalMessage, blockState); + break; + + case "cover_image": + await handleCoverImage(interaction, editorMessage, originalMessage, blockState); + break; + + case "show_variables": + await handleShowVariables(interaction); + break; + + case "show_raw": + await handleShowRaw(interaction, blockState); + break; + + case "save_block": + await handleSaveBlock(interaction, client, blockName, blockState, originalMessage.guildId!); + break; + + case "cancel_block": + await handleCancelBlock(interaction, editorMessage); + break; + + default: + await interaction.reply({ + content: `⚠️ Funcionalidad \`${customId}\` en desarrollo.`, + flags: MessageFlags.Ephemeral + }); + break; + } +} + +async function handleEditTitle( + interaction: ButtonInteraction, + editorMessage: Message, + originalMessage: Message, + blockState: BlockState +): Promise { + const modal = new ModalBuilder() + .setCustomId("edit_title_modal") + .setTitle("Editar Título del Bloque"); + + const titleInput = new TextInputBuilder() + .setCustomId("title_input") + .setLabel("Título") + .setStyle(TextInputStyle.Short) + .setPlaceholder("Escribe el título del bloque...") + .setValue(blockState.title || "") + .setRequired(true) + .setMaxLength(256); + + const actionRow = new ActionRowBuilder().addComponents(titleInput); + modal.addComponents(actionRow); + + await interaction.showModal(modal); + + try { + const modalInteraction = await interaction.awaitModalSubmit({ time: 300000 }); + const newTitle = modalInteraction.fields.getTextInputValue("title_input").trim(); + + if (newTitle) { + blockState.title = newTitle; + await updateEditor(editorMessage, { + display: await DisplayComponentUtils.renderPreview(blockState, originalMessage.member!, originalMessage.guild!), + components: DisplayComponentUtils.createEditorButtons(false) + }); + } + + await modalInteraction.reply({ + content: "✅ Título actualizado correctamente.", + flags: MessageFlags.Ephemeral + }); + } catch { + // Modal timed out or error occurred + // no-op + } +} + +async function handleEditDescription( + interaction: ButtonInteraction, + editorMessage: Message, + originalMessage: Message, + blockState: BlockState +): Promise { + const modal = new ModalBuilder() + .setCustomId("edit_description_modal") + .setTitle("Editar Descripción del Bloque"); + + const descriptionInput = new TextInputBuilder() + .setCustomId("description_input") + .setLabel("Descripción") + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder("Escribe la descripción del bloque...") + .setValue(blockState.description || "") + .setRequired(false) + .setMaxLength(4000); + + const actionRow = new ActionRowBuilder().addComponents(descriptionInput); + modal.addComponents(actionRow); + + await interaction.showModal(modal); + + try { + const modalInteraction = await interaction.awaitModalSubmit({ time: 300000 }); + const newDescription = modalInteraction.fields.getTextInputValue("description_input").trim(); + + blockState.description = newDescription || undefined; + await updateEditor(editorMessage, { + display: await DisplayComponentUtils.renderPreview(blockState, originalMessage.member!, originalMessage.guild!), + components: DisplayComponentUtils.createEditorButtons(false) }); - // Agregar manejo de modales mejorado con mejor gestión de errores - let modalHandlerActive = true; + await modalInteraction.reply({ + content: "✅ Descripción actualizada correctamente.", + flags: MessageFlags.Ephemeral + }); + } catch { + // ignore + } +} - const modalHandler = async (interaction: any) => { - if (!interaction.isModalSubmit()) return; - if (interaction.user.id !== message.author.id) return; - // Quitamos la restricción de endsWith('_modal') para permitir IDs dinámicos con índice - if (!modalHandlerActive) return; // Evitar procesar si ya no está activo +async function handleEditColor( + interaction: ButtonInteraction, + editorMessage: Message, + originalMessage: Message, + blockState: BlockState +): Promise { + const modal = new ModalBuilder() + .setCustomId("edit_color_modal") + .setTitle("Editar Color del Bloque"); - try { - const id = interaction.customId as string; - if (id === 'edit_title_modal') { - blockState.title = interaction.fields.getTextInputValue('title_input'); - await interaction.reply({ content: '✅ Título actualizado.', flags: 64 }); - } else if (id === 'edit_description_modal') { - const newDescription = interaction.fields.getTextInputValue('description_input'); - const firstText = blockState.components.find((c: any) => c.type === 10); - if (firstText) firstText.content = newDescription; else blockState.components.push({ type: 10, content: newDescription, thumbnail: null }); - await interaction.reply({ content: '✅ Descripción actualizada.', flags: 64 }); - } else if (id === 'edit_color_modal') { - const colorInput = interaction.fields.getTextInputValue('color_input'); - if (colorInput.trim() === '') { - blockState.color = null; - } else { - let hexColor = colorInput.replace('#', ''); - if (/^[0-9A-F]{6}$/i.test(hexColor)) { - blockState.color = parseInt(hexColor, 16); - } else { - await interaction.reply({ content: '❌ Color inválido. Usa formato HEX (#FF5733)', flags: 64 }); - return; - } - } - await interaction.reply({ content: '✅ Color actualizado.', flags: 64 }); - } else if (id === 'add_content_modal') { - const newContent = interaction.fields.getTextInputValue('content_input'); - blockState.components.push({ type: 10, content: newContent, thumbnail: null }); - await interaction.reply({ content: '✅ Contenido añadido.', flags: 64 }); - } else if (id === 'add_image_modal') { - const imageUrl = interaction.fields.getTextInputValue('image_url_input'); - if (isValidUrl(imageUrl)) { - blockState.components.push({ type: 12, url: imageUrl }); - await interaction.reply({ content: '✅ Imagen añadida.', flags: 64 }); - } else { - await interaction.reply({ content: '❌ URL de imagen inválida.', flags: 64 }); - return; - } - } else if (id === 'add_cover_modal' || id === 'edit_cover_modal') { - const coverUrl = interaction.fields.getTextInputValue('cover_input'); - if (isValidUrl(coverUrl)) { - blockState.coverImage = coverUrl; - await interaction.reply({ content: '✅ Imagen de portada actualizada.', flags: 64 }); - } else { - await interaction.reply({ content: '❌ URL de portada inválida.', flags: 64 }); - return; - } - } else if (id === 'add_separator_modal') { - const visibleStr = interaction.fields.getTextInputValue('separator_visible').toLowerCase(); - const spacingStr = interaction.fields.getTextInputValue('separator_spacing') || '1'; - const divider = visibleStr === 'true' || visibleStr === '1' || visibleStr === 'si' || visibleStr === 'sí'; - const spacing = Math.min(3, Math.max(1, parseInt(spacingStr) || 1)); - blockState.components.push({ type: 14, divider, spacing }); - await interaction.reply({ content: '✅ Separador añadido.', flags: 64 }); - } else if (id.startsWith('edit_thumbnail_modal_')) { - const idx = parseInt(id.replace('edit_thumbnail_modal_', '')); - const textComp = blockState.components[idx]; - if (!textComp || textComp.type !== 10) return; - const thumbnailUrl = interaction.fields.getTextInputValue('thumbnail_input'); - if (thumbnailUrl.trim() === '') { - textComp.thumbnail = null; - await interaction.reply({ content: '✅ Thumbnail eliminado.', flags: 64 }); - } else if (!isValidUrl(thumbnailUrl)) { - await interaction.reply({ content: '❌ URL de thumbnail inválida.', flags: 64 }); - return; - } else { - if (textComp.linkButton) { - await interaction.reply({ content: '❌ Este bloque ya tiene un botón link. Elimina el botón antes de añadir thumbnail.', flags: 64 }); - return; - } - textComp.thumbnail = thumbnailUrl; - await interaction.reply({ content: '✅ Thumbnail actualizado.', flags: 64 }); - } - } else if (id.startsWith('create_link_button_modal_') || id.startsWith('edit_link_button_modal_')) { - const idx = parseInt(id.replace('create_link_button_modal_', '').replace('edit_link_button_modal_', '')); - const textComp = blockState.components[idx]; - if (!textComp || textComp.type !== 10) return; + const colorInput = new TextInputBuilder() + .setCustomId("color_input") + .setLabel("Color (formato HEX)") + .setStyle(TextInputStyle.Short) + .setPlaceholder("#FF5733 o FF5733") + .setValue(blockState.color ? `#${blockState.color.toString(16).padStart(6, '0')}` : "") + .setRequired(false) + .setMaxLength(7); - const url = interaction.fields.getTextInputValue('link_url_input'); - const label = (interaction.fields.getTextInputValue('link_label_input') || '').trim(); - const emojiStr = (interaction.fields.getTextInputValue('link_emoji_input') || '').trim(); + const actionRow = new ActionRowBuilder().addComponents(colorInput); + modal.addComponents(actionRow); - if (!isValidUrl(url)) { - await interaction.reply({ content: '❌ URL inválida para el botón.', flags: 64 }); - return; - } + await interaction.showModal(modal); - const parsedEmoji = parseEmojiInput(emojiStr || undefined); - if (!label && !parsedEmoji) { - await interaction.reply({ content: '❌ Debes proporcionar al menos una etiqueta o un emoji.', flags: 64 }); - return; - } + try { + const modalInteraction = await interaction.awaitModalSubmit({ time: 300000 }); + const colorValue = modalInteraction.fields.getTextInputValue("color_input").trim(); - if (textComp.thumbnail) { - await interaction.reply({ content: '❌ Este bloque tiene thumbnail. Elimínalo antes de añadir un botón link.', flags: 64 }); - return; - } + if (colorValue) { + const cleanColor = colorValue.replace('#', ''); + const colorNumber = parseInt(cleanColor, 16); - textComp.linkButton = { - url, - label: label || undefined, - // Guardamos el string original; se parsea en render/build - emoji: emojiStr || undefined - }; + if (!isNaN(colorNumber) && cleanColor.length === 6) { + blockState.color = colorNumber; + await updateEditor(editorMessage, { + display: await DisplayComponentUtils.renderPreview(blockState, originalMessage.member!, originalMessage.guild!), + components: DisplayComponentUtils.createEditorButtons(false) + }); - await interaction.reply({ content: '✅ Botón link actualizado.', flags: 64 }); - } else if (id === 'import_json_modal') { - try { - const jsonString = interaction.fields.getTextInputValue('json_input'); - const importedData = JSON.parse(jsonString); - if (importedData && typeof importedData === 'object') { - blockState = { - title: importedData.title || blockState.title, - color: importedData.color || blockState.color, - coverImage: importedData.coverImage || blockState.coverImage, - components: Array.isArray(importedData.components) ? importedData.components : blockState.components - }; - for (const comp of blockState.components) { - if (comp?.type === 10 && comp.linkButton && comp.thumbnail) { - delete comp.thumbnail; // priorizamos linkButton - } - } - await interaction.reply({ content: '✅ JSON importado correctamente.', flags: 64 }); - } else { - await interaction.reply({ content: '❌ Estructura JSON inválida.', flags: 64 }); - return; - } - } catch { - await interaction.reply({ content: '❌ JSON inválido. Verifica el formato.', flags: 64 }); - return; - } - } else { - return; - } - - // Actualizar vista previa tras cada modal - setTimeout(async () => { - if (!modalHandlerActive) return; - try { - const messageExists = await editorMessage.fetch().catch(() => null); - if (!messageExists) return; - await updateEditor(editorMessage, { // @ts-ignore - display: await renderPreview(blockState, message.member, message.guild), - components: btns(false) - }); - } catch (error: any) { - if (error.code === 10008) { - logger.info('Mensaje del editor eliminado'); - } else if (error.code === 10062) { - logger.info('Interacción expirada'); - } else { - logger.error('Error actualizando preview:', error.message || error); - } - } - }, 500); - - } catch (error: any) { - logger.error('Error en modal:', error); - try { - if (error.code !== 10062 && !interaction.replied && !interaction.deferred) { - await interaction.reply({ content: '❌ Error procesando el modal.', flags: 64 }); - } - } catch {} + await modalInteraction.reply({ + content: "✅ Color actualizado correctamente.", + flags: MessageFlags.Ephemeral + }); + } else { + await modalInteraction.reply({ + content: "❌ Color inválido. Usa formato HEX como #FF5733", + flags: MessageFlags.Ephemeral + }); } - }; + } else { + blockState.color = undefined; + await updateEditor(editorMessage, { + display: await DisplayComponentUtils.renderPreview(blockState, originalMessage.member!, originalMessage.guild!), + components: DisplayComponentUtils.createEditorButtons(false) + }); - // Registrar el manejador de modales - client.on('interactionCreate', modalHandler); + await modalInteraction.reply({ + content: "✅ Color removido.", + flags: MessageFlags.Ephemeral + }); + } + } catch { + // ignore + } +} +async function handleAddContent( + interaction: ButtonInteraction, + editorMessage: Message, + originalMessage: Message, + blockState: BlockState +): Promise { + const modal = new ModalBuilder() + .setCustomId("add_content_modal") + .setTitle("Añadir Contenido de Texto"); + + const contentInput = new TextInputBuilder() + .setCustomId("content_input") + .setLabel("Contenido") + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder("Escribe el contenido de texto...") + .setRequired(true) + .setMaxLength(4000); + + const actionRow = new ActionRowBuilder().addComponents(contentInput); + modal.addComponents(actionRow); + + await interaction.showModal(modal); + + try { + const modalInteraction = await interaction.awaitModalSubmit({ time: 300000 }); + const content = modalInteraction.fields.getTextInputValue("content_input").trim(); + + if (content) { + blockState.components.push({ + type: 10, + content, + thumbnail: null + }); + + await updateEditor(editorMessage, { + display: await DisplayComponentUtils.renderPreview(blockState, originalMessage.member!, originalMessage.guild!), + components: DisplayComponentUtils.createEditorButtons(false) + }); + + await modalInteraction.reply({ + content: "✅ Contenido añadido correctamente.", + flags: MessageFlags.Ephemeral + }); + } + } catch { + // ignore + } +} + +async function handleAddSeparator( + interaction: ButtonInteraction, + editorMessage: Message, + originalMessage: Message, + blockState: BlockState +): Promise { + blockState.components.push({ + type: 14, + divider: true, + spacing: 1 + }); + + await updateEditor(editorMessage, { + display: await DisplayComponentUtils.renderPreview(blockState, originalMessage.member!, originalMessage.guild!), + components: DisplayComponentUtils.createEditorButtons(false) + }); + + await interaction.reply({ + content: "✅ Separador añadido correctamente.", + flags: MessageFlags.Ephemeral + }); +} + +async function handleAddImage( + interaction: ButtonInteraction, + editorMessage: Message, + originalMessage: Message, + blockState: BlockState +): Promise { + const modal = new ModalBuilder() + .setCustomId("add_image_modal") + .setTitle("Añadir Imagen"); + + const imageInput = new TextInputBuilder() + .setCustomId("image_input") + .setLabel("URL de la Imagen") + .setStyle(TextInputStyle.Short) + .setPlaceholder("https://ejemplo.com/imagen.png") + .setRequired(true) + .setMaxLength(512); + + const actionRow = new ActionRowBuilder().addComponents(imageInput); + modal.addComponents(actionRow); + + await interaction.showModal(modal); + + try { + const modalInteraction = await interaction.awaitModalSubmit({ time: 300000 }); + const imageUrl = modalInteraction.fields.getTextInputValue("image_input").trim(); + + if (imageUrl && DisplayComponentUtils.isValidUrl(imageUrl)) { + blockState.components.push({ + type: 12, + url: imageUrl + }); + + await updateEditor(editorMessage, { + display: await DisplayComponentUtils.renderPreview(blockState, originalMessage.member!, originalMessage.guild!), + components: DisplayComponentUtils.createEditorButtons(false) + }); + + await modalInteraction.reply({ + content: "✅ Imagen añadida correctamente.", + ephemeral: true + }); + } else { + await modalInteraction.reply({ + content: "❌ URL de imagen inválida.", + ephemeral: true + }); + } + } catch { + // ignore + } +} + +async function handleCoverImage( + interaction: ButtonInteraction, + editorMessage: Message, + originalMessage: Message, + blockState: BlockState +): Promise { + const modal = new ModalBuilder() + .setCustomId("cover_image_modal") + .setTitle("Imagen de Portada"); + + const coverInput = new TextInputBuilder() + .setCustomId("cover_input") + .setLabel("URL de la Imagen de Portada") + .setStyle(TextInputStyle.Short) + .setPlaceholder("https://ejemplo.com/portada.png") + .setValue(blockState.coverImage || "") + .setRequired(false) + .setMaxLength(512); + + const actionRow = new ActionRowBuilder().addComponents(coverInput); + modal.addComponents(actionRow); + + await interaction.showModal(modal); + + try { + const modalInteraction = await interaction.awaitModalSubmit({ time: 300000 }); + const coverUrl = modalInteraction.fields.getTextInputValue("cover_input").trim(); + + if (coverUrl && DisplayComponentUtils.isValidUrl(coverUrl)) { + blockState.coverImage = coverUrl; + } else { + blockState.coverImage = undefined; + } + + await updateEditor(editorMessage, { + display: await DisplayComponentUtils.renderPreview(blockState, originalMessage.member!, originalMessage.guild!), + components: DisplayComponentUtils.createEditorButtons(false) + }); + + await modalInteraction.reply({ + content: coverUrl ? "✅ Imagen de portada actualizada." : "✅ Imagen de portada removida.", + ephemeral: true + }); + } catch { + // ignore + } +} + +async function handleShowVariables(interaction: ButtonInteraction): Promise { + const variables = listVariables(); + await interaction.reply({ + content: `📋 **Variables disponibles:**\n\`\`\`\n${variables}\`\`\``, + flags: MessageFlags.Ephemeral + }); +} + +async function handleShowRaw(interaction: ButtonInteraction, blockState: BlockState): Promise { + const rawData = JSON.stringify(blockState, null, 2); + await interaction.reply({ + content: `📊 **Datos del bloque:**\n\`\`\`json\n${rawData.slice(0, 1800)}\`\`\``, + flags: MessageFlags.Ephemeral + }); +} + +async function handleSaveBlock( + interaction: ButtonInteraction, + client: Amayo, + blockName: string, + blockState: BlockState, + guildId: string +): Promise { + try { + await client.prisma.blockV2Config.create({ + data: { + guildId, + name: blockName, + config: blockState as any + } + }); + + await interaction.reply({ + content: `✅ **Bloque guardado exitosamente!**\n\n📄 **Nombre:** \`${blockName}\`\n🎨 **Componentes:** ${blockState.components.length}\n\n🎯 **Uso:** \`!send ${blockName}\``, + flags: MessageFlags.Ephemeral + }); + + logger.info(`Block created: ${blockName} in guild ${guildId}`); + } catch (error) { //@ts-ignore - collector.on("end", async (_, reason) => { - // Desactivar el manejador de modales cuando el collector termine - modalHandlerActive = false; - client.off('interactionCreate', modalHandler); - - if (reason === "time") { - try { - const messageExists = await editorMessage.fetch().catch(() => null); - if (messageExists) { - await updateEditor(editorMessage, { - // @ts-ignore - display: { - type: 17, - components: [{ type: 10, content: "⏰ Editor finalizado por inactividad." }] - }, - components: [] - }); - } - } catch (error) { - logger.info('No se pudo actualizar el mensaje final'); - } - } + logger.error("Error saving block:", error); + await interaction.reply({ + content: "❌ Error al guardar el bloque. Inténtalo de nuevo.", + flags: MessageFlags.Ephemeral }); } -}; +} + +async function handleCancelBlock(interaction: ButtonInteraction, editorMessage: Message): Promise { + await interaction.update({ + content: "❌ **Editor cancelado**\n\nLa creación del bloque ha sido cancelada.", + components: [], + embeds: [] + }); +} + +async function handleEditorTimeout(editorMessage: Message): Promise { + try { + await editorMessage.edit({ + content: "⏰ **Editor expirado**\n\nEl editor ha expirado por inactividad. Usa el comando nuevamente para crear un bloque.", + components: [], + embeds: [] + }); + } catch { + // message likely deleted + } +} diff --git a/src/commands/messages/alliaces/deleteDisplayComponent.ts b/src/commands/messages/alliaces/deleteDisplayComponent.ts index 413ee56..bdd837f 100644 --- a/src/commands/messages/alliaces/deleteDisplayComponent.ts +++ b/src/commands/messages/alliaces/deleteDisplayComponent.ts @@ -1,217 +1,411 @@ +import { + Message, + ButtonInteraction, + StringSelectMenuInteraction, + MessageComponentInteraction, + ComponentType, + ButtonStyle, + APIEmbed +} from "discord.js"; import { CommandMessage } from "../../../core/types/commands"; +import type Amayo from "../../../core/client"; +import type { JsonValue } from "@prisma/client/runtime/library"; + +interface BlockItem { + name: string; + id: string; +} + +interface ActionRowBuilder { + type: ComponentType.ActionRow; + components: any[]; +} export const command: CommandMessage = { name: "eliminar-embed", type: "message", aliases: ["embed-eliminar", "borrar-embed", "embeddelete"], cooldown: 10, - run: async (message: any, args: string[], client: any) => { + description: "Elimina bloques DisplayComponents del servidor", + category: "Alianzas", + usage: "eliminar-embed [nombre_bloque]", + run: async (message: Message, args: string[], client: Amayo): Promise => { if (!message.member?.permissions.has("Administrator")) { await message.reply("❌ No tienes permisos de Administrador."); return; } - // Obtener todos los bloques del servidor - const blocks = await client.prisma.blockV2Config.findMany({ - where: { guildId: message.guildId! }, - select: { name: true, id: true } - }); - - if (blocks.length === 0) { - const noBlocksEmbed = { - color: 0xf04747, - title: "🗂️ Panel de Eliminación de Bloques", - description: "📭 **No hay bloques disponibles**\n\nNo se encontraron bloques para eliminar en este servidor.\n\nPuedes crear nuevos bloques usando `!blockcreate`.", - footer: { - text: "Sistema de gestión de bloques • Amayo Bot" - } - }; - - await message.reply({ - embeds: [noBlocksEmbed] - }); + // If specific block name provided, handle direct deletion + if (args.length > 0) { + const blockName = args.join(" ").trim(); + await handleDirectDeletion(message, client, blockName); return; } - // Crear opciones para el select menu - const selectOptions = blocks.slice(0, 25).map((block: any, index: number) => ({ - label: block.name, - value: block.name, - description: `ID: ${block.id}`, - emoji: index < 10 ? { name: `${index + 1}️⃣` } : { name: "📄" } - })); - - // Crear embed principal de eliminación - const deleteEmbed = { - color: 0xff6b35, - title: "🗑️ Panel de Eliminación de Bloques", - description: `📊 **${blocks.length} bloque(s) encontrado(s)**\n\n⚠️ **ADVERTENCIA:** La eliminación es permanente e irreversible.\n\nSelecciona el bloque que deseas eliminar del menú de abajo:`, - footer: { - text: "Selecciona un bloque para eliminar • Timeout: 5 minutos" - } - }; - - const actionRow = { - type: 1, - components: [ - { - type: 3, // StringSelect - custom_id: "delete_block_select", - placeholder: "🗑️ Selecciona un bloque para eliminar...", - min_values: 1, - max_values: 1, - options: selectOptions - } - ] - }; - - const cancelRow = { - type: 1, - components: [ - { - type: 2, // Button - style: 4, // Danger - label: "❌ Cancelar", - custom_id: "cancel_delete" - } - ] - }; - - const panelMessage = await message.reply({ - embeds: [deleteEmbed], - components: [actionRow, cancelRow] - }); - - const collector = panelMessage.createMessageComponentCollector({ - time: 300000, // 5 minutos - filter: (i: any) => i.user.id === message.author.id - }); - - collector.on("collect", async (interaction: any) => { - if (interaction.customId === "cancel_delete") { - const canceledEmbed = { - color: 0x36393f, - title: "❌ Operación Cancelada", - description: "La eliminación de bloques ha sido cancelada.\nNingún bloque fue eliminado.", - footer: { text: "Operación cancelada por el usuario" } - }; - - await interaction.update({ - embeds: [canceledEmbed], - components: [] - }); - - collector.stop("cancelled"); - return; - } - - if (interaction.customId === "delete_block_select" && interaction.isStringSelectMenu()) { - const selectedBlock = interaction.values[0]; - - const confirmationEmbed = { - color: 0xf04747, - title: "⚠️ CONFIRMAR ELIMINACIÓN", - description: `🗑️ **Bloque a eliminar:** \`${selectedBlock}\`\n\n❗ **ESTA ACCIÓN ES IRREVERSIBLE**\n\nUna vez eliminado, no podrás recuperar:\n• Toda la configuración del bloque\n• Los componentes y contenido\n• Las imágenes y colores personalizados\n\n¿Estás seguro de que quieres continuar?`, - footer: { text: "⚠️ Acción irreversible - Piénsalo bien" } - }; - - const confirmationRow = { - type: 1, - components: [ - { - type: 2, - style: 4, // Danger - label: "🗑️ SÍ, ELIMINAR", - custom_id: `confirm_delete_${selectedBlock}` - }, - { - type: 2, - style: 2, // Secondary - label: "↩️ Volver Atrás", - custom_id: "back_to_selection" - } - ] - }; - - await interaction.update({ - embeds: [confirmationEmbed], - components: [confirmationRow] - }); - return; - } - - if (interaction.customId.startsWith("confirm_delete_")) { - const blockName = interaction.customId.replace("confirm_delete_", ""); - - try { - await client.prisma.blockV2Config.delete({ - where: { - guildId_name: { - guildId: message.guildId!, - name: blockName, - }, - }, - }); - - const successEmbed = { - color: 0x57f287, - title: "✅ Eliminación Exitosa", - description: `🗑️ **Bloque eliminado:** \`${blockName}\`\n\n✨ El bloque ha sido eliminado permanentemente de la base de datos.\n\n📋 Para ver los bloques restantes, usa: \`!embedlist\`\n📝 Para crear un nuevo bloque, usa: \`!blockcreate\``, - footer: { text: "Bloque eliminado exitosamente" } - }; - - await interaction.update({ - embeds: [successEmbed], - components: [] - }); - - collector.stop("success"); - - } catch (error) { - const errorEmbed = { - color: 0xf04747, - title: "❌ Error en la Eliminación", - description: `🔍 **Bloque no encontrado:** \`${blockName}\`\n\n💭 Posibles causas:\n• El bloque ya fue eliminado\n• Error de conexión con la base de datos\n• El nombre del bloque cambió\n\n🔄 Intenta refrescar la lista con \`!embedlist\``, - footer: { text: "Error de eliminación" } - }; - - await interaction.update({ - embeds: [errorEmbed], - components: [] - }); - - collector.stop("error"); - } - return; - } - - if (interaction.customId === "back_to_selection") { - await interaction.update({ - embeds: [deleteEmbed], - components: [actionRow, cancelRow] - }); - return; - } - }); - - collector.on("end", async (collected: any, reason: string) => { - if (reason === "time") { - const timeoutEmbed = { - color: 0x36393f, - title: "⏰ Tiempo Agotado", - description: "El panel de eliminación ha expirado por inactividad.\nUsa el comando nuevamente si necesitas eliminar bloques.", - footer: { text: "Panel expirado por inactividad" } - }; - - try { - await panelMessage.edit({ - embeds: [timeoutEmbed], - components: [] - }); - } catch (error) { - // Mensaje ya eliminado o error de edición - } - } - }); + // Otherwise, show interactive panel + await showDeletionPanel(message, client); }, }; + +async function handleDirectDeletion( + message: Message, + client: Amayo, + blockName: string +): Promise { + const block = await client.prisma.blockV2Config.findFirst({ + where: { + guildId: message.guildId!, + name: blockName + } + }); + + if (!block) { + await message.reply(`❌ No se encontró un bloque llamado \`${blockName}\`.`); + return; + } + + // Show confirmation for direct deletion + const confirmEmbed: APIEmbed = { + color: 0xff6b35, + title: "⚠️ Confirmar Eliminación", + description: `¿Estás seguro de que quieres eliminar el bloque \`${blockName}\`?\n\n**Esta acción es irreversible.**`, + footer: { text: "Confirma la eliminación usando los botones" } + }; + + const confirmRow: ActionRowBuilder = { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + style: ButtonStyle.Danger, + label: "🗑️ Confirmar Eliminación", + custom_id: `confirm_delete_${block.id}` + }, + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "❌ Cancelar", + custom_id: "cancel_delete" + } + ] + }; + + const confirmMessage = await message.reply({ + embeds: [confirmEmbed], + components: [confirmRow] + }); + + await handleConfirmationInteraction(confirmMessage, message, client, block); +} + +async function showDeletionPanel(message: Message, client: Amayo): Promise { + const blocks = await fetchBlocks(client, message.guildId!); + + if (blocks.length === 0) { + await handleNoBlocks(message); + return; + } + + const deleteEmbed = createDeletionEmbed(blocks); + const actionRow = createBlockSelectRow(blocks); + const cancelRow = createCancelRow(); + + const panelMessage = await message.reply({ + embeds: [deleteEmbed], + components: [actionRow, cancelRow] + }); + + await handlePanelInteractions(panelMessage, message, client, blocks); +} + +async function fetchBlocks(client: Amayo, guildId: string): Promise { + return await client.prisma.blockV2Config.findMany({ + where: { guildId }, + select: { name: true, id: true }, + orderBy: { name: 'asc' } + }); +} + +async function handleNoBlocks(message: Message): Promise { + const noBlocksEmbed: APIEmbed = { + color: 0xf04747, + title: "🗂️ Panel de Eliminación de Bloques", + description: "📭 **No hay bloques disponibles**\n\nNo se encontraron bloques para eliminar en este servidor.\n\nPuedes crear nuevos bloques usando `!crear-embed`.", + footer: { text: "Sistema de gestión de bloques • Amayo Bot" } + }; + + await message.reply({ + embeds: [noBlocksEmbed] + }); +} + +function createDeletionEmbed(blocks: BlockItem[]): APIEmbed { + return { + color: 0xff6b35, + title: "🗑️ Panel de Eliminación de Bloques", + description: `📊 **${blocks.length} bloque(s) encontrado(s)**\n\n⚠️ **ADVERTENCIA:** La eliminación es permanente e irreversible.\n\nSelecciona el bloque que deseas eliminar del menú de abajo:`, + footer: { text: "Selecciona un bloque para eliminar • Timeout: 5 minutos" } + }; +} + +function createBlockSelectRow(blocks: BlockItem[]): ActionRowBuilder { + const selectOptions = blocks.slice(0, 25).map((block, index) => ({ + label: block.name, + value: block.id, // Use ID instead of name for better uniqueness + description: `ID: ${block.id.slice(-8)}`, + emoji: index < 10 ? { name: `${index + 1}️⃣` } : { name: "📄" } + })); + + return { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.StringSelect, + custom_id: "delete_block_select", + placeholder: "🗑️ Selecciona un bloque para eliminar...", + min_values: 1, + max_values: 1, + options: selectOptions + } + ] + }; +} + +function createCancelRow(): ActionRowBuilder { + return { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + style: ButtonStyle.Danger, + label: "❌ Cancelar", + custom_id: "cancel_delete" + } + ] + }; +} + +async function handlePanelInteractions( + panelMessage: Message, + originalMessage: Message, + client: Amayo, + blocks: BlockItem[] +): Promise { + const collector = panelMessage.createMessageComponentCollector({ + time: 300000, // 5 minutes + filter: (interaction: MessageComponentInteraction) => interaction.user.id === originalMessage.author.id + }); + + collector.on("collect", async (interaction: MessageComponentInteraction) => { + try { + if (interaction.isButton() && interaction.customId === "cancel_delete") { + await handleCancellation(interaction); + collector.stop(); + } else if (interaction.isStringSelectMenu() && interaction.customId === "delete_block_select") { + const selectedBlockId = interaction.values[0]; + const selectedBlock = blocks.find(b => b.id === selectedBlockId); + + if (selectedBlock) { + await handleBlockSelection(interaction, client, selectedBlock); + collector.stop(); + } + } + } catch (error) { + console.error("Error handling deletion interaction:", error); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + content: "❌ Ocurrió un error al procesar la interacción.", + ephemeral: true + }); + } + } + }); + + collector.on("end", async (collected, reason) => { + if (reason === "time") { + await handlePanelTimeout(panelMessage); + } + }); +} + +async function handleCancellation(interaction: ButtonInteraction): Promise { + const canceledEmbed: APIEmbed = { + color: 0x36393f, + title: "❌ Operación Cancelada", + description: "La eliminación de bloques ha sido cancelada.\nNingún bloque fue eliminado.", + footer: { text: "Operación cancelada por el usuario" } + }; + + await interaction.update({ + embeds: [canceledEmbed], + components: [] + }); +} + +async function handleBlockSelection( + interaction: StringSelectMenuInteraction, + client: Amayo, + selectedBlock: BlockItem +): Promise { + const confirmEmbed: APIEmbed = { + color: 0xff4444, + title: "⚠️ Confirmar Eliminación", + description: `¿Estás seguro de que quieres **eliminar permanentemente** el bloque?\n\n📄 **Nombre:** \`${selectedBlock.name}\`\n🔑 **ID:** \`${selectedBlock.id}\`\n\n❗ **Esta acción NO se puede deshacer.**`, + footer: { text: "Confirma tu decisión usando los botones" } + }; + + const confirmRow: ActionRowBuilder = { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + style: ButtonStyle.Danger, + label: "🗑️ SÍ, ELIMINAR", + custom_id: `confirm_delete_${selectedBlock.id}` + }, + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "❌ Cancelar", + custom_id: "cancel_delete_final" + } + ] + }; + + await interaction.update({ + embeds: [confirmEmbed], + components: [confirmRow] + }); + + // Handle final confirmation + const finalCollector = interaction.message.createMessageComponentCollector({ + time: 60000, // 1 minute for final confirmation + filter: (i: MessageComponentInteraction) => i.user.id === interaction.user.id + }); + + finalCollector.on("collect", async (finalInteraction: ButtonInteraction) => { + try { + if (finalInteraction.customId === "cancel_delete_final") { + await handleCancellation(finalInteraction); + } else if (finalInteraction.customId === `confirm_delete_${selectedBlock.id}`) { + await executeBlockDeletion(finalInteraction, client, selectedBlock); + } + finalCollector.stop(); + } catch (error) { + console.error("Error in final confirmation:", error); + } + }); + + finalCollector.on("end", async (collected, reason) => { + if (reason === "time") { + await handleConfirmationTimeout(interaction.message); + } + }); +} + +async function handleConfirmationInteraction( + confirmMessage: Message, + originalMessage: Message, + client: Amayo, + block: any +): Promise { + const collector = confirmMessage.createMessageComponentCollector({ + time: 60000, // 1 minute + filter: (interaction: MessageComponentInteraction) => interaction.user.id === originalMessage.author.id + }); + + collector.on("collect", async (interaction: ButtonInteraction) => { + try { + if (interaction.customId === "cancel_delete") { + await handleCancellation(interaction); + } else if (interaction.customId === `confirm_delete_${block.id}`) { + await executeBlockDeletion(interaction, client, { name: block.name, id: block.id }); + } + collector.stop(); + } catch (error) { + console.error("Error in confirmation interaction:", error); + } + }); + + collector.on("end", async (collected, reason) => { + if (reason === "time") { + await handleConfirmationTimeout(confirmMessage); + } + }); +} + +async function executeBlockDeletion( + interaction: ButtonInteraction, + client: Amayo, + block: BlockItem +): Promise { + try { + // Delete the block from database + await client.prisma.blockV2Config.delete({ + where: { id: block.id } + }); + + const successEmbed: APIEmbed = { + color: 0x57f287, + title: "✅ Bloque Eliminado", + description: `El bloque \`${block.name}\` ha sido eliminado exitosamente.\n\n🗑️ **Operación completada**\n📄 **Bloque:** \`${block.name}\`\n🔑 **ID:** \`${block.id}\``, + footer: { text: "Bloque eliminado permanentemente" } + }; + + await interaction.update({ + embeds: [successEmbed], + components: [] + }); + + } catch (error) { + console.error("Error deleting block:", error); + + const errorEmbed: APIEmbed = { + color: 0xf04747, + title: "❌ Error al Eliminar", + description: `No se pudo eliminar el bloque \`${block.name}\`.\n\nPor favor, inténtalo de nuevo más tarde.`, + footer: { text: "Error en la eliminación" } + }; + + await interaction.update({ + embeds: [errorEmbed], + components: [] + }); + } +} + +async function handlePanelTimeout(panelMessage: Message): Promise { + const timeoutEmbed: APIEmbed = { + color: 0x36393f, + title: "⏰ Panel Expirado", + description: "El panel de eliminación ha expirado por inactividad.\n\nUsa `!eliminar-embed` para abrir un nuevo panel.", + footer: { text: "Panel expirado por inactividad" } + }; + + try { + await panelMessage.edit({ + embeds: [timeoutEmbed], + components: [] + }); + } catch (error) { + console.log("Could not edit message on timeout, likely deleted"); + } +} + +async function handleConfirmationTimeout(confirmMessage: Message): Promise { + const timeoutEmbed: APIEmbed = { + color: 0x36393f, + title: "⏰ Confirmación Expirada", + description: "La confirmación ha expirado por inactividad.\nLa eliminación ha sido cancelada.", + footer: { text: "Confirmación expirada" } + }; + + try { + await confirmMessage.edit({ + embeds: [timeoutEmbed], + components: [] + }); + } catch (error) { + console.log("Could not edit confirmation message on timeout"); + } +} diff --git a/src/commands/messages/alliaces/displayComponentList.ts b/src/commands/messages/alliaces/displayComponentList.ts index 85e9a42..a329961 100644 --- a/src/commands/messages/alliaces/displayComponentList.ts +++ b/src/commands/messages/alliaces/displayComponentList.ts @@ -1,402 +1,483 @@ +import { + Message, + ButtonInteraction, + StringSelectMenuInteraction, + MessageComponentInteraction, + ComponentType, + ButtonStyle, + APIButtonComponent, + APIStringSelectComponent, + APIEmbed +} from "discord.js"; import { CommandMessage } from "../../../core/types/commands"; +import type { + BlockConfig, + PaginationData +} from "../../../core/types/displayComponents"; +import type Amayo from "../../../core/client"; +import type { JsonValue } from "@prisma/client/runtime/library"; + +interface BlockListItem { + name: string; + id: string; + config: JsonValue; // Use Prisma's JsonValue type +} + +interface ActionRowBuilder { + type: ComponentType.ActionRow; + components: (APIButtonComponent | APIStringSelectComponent)[]; +} export const command: CommandMessage = { name: "lista-embeds", type: "message", aliases: ["embeds", "ver-embeds", "embedlist"], cooldown: 10, - run: async (message: any, args: string[], client: any) => { + description: "Muestra todos los bloques DisplayComponents configurados en el servidor", + category: "Alianzas", + usage: "lista-embeds", + run: async (message: Message, args: string[], client: Amayo): Promise => { + // Permission check if (!message.member?.permissions.has("Administrator")) { - return message.reply("❌ No tienes permisos de Administrador."); - } - - const blocks = await client.prisma.blockV2Config.findMany({ - where: { guildId: message.guildId! }, - select: { - name: true, - id: true, - config: true - }, - orderBy: { name: 'asc' } - }); - - if (blocks.length === 0) { - const emptyEmbed = { - color: 0x5865f2, - title: "📚 Centro de Gestión de Bloques", - description: "📭 **No hay bloques disponibles**\n\nEste servidor aún no tiene bloques configurados.\n\n🚀 **¿Quieres empezar?**\n• Usa `!crear-embed ` para crear tu primer bloque\n• Usa `!editar-embed ` para editar bloques existentes", - footer: { text: "Sistema de gestión de bloques • Amayo Bot" } - }; - - const createRow = { - type: 1, - components: [ - { - type: 2, - style: 3, - label: "📝 Crear Primer Bloque", - custom_id: "show_create_help" - } - ] - }; - - const helpMessage = await message.reply({ - embeds: [emptyEmbed], - components: [createRow] - }); - - const helpCollector = helpMessage.createMessageComponentCollector({ - time: 60000, - filter: (i: any) => i.user.id === message.author.id - }); - - helpCollector.on("collect", async (interaction: any) => { - if (interaction.customId === "show_create_help") { - const helpEmbed = { - color: 0x57f287, - title: "📖 Guía de Creación de Bloques", - description: "🔧 **Comandos disponibles:**\n\n• `!crear-embed ` - Crear nuevo bloque\n• `!editar-embed ` - Editar bloque existente\n• `!eliminar-embed ` - Eliminar bloque\n• `!lista-embeds` - Ver todos los bloques\n\n💡 **Tip:** Los bloques permiten crear interfaces modernas e interactivas.", - footer: { text: "Guía de comandos de creación" } - }; - - await interaction.update({ - embeds: [helpEmbed], - components: [] - }); - } - }); - + await message.reply("❌ No tienes permisos de Administrador."); return; } - // Dividir bloques en páginas de 5 - const itemsPerPage = 5; - const totalPages = Math.ceil(blocks.length / itemsPerPage); - let currentPage = 0; + const blocks = await fetchBlocks(client, message.guildId!); - const generateBlockListEmbed = (page: number) => { - const startIndex = page * itemsPerPage; - const endIndex = Math.min(startIndex + itemsPerPage, blocks.length); - const pageBlocks = blocks.slice(startIndex, endIndex); - - let blockListText = `📊 **Página ${page + 1} de ${totalPages}** (${blocks.length} total)\n\n`; - - pageBlocks.forEach((block: any, index: number) => { - const globalIndex = startIndex + index + 1; - const componentsCount = Array.isArray(block.config?.components) ? block.config.components.length : 0; - const hasImage = block.config?.coverImage ? "🖼️" : ""; - - blockListText += `**${globalIndex}.** \`${block.name}\` ${hasImage}\n`; - blockListText += ` └ ${componentsCount} componente(s) • ID: ${block.id.slice(-8)}\n\n`; - }); - - return { - color: 0x5865f2, - title: "📚 Centro de Gestión de Bloques", - description: blockListText, - footer: { text: `Página ${page + 1}/${totalPages} • ${blocks.length} bloques total` } - }; - }; - - const generateActionRows = (page: number) => { - const rows = []; - - // Select menu para acciones rápidas - const currentPageBlocks = blocks.slice(page * itemsPerPage, (page + 1) * itemsPerPage); - if (currentPageBlocks.length > 0) { - const selectOptions = currentPageBlocks.map((block: any) => ({ - label: block.name, - value: block.name, - description: `${Array.isArray(block.config?.components) ? block.config.components.length : 0} componente(s)`, - emoji: { name: "⚙️" } - })); - - rows.push({ - type: 1, - components: [ - { - type: 3, - custom_id: "block_actions_select", - placeholder: "⚙️ Selecciona un bloque para gestionar...", - options: selectOptions - } - ] - }); - } - - // Botones de navegación y acciones generales - const navigationRow: any = { - type: 1, - components: [] - }; - - // Navegación - if (totalPages > 1) { - navigationRow.components.push({ - type: 2, - style: 2, - label: "◀️ Anterior", - custom_id: "prev_page", - disabled: page === 0 - }); - - navigationRow.components.push({ - type: 2, - style: 2, - label: `${page + 1}/${totalPages}`, - custom_id: "page_info", - disabled: true - }); - - navigationRow.components.push({ - type: 2, - style: 2, - label: "▶️ Siguiente", - custom_id: "next_page", - disabled: page === totalPages - 1 - }); - } - - // Botón de refrescar - navigationRow.components.push({ - type: 2, - style: 1, - label: "🔄 Refrescar", - custom_id: "refresh_list" - }); - - rows.push(navigationRow); - - // Acciones principales - const actionsRow = { - type: 1, - components: [ - { - type: 2, - style: 3, - label: "📝 Crear Nuevo", - custom_id: "show_create_commands" - }, - { - type: 2, - style: 2, - label: "📋 Exportar Lista", - custom_id: "export_block_list" - }, - { - type: 2, - style: 4, - label: "🗑️ Eliminar", - custom_id: "show_delete_commands" - } - ] - }; - - rows.push(actionsRow); - - return rows; - }; + if (blocks.length === 0) { + await handleEmptyBlocksList(message); + return; + } + const pagination = createPagination(blocks, 0, 5); const panelMessage = await message.reply({ - embeds: [generateBlockListEmbed(currentPage)], - components: generateActionRows(currentPage) + embeds: [generateBlockListEmbed(pagination)], + components: generateActionRows(pagination) }); - const collector = panelMessage.createMessageComponentCollector({ - time: 600000, - filter: (i: any) => i.user.id === message.author.id - }); - - collector.on("collect", async (interaction: any) => { - switch (interaction.customId) { - case "prev_page": - if (currentPage > 0) { - currentPage--; - await interaction.update({ - embeds: [generateBlockListEmbed(currentPage)], - components: generateActionRows(currentPage) - }); - } - break; - - case "next_page": - if (currentPage < totalPages - 1) { - currentPage++; - await interaction.update({ - embeds: [generateBlockListEmbed(currentPage)], - components: generateActionRows(currentPage) - }); - } - break; - - case "refresh_list": - // Recargar datos - const refreshedBlocks = await client.prisma.blockV2Config.findMany({ - where: { guildId: message.guildId! }, - select: { - name: true, - id: true, - config: true - }, - orderBy: { name: 'asc' } - }); - - blocks.length = 0; - blocks.push(...refreshedBlocks); - - const newTotalPages = Math.ceil(blocks.length / itemsPerPage); - if (currentPage >= newTotalPages) { - currentPage = Math.max(0, newTotalPages - 1); - } - - await interaction.update({ - embeds: [generateBlockListEmbed(currentPage)], - components: generateActionRows(currentPage) - }); - break; - - case "block_actions_select": - if (interaction.isStringSelectMenu()) { - const selectedBlock = interaction.values[0]; - - const blockActionEmbed = { - color: 0xff9500, - title: `⚙️ Gestión de Bloque: \`${selectedBlock}\``, - description: "Selecciona la acción que deseas realizar con este bloque:", - footer: { text: "Acciones disponibles para el bloque seleccionado" } - }; - - const blockActionsRow = { - type: 1, - components: [ - { - type: 2, - style: 1, - label: "✏️ Editar", - custom_id: `edit_block_${selectedBlock}` - }, - { - type: 2, - style: 2, - label: "👁️ Vista Previa", - custom_id: `preview_block_${selectedBlock}` - }, - { - type: 2, - style: 2, - label: "📋 Duplicar", - custom_id: `duplicate_block_${selectedBlock}` - }, - { - type: 2, - style: 4, - label: "🗑️ Eliminar", - custom_id: `delete_block_${selectedBlock}` - } - ] - }; - - const backRow = { - type: 1, - components: [ - { - type: 2, - style: 2, - label: "↩️ Volver a la Lista", - custom_id: "back_to_list" - } - ] - }; - - await interaction.update({ - embeds: [blockActionEmbed], - components: [blockActionsRow, backRow] - }); - } - break; - - case "back_to_list": - await interaction.update({ - embeds: [generateBlockListEmbed(currentPage)], - components: generateActionRows(currentPage) - }); - break; - - case "show_create_commands": - await interaction.reply({ - content: `🔧 **Crear nuevos bloques:**\n\n• \`!crear-embed \` - Crear bloque básico\n• \`!editar-embed \` - Editor avanzado\n\n💡 **Ejemplo:** \`!crear-embed bienvenida\`\n\n📖 **Guía completa:** Los bloques usan DisplayComponents para crear interfaces modernas e interactivas.`, - flags: 64 - }); - break; - - case "show_delete_commands": - await interaction.reply({ - content: `⚠️ **Eliminar bloques:**\n\n• \`!eliminar-embed\` - Panel interactivo de eliminación\n• \`!eliminar-embed \` - Eliminación directa\n\n❗ **Advertencia:** La eliminación es irreversible.`, - flags: 64 - }); - break; - - case "export_block_list": - const exportText = blocks.map((block: any, index: number) => { - const componentsCount = Array.isArray(block.config?.components) ? block.config.components.length : 0; - return `${index + 1}. ${block.name} (${componentsCount} componentes) - ID: ${block.id}`; - }).join('\n'); - - await interaction.reply({ - content: `📋 **Lista Exportada:**\n\`\`\`\n${exportText}\`\`\``, - flags: 64 - }); - break; - - default: - // Manejar acciones específicas de bloques - if (interaction.customId.startsWith("edit_block_")) { - const blockName = interaction.customId.replace("edit_block_", ""); - await interaction.reply({ - content: `Usa: \`!editar-embed ${blockName}\``, - flags: 64 - }); - } else if (interaction.customId.startsWith("delete_block_")) { - const blockName = interaction.customId.replace("delete_block_", ""); - await interaction.reply({ - content: `Usa: \`!eliminar-embed ${blockName}\` para eliminar este bloque de forma segura.`, - flags: 64 - }); - } else if (interaction.customId.startsWith("preview_block_")) { - const blockName = interaction.customId.replace("preview_block_", ""); - await interaction.reply({ - content: `Vista previa de \`${blockName}\` - Funcionalidad en desarrollo`, - flags: 64 - }); - } else if (interaction.customId.startsWith("duplicate_block_")) { - const blockName = interaction.customId.replace("duplicate_block_", ""); - await interaction.reply({ - content: `Funcionalidad de duplicación de \`${blockName}\` en desarrollo`, - flags: 64 - }); - } - break; - } - }); - - collector.on("end", async (collected: any, reason: string) => { - if (reason === "time") { - const timeoutEmbed = { - color: 0x36393f, - title: "⏰ Panel Expirado", - description: "El panel de gestión ha expirado por inactividad.\n\nUsa `!lista-embeds` para abrir un nuevo panel de gestión.", - footer: { text: "Panel expirado por inactividad" } - }; - - try { - await panelMessage.edit({ - embeds: [timeoutEmbed], - components: [] - }); - } catch (error) { - // Mensaje eliminado o error de edición - } - } - }); + await handleInteractions(panelMessage, message, client, pagination); }, -}; \ No newline at end of file +}; + +async function fetchBlocks(client: Amayo, guildId: string): Promise { + return await client.prisma.blockV2Config.findMany({ + where: { guildId }, + select: { + name: true, + id: true, + config: true + }, + orderBy: { name: 'asc' } + }); +} + +async function handleEmptyBlocksList(message: Message): Promise { + const emptyEmbed: APIEmbed = { + color: 0x5865f2, + title: "📚 Centro de Gestión de Bloques", + description: "📭 **No hay bloques disponibles**\n\nEste servidor aún no tiene bloques configurados.\n\n🚀 **¿Quieres empezar?**\n• Usa `!crear-embed ` para crear tu primer bloque\n• Usa `!editar-embed ` para editar bloques existentes", + footer: { text: "Sistema de gestión de bloques • Amayo Bot" } + }; + + const createRow: ActionRowBuilder = { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + style: ButtonStyle.Success, + label: "📝 Crear Primer Bloque", + custom_id: "show_create_help" + } + ] + }; + + const helpMessage = await message.reply({ + embeds: [emptyEmbed], + components: [createRow] + }); + + const helpCollector = helpMessage.createMessageComponentCollector({ + time: 60000, + filter: (interaction: MessageComponentInteraction) => interaction.user.id === message.author.id + }); + + helpCollector.on("collect", async (interaction: ButtonInteraction) => { + if (interaction.customId === "show_create_help") { + const helpEmbed: APIEmbed = { + color: 0x57f287, + title: "📖 Guía de Creación de Bloques", + description: "🔧 **Comandos disponibles:**\n\n• `!crear-embed ` - Crear nuevo bloque\n• `!editar-embed ` - Editar bloque existente\n• `!eliminar-embed ` - Eliminar bloque\n• `!lista-embeds` - Ver todos los bloques\n\n💡 **Tip:** Los bloques permiten crear interfaces modernas e interactivas.", + footer: { text: "Guía de comandos de creación" } + }; + + await interaction.update({ + embeds: [helpEmbed], + components: [] + }); + } + }); +} + +function createPagination(blocks: BlockListItem[], currentPage: number, itemsPerPage: number): PaginationData { + const totalPages = Math.ceil(blocks.length / itemsPerPage); + return { + items: blocks, + currentPage, + totalPages, + itemsPerPage + }; +} + +function generateBlockListEmbed(pagination: PaginationData): APIEmbed { + const { items, currentPage, totalPages, itemsPerPage } = pagination; + const startIndex = currentPage * itemsPerPage; + const endIndex = Math.min(startIndex + itemsPerPage, items.length); + const pageBlocks = items.slice(startIndex, endIndex); + + let blockListText = `📊 **Página ${currentPage + 1} de ${totalPages}** (${items.length} total)\n\n`; + + pageBlocks.forEach((block, index) => { + const globalIndex = startIndex + index + 1; + const config = block.config as BlockConfig; + const componentsCount = Array.isArray(config?.components) ? config.components.length : 0; + const hasImage = config?.coverImage ? "🖼️" : ""; + + blockListText += `**${globalIndex}.** \`${block.name}\` ${hasImage}\n`; + blockListText += ` └ ${componentsCount} componente(s) • ID: ${block.id.slice(-8)}\n\n`; + }); + + return { + color: 0x5865f2, + title: "📚 Centro de Gestión de Bloques", + description: blockListText, + footer: { text: `Página ${currentPage + 1}/${totalPages} • ${items.length} bloques total` } + }; +} + +function generateActionRows(pagination: PaginationData): ActionRowBuilder[] { + const rows: ActionRowBuilder[] = []; + const { items, currentPage, totalPages, itemsPerPage } = pagination; + + // Select menu for quick actions on current page blocks + const currentPageBlocks = items.slice(currentPage * itemsPerPage, (currentPage + 1) * itemsPerPage); + if (currentPageBlocks.length > 0) { + const selectOptions = currentPageBlocks.map((block) => { + const config = block.config as BlockConfig; + const componentsCount = Array.isArray(config?.components) ? config.components.length : 0; + return { + label: block.name, + value: block.name, + description: `${componentsCount} componente(s)`, + emoji: { name: "⚙️" } + }; + }); + + rows.push({ + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.StringSelect, + custom_id: "block_actions_select", + placeholder: "⚙️ Selecciona un bloque para gestionar...", + options: selectOptions + } + ] + }); + } + + // Navigation buttons + const navigationComponents: APIButtonComponent[] = []; + + if (totalPages > 1) { + navigationComponents.push( + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "◀️ Anterior", + custom_id: "prev_page", + disabled: currentPage === 0 + }, + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: `${currentPage + 1}/${totalPages}`, + custom_id: "page_info", + disabled: true + }, + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "▶️ Siguiente", + custom_id: "next_page", + disabled: currentPage === totalPages - 1 + } + ); + } + + navigationComponents.push({ + type: ComponentType.Button, + style: ButtonStyle.Primary, + label: "🔄 Refrescar", + custom_id: "refresh_list" + }); + + rows.push({ + type: ComponentType.ActionRow, + components: navigationComponents + }); + + // Action buttons + rows.push({ + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + style: ButtonStyle.Success, + label: "📝 Crear Nuevo", + custom_id: "show_create_commands" + }, + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "📋 Exportar Lista", + custom_id: "export_block_list" + }, + { + type: ComponentType.Button, + style: ButtonStyle.Danger, + label: "🗑️ Eliminar", + custom_id: "show_delete_commands" + } + ] + }); + + return rows; +} + +async function handleInteractions( + panelMessage: Message, + originalMessage: Message, + client: Amayo, + pagination: PaginationData +): Promise { + const collector = panelMessage.createMessageComponentCollector({ + time: 600000, + filter: (interaction: MessageComponentInteraction) => interaction.user.id === originalMessage.author.id + }); + + collector.on("collect", async (interaction: MessageComponentInteraction) => { + try { + if (interaction.isButton()) { + await handleButtonInteraction(interaction, client, pagination, originalMessage.guildId!); + } else if (interaction.isStringSelectMenu()) { + await handleSelectMenuInteraction(interaction); + } + } catch (error) { + console.error("Error handling interaction:", error); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + content: "❌ Ocurrió un error al procesar la interacción.", + ephemeral: true + }); + } + } + }); + + collector.on("end", async (collected, reason) => { + if (reason === "time") { + await handleCollectorTimeout(panelMessage); + } + }); +} + +async function handleButtonInteraction( + interaction: ButtonInteraction, + client: Amayo, + pagination: PaginationData, + guildId: string +): Promise { + switch (interaction.customId) { + case "prev_page": + if (pagination.currentPage > 0) { + pagination.currentPage--; + await interaction.update({ + embeds: [generateBlockListEmbed(pagination)], + components: generateActionRows(pagination) + }); + } + break; + + case "next_page": + if (pagination.currentPage < pagination.totalPages - 1) { + pagination.currentPage++; + await interaction.update({ + embeds: [generateBlockListEmbed(pagination)], + components: generateActionRows(pagination) + }); + } + break; + + case "refresh_list": + const refreshedBlocks = await fetchBlocks(client, guildId); + pagination.items = refreshedBlocks; + pagination.totalPages = Math.ceil(refreshedBlocks.length / pagination.itemsPerPage); + + if (pagination.currentPage >= pagination.totalPages) { + pagination.currentPage = Math.max(0, pagination.totalPages - 1); + } + + await interaction.update({ + embeds: [generateBlockListEmbed(pagination)], + components: generateActionRows(pagination) + }); + break; + + case "show_create_commands": + await interaction.reply({ + content: `🔧 **Crear nuevos bloques:**\n\n• \`!crear-embed \` - Crear bloque básico\n• \`!editar-embed \` - Editor avanzado\n\n💡 **Ejemplo:** \`!crear-embed bienvenida\`\n\n📖 **Guía completa:** Los bloques usan DisplayComponents para crear interfaces modernas e interactivas.`, + ephemeral: true + }); + break; + + case "show_delete_commands": + await interaction.reply({ + content: `⚠️ **Eliminar bloques:**\n\n• \`!eliminar-embed\` - Panel interactivo de eliminación\n• \`!eliminar-embed \` - Eliminación directa\n\n❗ **Advertencia:** La eliminación es irreversible.`, + ephemeral: true + }); + break; + + case "export_block_list": + const exportText = pagination.items.map((block, index) => { + const config = block.config as BlockConfig; + const componentsCount = Array.isArray(config?.components) ? config.components.length : 0; + return `${index + 1}. ${block.name} (${componentsCount} componentes) - ID: ${block.id}`; + }).join('\n'); + + await interaction.reply({ + content: `📋 **Lista Exportada:**\n\`\`\`\n${exportText}\`\`\``, + ephemeral: true + }); + break; + + case "back_to_list": + await interaction.update({ + embeds: [generateBlockListEmbed(pagination)], + components: generateActionRows(pagination) + }); + break; + + default: + await handleSpecificBlockActions(interaction); + break; + } +} + +async function handleSelectMenuInteraction(interaction: StringSelectMenuInteraction): Promise { + if (interaction.customId === "block_actions_select") { + const selectedBlock = interaction.values[0]; + + const blockActionEmbed: APIEmbed = { + color: 0xff9500, + title: `⚙️ Gestión de Bloque: \`${selectedBlock}\``, + description: "Selecciona la acción que deseas realizar con este bloque:", + footer: { text: "Acciones disponibles para el bloque seleccionado" } + }; + + const blockActionsRow: ActionRowBuilder = { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + style: ButtonStyle.Primary, + label: "✏️ Editar", + custom_id: `edit_block_${selectedBlock}` + }, + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "👁️ Vista Previa", + custom_id: `preview_block_${selectedBlock}` + }, + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "📋 Duplicar", + custom_id: `duplicate_block_${selectedBlock}` + }, + { + type: ComponentType.Button, + style: ButtonStyle.Danger, + label: "🗑️ Eliminar", + custom_id: `delete_block_${selectedBlock}` + } + ] + }; + + const backRow: ActionRowBuilder = { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "↩️ Volver a la Lista", + custom_id: "back_to_list" + } + ] + }; + + await interaction.update({ + embeds: [blockActionEmbed], + components: [blockActionsRow, backRow] + }); + } +} + +async function handleSpecificBlockActions(interaction: ButtonInteraction): Promise { + const { customId } = interaction; + + if (customId.startsWith("edit_block_")) { + const blockName = customId.replace("edit_block_", ""); + await interaction.reply({ + content: `Usa: \`!editar-embed ${blockName}\``, + ephemeral: true + }); + } else if (customId.startsWith("delete_block_")) { + const blockName = customId.replace("delete_block_", ""); + await interaction.reply({ + content: `Usa: \`!eliminar-embed ${blockName}\` para eliminar este bloque de forma segura.`, + ephemeral: true + }); + } else if (customId.startsWith("preview_block_")) { + const blockName = customId.replace("preview_block_", ""); + await interaction.reply({ + content: `Vista previa de \`${blockName}\` - Funcionalidad en desarrollo`, + ephemeral: true + }); + } else if (customId.startsWith("duplicate_block_")) { + const blockName = customId.replace("duplicate_block_", ""); + await interaction.reply({ + content: `Funcionalidad de duplicación de \`${blockName}\` en desarrollo`, + ephemeral: true + }); + } +} + +async function handleCollectorTimeout(panelMessage: Message): Promise { + const timeoutEmbed: APIEmbed = { + color: 0x36393f, + title: "⏰ Panel Expirado", + description: "El panel de gestión ha expirado por inactividad.\n\nUsa `!lista-embeds` para abrir un nuevo panel de gestión.", + footer: { text: "Panel expirado por inactividad" } + }; + + try { + await panelMessage.edit({ + embeds: [timeoutEmbed], + components: [] + }); + } catch (error) { + // Message was deleted or other edit error - ignore + console.log("Could not edit message on timeout, likely deleted"); + } +} diff --git a/src/commands/messages/alliaces/displayComponentsDemo.ts b/src/commands/messages/alliaces/displayComponentsDemo.ts index 2706eed..c0e4f02 100644 --- a/src/commands/messages/alliaces/displayComponentsDemo.ts +++ b/src/commands/messages/alliaces/displayComponentsDemo.ts @@ -1,4 +1,21 @@ +import { + Message, + ButtonInteraction, + MessageComponentInteraction, + ComponentType, + ButtonStyle, + MessageFlags +} from "discord.js"; import { CommandMessage } from "../../../core/types/commands"; +import type { + DisplayComponentContainer +} from "../../../core/types/displayComponents"; +import type Amayo from "../../../core/client"; + +interface ActionRowBuilder { + type: ComponentType.ActionRow; + components: any[]; // Discord.js API components +} export const command: CommandMessage = { name: "displaydemo", @@ -8,401 +25,450 @@ export const command: CommandMessage = { description: "Demostración de DisplayComponents con accesorios y acciones.", category: "Alianzas", usage: "displaydemo", - run: async (message, args, client) => { + run: async (message: Message, _args: string[], _client: Amayo): Promise => { if (!message.member?.permissions.has("Administrator")) { await message.reply("❌ No tienes permisos de Administrador."); return; } - // 🎯 DEMOSTRACIÓN COMPLETA DE DISPLAYCOMPONENTS CON ACCESORIOS - - // Panel principal con accessory de thumbnail - const mainPanel = { - type: 17, // Container - accent_color: 0x5865f2, - components: [ - { - type: 10, // TextDisplay - content: "🎨 **Demostración de DisplayComponents Avanzados**" - }, - { - type: 14, // Separator - divider: true, - spacing: 2 - }, - // Sección con accessory de botón - { - type: 9, // Section - components: [ - { - type: 10, - content: "🔘 **Sección con Botón Accesorio**\n\nEste texto aparece junto a un botón como accesorio. Los accesorios permiten añadir elementos interactivos sin ocupar una fila completa." - } - ], - accessory: { - type: 2, // Button - style: 1, // Primary - label: "Acción Rápida", - custom_id: "quick_action", - emoji: { name: "⚡" } - } - }, - { - type: 14, - divider: true, - spacing: 1 - }, - // Sección con accessory de thumbnail - { - type: 9, // Section - components: [ - { - type: 10, - content: "🖼️ **Sección con Thumbnail**\n\nAquí se muestra texto con una imagen en miniatura como accesorio. Perfecto para mostrar íconos de servidores, avatares o logotipos." - } - ], - accessory: { - type: 11, // Thumbnail - media: { - url: message.guild?.iconURL({ forceStatic: false }) || "https://cdn.discordapp.com/embed/avatars/0.png" - } - } - }, - { - type: 14, - divider: true, - spacing: 1 - }, - // Sección con accessory de link button - { - type: 9, // Section - components: [ - { - type: 10, - content: "🔗 **Sección con Botón de Enlace**\n\nEste tipo de accesorio permite enlaces externos directos sin necesidad de interacciones complejas." - } - ], - accessory: { - type: 2, // Button - style: 5, // Link - label: "Ir a Discord", - url: "https://discord.com", - emoji: { name: "🚀" } - } - } - ] - }; - - // Fila de botones normales para interacción - const actionRow = { - type: 1, // ActionRow - components: [ - { - type: 2, - style: 3, // Success - label: "✨ Más Ejemplos", - custom_id: "show_more_examples" - }, - { - type: 2, - style: 2, // Secondary - label: "🔄 Cambiar Estilos", - custom_id: "change_styles" - }, - { - type: 2, - style: 4, // Danger - label: "❌ Cerrar", - custom_id: "close_demo" - } - ] - }; + const mainPanel = createMainPanel(message); + const actionRow = createActionRow(); const demoMessage = await message.reply({ - // Activar Display Components V2 y mantener SuppressEmbeds - flags: (32768 | 4096), + // Enable Display Components V2 and suppress embeds + flags: MessageFlags.SuppressEmbeds | 32768, components: [mainPanel, actionRow] }); - const collector = demoMessage.createMessageComponentCollector({ - time: 300000, // 5 minutos - filter: (i: any) => i.user.id === message.author.id - }); + await handleDemoInteractions(demoMessage, message); + }, +}; - collector.on("collect", async (interaction: any) => { +function createMainPanel(message: Message): DisplayComponentContainer { + return { + type: 17, // Container + accent_color: 0x5865f2, + components: [ + { + type: 10, // TextDisplay + content: "🎨 **Demostración de DisplayComponents Avanzados**" + }, + { + type: 14, // Separator + divider: true, + spacing: 2 + }, + // Section with button accessory + { + type: 9, // Section + components: [ + { + type: 10, + content: "🔘 **Sección con Botón Accesorio**\n\nEste texto aparece junto a un botón como accesorio. Los accesorios permiten añadir elementos interactivos sin ocupar una fila completa." + } + ], + accessory: { + type: 2, // Button + style: ButtonStyle.Primary, + label: "Acción Rápida", + custom_id: "quick_action", + emoji: { name: "⚡" } + } + }, + { + type: 14, + divider: true, + spacing: 1 + }, + // Section with thumbnail accessory + { + type: 9, // Section + components: [ + { + type: 10, + content: "🖼️ **Sección con Thumbnail**\n\nAquí se muestra texto con una imagen en miniatura como accesorio. Perfecto para mostrar íconos de servidores, avatares o logotipos." + } + ], + accessory: { + type: 11, // Thumbnail + media: { + url: message.guild?.iconURL({ forceStatic: false }) || "https://cdn.discordapp.com/embed/avatars/0.png" + } + } + }, + { + type: 14, + divider: true, + spacing: 1 + }, + // Section with link button accessory + { + type: 9, // Section + components: [ + { + type: 10, + content: "🔗 **Sección con Botón de Enlace**\n\nEste tipo de accesorio permite enlaces externos directos sin necesidad de interacciones complejas." + } + ], + accessory: { + type: 2, // Button + style: ButtonStyle.Link, + label: "Ir a Discord", + url: "https://discord.com", + emoji: { name: "🚀" } + } + } + ] + }; +} + +function createActionRow(): ActionRowBuilder { + return { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + style: ButtonStyle.Success, + label: "✨ Más Ejemplos", + custom_id: "show_more_examples" + }, + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "🔄 Cambiar Estilos", + custom_id: "change_styles" + }, + { + type: ComponentType.Button, + style: ButtonStyle.Danger, + label: "❌ Cerrar", + custom_id: "close_demo" + } + ] + }; +} + +async function handleDemoInteractions(demoMessage: Message, originalMessage: Message): Promise { + const collector = demoMessage.createMessageComponentCollector({ + time: 300000, // 5 minutes + filter: (interaction: MessageComponentInteraction) => interaction.user.id === originalMessage.author.id + }); + + collector.on("collect", async (interaction: ButtonInteraction) => { + try { switch (interaction.customId) { case "quick_action": await interaction.reply({ content: "⚡ **Acción Rápida Ejecutada!**\n\nEste botón estaba como accesorio en una sección.", - flags: 64 // Ephemeral + ephemeral: true }); break; case "show_more_examples": - // Panel con múltiples ejemplos de accesorios - const examplesPanel = { - type: 17, - accent_color: 0xff9500, - components: [ - { - type: 10, - content: "🎯 **Más Ejemplos de Accesorios**" - }, - { - type: 14, - divider: true, - spacing: 2 - }, - // Ejemplo con avatar del usuario - { - type: 9, - components: [ - { - type: 10, - content: `👤 **Perfil de ${message.author.username}**\n\nEjemplo usando tu avatar como thumbnail accesorio.` - } - ], - accessory: { - type: 11, - media: { - url: message.author.displayAvatarURL({ forceStatic: false }) - } - } - }, - { - type: 14, - divider: false, - spacing: 1 - }, - // Ejemplo con botón de estilo diferente - { - type: 9, - components: [ - { - type: 10, - content: "🎨 **Botones con Diferentes Estilos**\n\nLos accesorios pueden tener distintos estilos y emojis personalizados." - } - ], - accessory: { - type: 2, - style: 4, // Danger - label: "Peligro", - custom_id: "danger_button", - emoji: { name: "⚠️" } - } - }, - { - type: 14, - divider: false, - spacing: 1 - }, - // Imagen como accessory - { - type: 9, - components: [ - { - type: 10, - content: "🖼️ **Imágenes Personalizadas**\n\nTambién puedes usar imágenes personalizadas, íconos de servidores invitados, etc." - } - ], - accessory: { - type: 11, - media: { - url: "https://cdn.discordapp.com/attachments/123/456/discord-logo.png" - } - } - } - ] - }; - - await interaction.update({ - components: [examplesPanel, { - type: 1, - components: [ - { - type: 2, - style: 2, // Secondary - label: "↩️ Volver", - custom_id: "back_to_main" - } - ] - }] - }); + await handleMoreExamples(interaction, originalMessage); break; case "change_styles": - // Panel mostrando diferentes combinaciones de estilos - const stylesPanel = { - type: 17, - accent_color: 0x57f287, - components: [ - { - type: 10, - content: "🎨 **Galería de Estilos**" - }, - { - type: 14, - divider: true, - spacing: 2 - }, - // Botón Primary como accesorio - { - type: 9, - components: [ - { - type: 10, - content: "🔵 **Botón Primary (Azul)**\nEstilo: 1 - Para acciones principales" - } - ], - accessory: { - type: 2, - style: 1, // Primary - label: "Principal", - custom_id: "style_primary" - } - }, - // Botón Secondary como accesorio - { - type: 9, - components: [ - { - type: 10, - content: "⚫ **Botón Secondary (Gris)**\nEstilo: 2 - Para acciones secundarias" - } - ], - accessory: { - type: 2, - style: 2, // Secondary - label: "Secundario", - custom_id: "style_secondary" - } - }, - // Botón Success como accesorio - { - type: 9, - components: [ - { - type: 10, - content: "🟢 **Botón Success (Verde)**\nEstilo: 3 - Para confirmar acciones" - } - ], - accessory: { - type: 2, - style: 3, // Success - label: "Confirmar", - custom_id: "style_success" - } - }, - // Botón Danger como accesorio - { - type: 9, - components: [ - { - type: 10, - content: "🔴 **Botón Danger (Rojo)**\nEstilo: 4 - Para acciones destructivas" - } - ], - accessory: { - type: 2, - style: 4, // Danger - label: "Eliminar", - custom_id: "style_danger" - } - } - ] - }; - - await interaction.update({ - components: [stylesPanel, { - type: 1, - components: [ - { - type: 2, - style: 2, - label: "↩️ Volver", - custom_id: "back_to_main" - } - ] - }] - }); - break; - - case "danger_button": - case "style_primary": - case "style_secondary": - case "style_success": - case "style_danger": - await interaction.reply({ - content: `🎯 **Botón ${interaction.customId.replace('style_', '').replace('_', ' ')} activado!**\n\nEste botón era un accesorio de una sección.`, - flags: 64 // Ephemeral - }); + await handleStylesDemo(interaction); break; case "back_to_main": + const mainPanel = createMainPanel(originalMessage); + const actionRow = createActionRow(); await interaction.update({ components: [mainPanel, actionRow] }); break; case "close_demo": - const closedPanel = { - type: 17, - accent_color: 0x36393f, - components: [ - { - type: 10, - content: "✅ **Demostración Finalizada**" - }, - { - type: 14, - divider: true, - spacing: 1 - }, - { - type: 10, - content: "Gracias por probar DisplayComponents con accesorios!\n\n💡 **Recuerda:** Los accesorios son ideales para:\n• Botones de acción rápida\n• Thumbnails e íconos\n• Enlaces externos\n• Elementos decorativos" - } - ] - }; - - await interaction.update({ - components: [closedPanel] - }); + await handleCloseDemo(interaction); collector.stop(); break; + + default: + await handleStyleButtons(interaction); + break; } - }); + } catch (error) { + console.error("Error handling demo interaction:", error); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + content: "❌ Ocurrió un error al procesar la interacción.", + ephemeral: true + }); + } + } + }); - collector.on("end", async (collected: any, reason: string) => { - if (reason === "time") { - try { - const timeoutPanel = { - type: 17, - accent_color: 0x36393f, - components: [ - { - type: 10, - content: "⏰ **Demostración Expirada**" - }, - { - type: 14, - divider: true, - spacing: 1 - }, - { - type: 10, - content: "La demostración ha expirado por inactividad.\nUsa `!displaydemo` nuevamente para verla." - } - ] - }; + collector.on("end", async (collected, reason) => { + if (reason === "time") { + await handleDemoTimeout(demoMessage); + } + }); +} - await demoMessage.edit({ - components: [timeoutPanel] - }); - } catch (error) { - // Mensaje eliminado o error de edición +async function handleMoreExamples(interaction: ButtonInteraction, originalMessage: Message): Promise { + const examplesPanel: DisplayComponentContainer = { + type: 17, + accent_color: 0xff9500, + components: [ + { + type: 10, + content: "🎯 **Más Ejemplos de Accesorios**" + }, + { + type: 14, + divider: true, + spacing: 2 + }, + // Example with user avatar + { + type: 9, + components: [ + { + type: 10, + content: `👤 **Perfil de ${originalMessage.author.username}**\n\nEjemplo usando tu avatar como thumbnail accesorio.` + } + ], + accessory: { + type: 11, + media: { + url: originalMessage.author.displayAvatarURL({ forceStatic: false }) + } + } + }, + { + type: 14, + divider: false, + spacing: 1 + }, + // Example with different button style + { + type: 9, + components: [ + { + type: 10, + content: "🎨 **Botones con Diferentes Estilos**\n\nLos accesorios pueden tener distintos estilos y emojis personalizados." + } + ], + accessory: { + type: 2, + style: ButtonStyle.Danger, + label: "Peligro", + custom_id: "danger_button", + emoji: { name: "⚠️" } + } + }, + { + type: 14, + divider: false, + spacing: 1 + }, + // Custom image as accessory + { + type: 9, + components: [ + { + type: 10, + content: "🖼️ **Imágenes Personalizadas**\n\nTambién puedes usar imágenes personalizadas, íconos de servidores invitados, etc." + } + ], + accessory: { + type: 11, + media: { + url: "https://cdn.discordapp.com/attachments/1234/5678/discord-logo.png" + } } } + ] + }; + + const backRow: ActionRowBuilder = { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "↩️ Volver", + custom_id: "back_to_main" + } + ] + }; + + await interaction.update({ + components: [examplesPanel, backRow] + }); +} + +async function handleStylesDemo(interaction: ButtonInteraction): Promise { + const stylesPanel: DisplayComponentContainer = { + type: 17, + accent_color: 0x57f287, + components: [ + { + type: 10, + content: "🎨 **Galería de Estilos**" + }, + { + type: 14, + divider: true, + spacing: 2 + }, + // Primary button as accessory + { + type: 9, + components: [ + { + type: 10, + content: "🔵 **Botón Primary (Azul)**\nEstilo: 1 - Para acciones principales" + } + ], + accessory: { + type: 2, + style: ButtonStyle.Primary, + label: "Principal", + custom_id: "style_primary" + } + }, + // Secondary button as accessory + { + type: 9, + components: [ + { + type: 10, + content: "⚫ **Botón Secondary (Gris)**\nEstilo: 2 - Para acciones secundarias" + } + ], + accessory: { + type: 2, + style: ButtonStyle.Secondary, + label: "Secundario", + custom_id: "style_secondary" + } + }, + // Success button as accessory + { + type: 9, + components: [ + { + type: 10, + content: "🟢 **Botón Success (Verde)**\nEstilo: 3 - Para confirmar acciones" + } + ], + accessory: { + type: 2, + style: ButtonStyle.Success, + label: "Confirmar", + custom_id: "style_success" + } + }, + // Danger button as accessory + { + type: 9, + components: [ + { + type: 10, + content: "🔴 **Botón Danger (Rojo)**\nEstilo: 4 - Para acciones destructivas" + } + ], + accessory: { + type: 2, + style: ButtonStyle.Danger, + label: "Eliminar", + custom_id: "style_danger" + } + } + ] + }; + + const backRow: ActionRowBuilder = { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "↩️ Volver", + custom_id: "back_to_main" + } + ] + }; + + await interaction.update({ + components: [stylesPanel, backRow] + }); +} + +async function handleStyleButtons(interaction: ButtonInteraction): Promise { + const styleMap: Record = { + "danger_button": "Peligro", + "style_primary": "Primary", + "style_secondary": "Secondary", + "style_success": "Success", + "style_danger": "Danger" + }; + + const styleName = styleMap[interaction.customId]; + if (styleName) { + await interaction.reply({ + content: `🎯 **Botón ${styleName} activado!**\n\nEste botón era un accesorio de una sección.`, + ephemeral: true }); - }, -}; + } +} + +async function handleCloseDemo(interaction: ButtonInteraction): Promise { + const closedPanel: DisplayComponentContainer = { + type: 17, + accent_color: 0x36393f, + components: [ + { + type: 10, + content: "✅ **Demostración Finalizada**" + }, + { + type: 14, + divider: true, + spacing: 1 + }, + { + type: 10, + content: "Gracias por probar DisplayComponents con accesorios!\n\n💡 **Recuerda:** Los accesorios son ideales para:\n• Botones de acción rápida\n• Thumbnails e íconos\n• Enlaces externos\n• Elementos decorativos" + } + ] + }; + + await interaction.update({ + components: [closedPanel] + }); +} + +async function handleDemoTimeout(demoMessage: Message): Promise { + try { + const timeoutPanel: DisplayComponentContainer = { + type: 17, + accent_color: 0x36393f, + components: [ + { + type: 10, + content: "⏰ **Demostración Expirada**" + }, + { + type: 14, + divider: true, + spacing: 1 + }, + { + type: 10, + content: "La demostración ha expirado por inactividad.\nUsa `!displaydemo` nuevamente para verla." + } + ] + }; + + await demoMessage.edit({ + components: [timeoutPanel] + }); + } catch (error) { + // Message was deleted or other edit error - ignore + console.log("Could not edit demo message on timeout, likely deleted"); + } +} diff --git a/src/core/types/displayComponentEditor.ts b/src/core/types/displayComponentEditor.ts new file mode 100644 index 0000000..7f602c5 --- /dev/null +++ b/src/core/types/displayComponentEditor.ts @@ -0,0 +1,408 @@ +import { + ButtonStyle, + ComponentType, + APIButtonComponent, + APISelectMenuComponent, + GuildMember, + Guild +} from 'discord.js'; +import type { + DisplayComponent, + DisplayComponentContainer, + BlockConfig +} from './displayComponents'; +import { replaceVars, isValidUrlOrVariable } from '../lib/vars'; + +// Editor-specific component types (how we store configuration while editing) +export interface EditorTextDisplay { + type: 10; // TextDisplay + content: string; + thumbnail?: string | null; // optional image URL + linkButton?: LinkButton | null; // optional link button accessory +} + +export interface EditorSeparator { + type: 14; // Separator + divider?: boolean; + spacing?: number; // 1-3 typical +} + +export interface EditorImage { + type: 12; // Image/Media + url: string; // single image URL (later rendered as items: [{ media: { url } }]) +} + +export type EditorComponent = EditorTextDisplay | EditorSeparator | EditorImage; + +// Block state for editing/creating +export interface BlockState { + title?: string; + description?: string; + color?: number; + coverImage?: string; + components: EditorComponent[]; +} + +// Emoji input types +export interface CustomEmoji { + id: string; + name: string; + animated?: boolean; +} + +export interface UnicodeEmoji { + name: string; +} + +export type EmojiInput = CustomEmoji | UnicodeEmoji; + +// Link button configuration +export interface LinkButton { + url: string; + label?: string; + emoji?: string; +} + +// Action row builders +export interface EditorActionRow { + type: ComponentType.ActionRow; + components: APIButtonComponent[]; +} + +export interface SelectActionRow { + type: ComponentType.ActionRow; + components: APISelectMenuComponent[]; +} + +// Utility functions +export class DisplayComponentUtils { + /** + * Validates if a URL is valid or a system variable + */ + static isValidUrl(url: string): boolean { + return isValidUrlOrVariable(url); + } + + /** + * Validates and cleans content for Discord + */ + static validateContent(content: string): string { + if (!content || typeof content !== 'string') { + return "Sin contenido"; + } + + const cleaned = content.trim(); + if (cleaned.length === 0) { + return "Sin contenido"; + } + + // Truncate if exceeds Discord limit (4000 characters) + if (cleaned.length > 4000) { + return cleaned.substring(0, 3997) + "..."; + } + + return cleaned; + } + + /** + * Parses emoji input (unicode or custom / <:name:id>) + */ + static parseEmojiInput(input?: string): EmojiInput | null { + if (!input) return null; + + const trimmed = input.trim(); + if (!trimmed) return null; + + const match = trimmed.match(/^<(a?):(\w+):(\d+)>$/); + if (match) { + const animated = match[1] === 'a'; + const name = match[2]; + const id = match[3]; + return { id, name, animated }; + } + + // Assume unicode if not custom emoji format + return { name: trimmed }; + } + + /** + * Builds a link accessory for Display Components + */ + static async buildLinkAccessory( + link: LinkButton, + member: GuildMember, + guild: Guild + ): Promise { + if (!link || !link.url) return null; + + const processedUrl = await replaceVars(link.url, member, guild); + if (!this.isValidUrl(processedUrl)) return null; + + const accessory: any = { + type: 2, // Button + style: ButtonStyle.Link, + url: processedUrl + }; + + if (link.label && typeof link.label === 'string' && link.label.trim().length > 0) { + accessory.label = link.label.trim().slice(0, 80); + } + + if (link.emoji && typeof link.emoji === 'string') { + const parsed = this.parseEmojiInput(link.emoji); + if (parsed) accessory.emoji = parsed; + } + + // Must have at least label or emoji + if (!accessory.label && !accessory.emoji) { + return null; + } + + return accessory; + } + + /** + * Renders preview of a block + */ + static async renderPreview( + blockState: BlockState, + member: GuildMember, + guild: Guild + ): Promise { + const previewComponents: DisplayComponent[] = []; + + // Add cover image first if exists + if (blockState.coverImage && this.isValidUrl(blockState.coverImage)) { + const processedCoverUrl = await replaceVars(blockState.coverImage, member, guild); + if (this.isValidUrl(processedCoverUrl)) { + previewComponents.push({ + type: 12, + items: [{ media: { url: processedCoverUrl } }] + } as any); + } + } + + // Add title after cover - VALIDATE CONTENT + const processedTitle = await replaceVars(blockState.title ?? "Sin título", member, guild); + previewComponents.push({ + type: 10, + content: this.validateContent(processedTitle) + } as any); + + // Process components in order + for (const c of blockState.components) { + if (c.type === 10) { + const processedThumbnail = c.thumbnail ? + await replaceVars(c.thumbnail, member, guild) : null; + const processedContent = await replaceVars(c.content || "Sin contenido", member, guild); + const validatedContent = this.validateContent(processedContent); + + // Build accessory by priority: linkButton > thumbnail + let accessory: any = null; + if (c.linkButton) { + const built = await this.buildLinkAccessory(c.linkButton, member, guild); + if (built) accessory = built; + } + if (!accessory && processedThumbnail && this.isValidUrl(processedThumbnail)) { + accessory = { + type: 11, + media: { url: processedThumbnail } + }; + } + + if (accessory) { + previewComponents.push({ + type: 9, + components: [{ + type: 10, + content: validatedContent + } as any], + accessory + } as any); + } else { + // No valid accessory + previewComponents.push({ + type: 10, + content: validatedContent + } as any); + } + } else if (c.type === 14) { + // Separator + previewComponents.push({ + type: 14, + divider: c.divider ?? true, + spacing: c.spacing ?? 1 + } as any); + } else if (c.type === 12) { + // Image + const processedImageUrl = await replaceVars(c.url, member, guild); + if (this.isValidUrl(processedImageUrl)) { + previewComponents.push({ + type: 12, + items: [{ media: { url: processedImageUrl } }] + } as any); + } + } + } + + return { + type: 17, + accent_color: blockState.color || 0x5865f2, + components: previewComponents + }; + } + + /** + * Creates editor button rows + */ + static createEditorButtons(disabled = false): EditorActionRow[] { + return [ + { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "📝 Título", + disabled, + custom_id: "edit_title" + }, + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "📄 Descripción", + disabled, + custom_id: "edit_description" + }, + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "🎨 Color", + disabled, + custom_id: "edit_color" + }, + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "➕ Contenido", + disabled, + custom_id: "add_content" + }, + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "➖ Separador", + disabled, + custom_id: "add_separator" + } + ] + }, + { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "🖼️ Imagen", + disabled, + custom_id: "add_image" + }, + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "🖼️ Portada", + disabled, + custom_id: "cover_image" + }, + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "📎 Thumbnail", + disabled, + custom_id: "edit_thumbnail" + }, + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "🔗 Crear Botón Link", + disabled, + custom_id: "edit_link_button" + }, + { + type: ComponentType.Button, + style: ButtonStyle.Primary, + label: "🔄 Mover", + disabled, + custom_id: "move_block" + } + ] + }, + { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "🎯 Variables", + disabled, + custom_id: "show_variables" + }, + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "📋 Duplicar", + disabled, + custom_id: "duplicate_block" + }, + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "📊 Vista Raw", + disabled, + custom_id: "show_raw" + }, + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "📥 Importar", + disabled, + custom_id: "import_json" + }, + { + type: ComponentType.Button, + style: ButtonStyle.Secondary, + label: "📤 Exportar", + disabled, + custom_id: "export_json" + } + ] + }, + { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + style: ButtonStyle.Success, + label: "💾 Guardar", + disabled, + custom_id: "save_block" + }, + { + type: ComponentType.Button, + style: ButtonStyle.Danger, + label: "❌ Cancelar", + disabled, + custom_id: "cancel_block" + }, + { + type: ComponentType.Button, + style: ButtonStyle.Danger, + label: "🗑️ Eliminar", + disabled, + custom_id: "delete_block" + } + ] + } + ]; + } +} diff --git a/src/core/types/displayComponents.ts b/src/core/types/displayComponents.ts new file mode 100644 index 0000000..7999da0 --- /dev/null +++ b/src/core/types/displayComponents.ts @@ -0,0 +1,101 @@ +import type { + ButtonStyle, + APIEmbed, + ComponentType +} from 'discord.js'; + +// Display Components V2 Types +export interface DisplayComponentContainer { + type: 17; // Container type + accent_color?: number; + components: DisplayComponent[]; +} + +export interface DisplayComponentSection { + type: 9; // Section type + components: DisplayComponent[]; + accessory?: DisplayComponentAccessory; +} + +export interface DisplayComponentText { + type: 10; // TextDisplay type + content: string; +} + +export interface DisplayComponentSeparator { + type: 14; // Separator type + divider?: boolean; + spacing?: number; +} + +export interface DisplayComponentThumbnail { + type: 11; // Thumbnail type + media: { + url: string; + }; +} + +export interface DisplayComponentButton { + type: 2; // Button type + style: ButtonStyle; + label: string; + custom_id?: string; + url?: string; + emoji?: { + name: string; + id?: string; + }; + disabled?: boolean; +} + +export type DisplayComponent = + | DisplayComponentContainer + | DisplayComponentSection + | DisplayComponentText + | DisplayComponentSeparator + | DisplayComponentThumbnail; + +export type DisplayComponentAccessory = + | DisplayComponentButton + | DisplayComponentThumbnail; + +// Block configuration types - compatible with Prisma JsonValue +export interface BlockConfig { + components?: any[]; // Use any[] to be compatible with JsonValue + coverImage?: string; + version?: string; +} + +export interface Block { + id: string; + name: string; + guildId: string; + config: BlockConfig; + createdAt?: Date; + updatedAt?: Date; +} + +// Pagination helpers +export interface PaginationData { + items: T[]; + currentPage: number; + totalPages: number; + itemsPerPage: number; +} + +export interface PaginationControls { + hasPrevious: boolean; + hasNext: boolean; + currentPage: number; + totalPages: number; +} + +// Component constants for runtime use +export const COMPONENT_TYPES = { + CONTAINER: 17, + SECTION: 9, + TEXT_DISPLAY: 10, + SEPARATOR: 14, + THUMBNAIL: 11, + BUTTON: 2 +} as const;