'use client'; import { ComponentActionTypeSchema, ComponentButtonStyleSchema, emptyComponentsV2, MessageComponentsV2Schema, parseMessageComponentsV2, type ComponentAction, type ComponentActionRow, type ComponentActionType, type ComponentButton, type ComponentButtonStyle, type ComponentSelect, type ComponentStringSelect, type ComponentV2Node, type MessageComponentsV2, type StringSelectOption } from '@nexumi/shared'; import { ChevronDown, ChevronUp, Plus, Trash2 } from 'lucide-react'; import { useMemo } from 'react'; import { useTranslations } from '@/components/locale-provider'; import { Button } from '@/components/ui/button'; import { DiscordRoleSelect } from '@/components/ui/discord-role-select'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { PlaceholderHelp } from '@/components/ui/placeholder-help'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import type { PlaceholderPresetId } from '@/lib/placeholder-presets'; import { cn } from '@/lib/utils'; export { emptyComponentsV2 }; const ACTION_TYPES = ComponentActionTypeSchema.options; const BUTTON_STYLES = ComponentButtonStyleSchema.options; const SELECT_TYPES = [ 'string_select', 'user_select', 'role_select', 'channel_select', 'mentionable_select' ] as const; type SelectType = (typeof SELECT_TYPES)[number]; type BlockType = ComponentV2Node['type']; function newActionId(prefix = 'action'): string { return `${prefix}_${Math.random().toString(36).slice(2, 10)}`; } function applyPreviewVars(template: string, vars: Record): string { const withVars = Object.entries(vars).reduce( (acc, [key, value]) => acc.replaceAll(`{${key}}`, value), template ); return withVars.replace(/\["(\d{17,20})"\]/g, '#$1').replace(/<#(\d{17,20})>/g, '#$1'); } function previewUrl(value: string | undefined, vars: Record): string | undefined { if (!value?.trim()) { return undefined; } const rendered = applyPreviewVars(value, vars).trim(); if (!/^https?:\/\//i.test(rendered)) { return undefined; } return rendered; } export function normalizeComponentsV2( value: MessageComponentsV2 | null | undefined ): MessageComponentsV2 | null { if (!value) { return null; } const parsed = MessageComponentsV2Schema.safeParse(value); return parsed.success ? parsed.data : null; } export function componentsV2FromUnknown(raw: unknown): MessageComponentsV2 | null { return parseMessageComponentsV2(raw); } function defaultBlock(type: BlockType): ComponentV2Node { switch (type) { case 'container': return { type: 'container', components: [{ type: 'text_display', content: 'Hello from Nexumi!' }] }; case 'text_display': return { type: 'text_display', content: 'Text content' }; case 'separator': return { type: 'separator', divider: true, spacing: 'Small' }; case 'section': return { type: 'section', text: 'Section text', accessory: { type: 'thumbnail', url: 'https://cdn.discordapp.com/embed/avatars/0.png' } }; case 'media_gallery': return { type: 'media_gallery', items: [{ url: 'https://cdn.discordapp.com/embed/avatars/0.png' }] }; case 'action_row': return { type: 'action_row', components: [{ type: 'button', style: 'Secondary', label: 'Button' }] }; default: return { type: 'text_display', content: 'Text content' }; } } function defaultSelect(type: SelectType): ComponentSelect { switch (type) { case 'string_select': return { type: 'string_select', placeholder: 'Choose an option', options: [{ label: 'Option 1', value: 'opt1' }] }; case 'user_select': return { type: 'user_select', actionId: newActionId('user'), placeholder: 'Select a user' }; case 'role_select': return { type: 'role_select', actionId: newActionId('role'), placeholder: 'Select roles', maxValues: 5 }; case 'channel_select': return { type: 'channel_select', actionId: newActionId('channel'), placeholder: 'Select a channel' }; case 'mentionable_select': return { type: 'mentionable_select', actionId: newActionId('mentionable'), placeholder: 'Select a user or role' }; } } function defaultAction(type: ComponentActionType): ComponentAction { switch (type) { case 'EPHEMERAL_REPLY': case 'PUBLIC_REPLY': return { type, content: 'Thanks for clicking!' }; case 'ASSIGN_ROLE': case 'REMOVE_ROLE': case 'TOGGLE_ROLE': return { type, roleId: '' }; case 'ASSIGN_SELECTED_ROLES': return { type }; case 'NONE': return { type }; } } function ensureAction( actions: MessageComponentsV2['actions'], actionId: string, fallback: ComponentAction = { type: 'NONE' } ): MessageComponentsV2['actions'] { if (actions[actionId]) { return actions; } return { ...actions, [actionId]: fallback }; } interface DiscordComponentsV2BuilderProps { value: MessageComponentsV2 | null; onChange: (next: MessageComponentsV2 | null) => void; guildId?: string; className?: string; previewVars?: Record; showClearHint?: boolean; placeholderPreset?: PlaceholderPresetId; } export function DiscordComponentsV2Builder({ value, onChange, guildId, className, previewVars, showClearHint = true, placeholderPreset = 'welcome' }: DiscordComponentsV2BuilderProps) { const t = useTranslations(); const draft = value ?? emptyComponentsV2(); const vars = previewVars ?? {}; function patch(next: Partial) { onChange({ ...draft, ...next }); } function patchComponents(components: ComponentV2Node[]) { patch({ components }); } function patchActions(actions: MessageComponentsV2['actions']) { patch({ actions }); } function addBlock(type: BlockType) { patchComponents([...draft.components, defaultBlock(type)]); } function updateBlock(index: number, node: ComponentV2Node) { patchComponents(draft.components.map((entry, i) => (i === index ? node : entry))); } function removeBlock(index: number) { patchComponents(draft.components.filter((_, i) => i !== index)); } function moveBlock(index: number, direction: -1 | 1) { const next = [...draft.components]; const target = index + direction; if (target < 0 || target >= next.length) { return; } const current = next[index]; const swap = next[target]; if (!current || !swap) { return; } next[index] = swap; next[target] = current; patchComponents(next); } const referencedActionIds = useMemo(() => { const ids = new Set(); const walk = (nodes: ComponentV2Node[]) => { for (const node of nodes) { if (node.type === 'button' && node.actionId) { ids.add(node.actionId); } else if (node.type === 'string_select') { if (node.actionId) { ids.add(node.actionId); } for (const option of node.options) { if (option.actionId) { ids.add(option.actionId); } } } else if ( (node.type === 'user_select' || node.type === 'role_select' || node.type === 'channel_select' || node.type === 'mentionable_select') && node.actionId ) { ids.add(node.actionId); } else if (node.type === 'action_row') { walk(node.components as ComponentV2Node[]); } else if (node.type === 'section' && node.accessory.type === 'button' && node.accessory.actionId) { ids.add(node.accessory.actionId); } else if (node.type === 'container') { walk(node.components); } } }; walk(draft.components); return [...ids].sort(); }, [draft.components]); return (
{draft.components.length === 0 ? (

{t('componentsV2Builder.empty')}

) : (
{draft.components.map((node, index) => ( updateBlock(index, next)} onRemove={() => removeBlock(index)} onMoveUp={() => moveBlock(index, -1)} onMoveDown={() => moveBlock(index, 1)} canMoveUp={index > 0} canMoveDown={index < draft.components.length - 1} actions={draft.actions} onActionsChange={patchActions} t={t} /> ))}
)} {referencedActionIds.length > 0 ? (

{t('componentsV2Builder.actionsTitle')}

{referencedActionIds.map((actionId) => ( patchActions({ ...draft.actions, [actionId]: next })} t={t} /> ))}
) : null} {showClearHint ? (

{t('componentsV2Builder.clearHint')}

) : null}
); } interface BlockEditorProps { node: ComponentV2Node; depth: number; guildId?: string; onChange: (next: ComponentV2Node) => void; onRemove?: () => void; onMoveUp?: () => void; onMoveDown?: () => void; canMoveUp?: boolean; canMoveDown?: boolean; actions: MessageComponentsV2['actions']; onActionsChange: (next: MessageComponentsV2['actions']) => void; t: (key: string) => string; } function BlockEditor({ node, depth, guildId, onChange, onRemove, onMoveUp, onMoveDown, canMoveUp, canMoveDown, actions, onActionsChange, t }: BlockEditorProps) { const header = (

{t(`componentsV2Builder.blockTypes.${node.type}`)}

{onMoveUp ? ( ) : null} {onMoveDown ? ( ) : null} {onRemove ? ( ) : null}
); return (
0 && 'ml-3 border-dashed')}> {header}
); } function BlockFields({ node, depth, guildId, onChange, actions, onActionsChange, t }: Omit) { switch (node.type) { case 'text_display': return (