Files
Nexumi/apps/webui/src/components/ui/discord-components-v2-builder.tsx
TheOnlyMace 4e135bcf43 Enhance component message handling and introduce new features
- Added `ComponentMessageBinding` model to manage message components in the database.
- Updated `WelcomeConfig`, `Tag`, and `ScheduledMessage` models to support new component fields.
- Integrated component handling in various modules, including Scheduler and Tags, to improve message customization.
- Enhanced user experience by implementing new commands for creating and managing component messages in the utility module.
- Updated localization files to reflect new component-related features and improve user guidance.
2026-07-22 22:55:37 +02:00

1301 lines
41 KiB
TypeScript

'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, string>): 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, string>): 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<string, string>;
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<MessageComponentsV2>) {
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<string>();
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 (
<div className={cn('grid gap-4 lg:grid-cols-2', className)}>
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Label>{t('componentsV2Builder.blocks')}</Label>
<Select onValueChange={(type) => addBlock(type as BlockType)}>
<SelectTrigger className="h-8 w-auto min-w-[10rem]">
<SelectValue placeholder={t('componentsV2Builder.addBlock')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="container">{t('componentsV2Builder.blockTypes.container')}</SelectItem>
<SelectItem value="text_display">{t('componentsV2Builder.blockTypes.text_display')}</SelectItem>
<SelectItem value="separator">{t('componentsV2Builder.blockTypes.separator')}</SelectItem>
<SelectItem value="section">{t('componentsV2Builder.blockTypes.section')}</SelectItem>
<SelectItem value="media_gallery">{t('componentsV2Builder.blockTypes.media_gallery')}</SelectItem>
<SelectItem value="action_row">{t('componentsV2Builder.blockTypes.action_row')}</SelectItem>
</SelectContent>
</Select>
</div>
{draft.components.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('componentsV2Builder.empty')}</p>
) : (
<div className="space-y-3">
{draft.components.map((node, index) => (
<BlockEditor
key={`${node.type}-${index}`}
guildId={guildId}
node={node}
depth={0}
onChange={(next) => 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}
/>
))}
</div>
)}
{referencedActionIds.length > 0 ? (
<div className="space-y-3 rounded-md border border-border p-3">
<p className="text-xs font-medium text-foreground">{t('componentsV2Builder.actionsTitle')}</p>
{referencedActionIds.map((actionId) => (
<ActionEditor
key={actionId}
guildId={guildId}
actionId={actionId}
action={draft.actions[actionId] ?? { type: 'NONE' }}
onChange={(next) => patchActions({ ...draft.actions, [actionId]: next })}
t={t}
/>
))}
</div>
) : null}
{showClearHint ? (
<p className="text-xs text-muted-foreground">{t('componentsV2Builder.clearHint')}</p>
) : null}
<PlaceholderHelp preset={placeholderPreset} />
<button
type="button"
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
onClick={() => onChange(null)}
>
{t('componentsV2Builder.clear')}
</button>
</div>
<div className="space-y-2">
<Label>{t('componentsV2Builder.preview')}</Label>
<ComponentsV2Preview payload={draft} vars={vars} t={t} />
</div>
</div>
);
}
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 = (
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs font-medium text-foreground">
{t(`componentsV2Builder.blockTypes.${node.type}`)}
</p>
<div className="flex items-center gap-1">
{onMoveUp ? (
<Button type="button" variant="ghost" size="icon" className="size-7" disabled={!canMoveUp} onClick={onMoveUp}>
<ChevronUp className="size-4" />
</Button>
) : null}
{onMoveDown ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
disabled={!canMoveDown}
onClick={onMoveDown}
>
<ChevronDown className="size-4" />
</Button>
) : null}
{onRemove ? (
<Button type="button" variant="ghost" size="icon" className="size-7" onClick={onRemove}>
<Trash2 className="size-4" />
</Button>
) : null}
</div>
</div>
);
return (
<div className={cn('space-y-3 rounded-md border border-border p-3', depth > 0 && 'ml-3 border-dashed')}>
{header}
<BlockFields
node={node}
depth={depth}
guildId={guildId}
onChange={onChange}
actions={actions}
onActionsChange={onActionsChange}
t={t}
/>
</div>
);
}
function BlockFields({
node,
depth,
guildId,
onChange,
actions,
onActionsChange,
t
}: Omit<BlockEditorProps, 'onRemove' | 'onMoveUp' | 'onMoveDown' | 'canMoveUp' | 'canMoveDown'>) {
switch (node.type) {
case 'text_display':
return (
<Textarea
rows={3}
maxLength={4000}
value={node.content}
placeholder={t('componentsV2Builder.textPlaceholder')}
onChange={(event) => onChange({ ...node, content: event.target.value })}
/>
);
case 'separator':
return (
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
<Label>{t('componentsV2Builder.divider')}</Label>
<Switch
checked={node.divider !== false}
onCheckedChange={(divider) => onChange({ ...node, divider })}
/>
</div>
<div className="space-y-2">
<Label>{t('componentsV2Builder.spacing')}</Label>
<Select
value={node.spacing ?? 'Small'}
onValueChange={(spacing) => onChange({ ...node, spacing: spacing as 'Small' | 'Large' })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Small">{t('componentsV2Builder.spacingSmall')}</SelectItem>
<SelectItem value="Large">{t('componentsV2Builder.spacingLarge')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
case 'container':
return (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('componentsV2Builder.accentColor')}</Label>
<Input
type="number"
min={0}
max={16777215}
value={node.accentColor ?? ''}
placeholder={t('common.optional')}
onChange={(event) => {
const parsed = Number.parseInt(event.target.value, 10);
onChange({
...node,
accentColor: Number.isFinite(parsed) ? parsed : undefined
});
}}
/>
</div>
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
<Label>{t('componentsV2Builder.spoiler')}</Label>
<Switch checked={Boolean(node.spoiler)} onCheckedChange={(spoiler) => onChange({ ...node, spoiler })} />
</div>
</div>
<NestedBlocksEditor
components={node.components}
depth={depth + 1}
guildId={guildId}
onChange={(components) => onChange({ ...node, components })}
actions={actions}
onActionsChange={onActionsChange}
t={t}
/>
</div>
);
case 'section':
return (
<div className="space-y-3">
<div className="space-y-2">
<Label>{t('componentsV2Builder.sectionText')}</Label>
<Textarea
rows={3}
maxLength={4000}
value={Array.isArray(node.text) ? node.text.join('\n---\n') : node.text}
onChange={(event) => onChange({ ...node, text: event.target.value })}
/>
</div>
<div className="space-y-2">
<Label>{t('componentsV2Builder.accessoryType')}</Label>
<Select
value={node.accessory.type}
onValueChange={(type) => {
if (type === 'thumbnail') {
onChange({
...node,
accessory: { type: 'thumbnail', url: 'https://cdn.discordapp.com/embed/avatars/0.png' }
});
} else {
onChange({
...node,
accessory: { type: 'button', style: 'Secondary', label: 'Button' }
});
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="thumbnail">{t('componentsV2Builder.accessoryThumbnail')}</SelectItem>
<SelectItem value="button">{t('componentsV2Builder.accessoryButton')}</SelectItem>
</SelectContent>
</Select>
</div>
{node.accessory.type === 'thumbnail' ? (
<Input
value={node.accessory.url}
placeholder={t('componentsV2Builder.urlPlaceholder')}
onChange={(event) =>
onChange({
...node,
accessory: { ...node.accessory, url: event.target.value }
})
}
/>
) : (
<ButtonEditor
guildId={guildId}
button={node.accessory}
onChange={(accessory) => onChange({ ...node, accessory })}
actions={actions}
onActionsChange={onActionsChange}
t={t}
/>
)}
</div>
);
case 'media_gallery':
return (
<div className="space-y-2">
{node.items.map((item, index) => (
<div key={index} className="flex gap-2">
<Input
className="flex-1"
value={item.url}
placeholder={t('componentsV2Builder.urlPlaceholder')}
onChange={(event) => {
const items = node.items.map((entry, i) =>
i === index ? { ...entry, url: event.target.value } : entry
);
onChange({ ...node, items });
}}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onChange({ ...node, items: node.items.filter((_, i) => i !== index) })}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
{node.items.length < 10 ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
onChange({
...node,
items: [...node.items, { url: 'https://cdn.discordapp.com/embed/avatars/0.png' }]
})
}
>
<Plus className="size-4" />
{t('componentsV2Builder.addMediaItem')}
</Button>
) : null}
</div>
);
case 'action_row':
return (
<ActionRowEditor
row={node}
guildId={guildId}
onChange={onChange}
actions={actions}
onActionsChange={onActionsChange}
t={t}
/>
);
default:
return null;
}
}
function NestedBlocksEditor({
components,
depth,
guildId,
onChange,
actions,
onActionsChange,
t
}: {
components: ComponentV2Node[];
depth: number;
guildId?: string;
onChange: (next: ComponentV2Node[]) => void;
actions: MessageComponentsV2['actions'];
onActionsChange: (next: MessageComponentsV2['actions']) => void;
t: (key: string) => string;
}) {
function addNested(type: BlockType) {
onChange([...components, defaultBlock(type)]);
}
return (
<div className="space-y-2">
<Select onValueChange={(type) => addNested(type as BlockType)}>
<SelectTrigger className="h-8">
<SelectValue placeholder={t('componentsV2Builder.addNestedBlock')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="text_display">{t('componentsV2Builder.blockTypes.text_display')}</SelectItem>
<SelectItem value="separator">{t('componentsV2Builder.blockTypes.separator')}</SelectItem>
<SelectItem value="section">{t('componentsV2Builder.blockTypes.section')}</SelectItem>
<SelectItem value="media_gallery">{t('componentsV2Builder.blockTypes.media_gallery')}</SelectItem>
</SelectContent>
</Select>
{components.map((child, index) => (
<BlockEditor
key={`nested-${depth}-${index}`}
node={child}
depth={depth}
guildId={guildId}
onChange={(next) => onChange(components.map((entry, i) => (i === index ? next : entry)))}
onRemove={() => onChange(components.filter((_, i) => i !== index))}
onMoveUp={() => {
if (index === 0) return;
const next = [...components];
const current = next[index];
const prev = next[index - 1];
if (!current || !prev) return;
next[index - 1] = current;
next[index] = prev;
onChange(next);
}}
onMoveDown={() => {
if (index >= components.length - 1) return;
const next = [...components];
const current = next[index];
const following = next[index + 1];
if (!current || !following) return;
next[index] = following;
next[index + 1] = current;
onChange(next);
}}
canMoveUp={index > 0}
canMoveDown={index < components.length - 1}
actions={actions}
onActionsChange={onActionsChange}
t={t}
/>
))}
</div>
);
}
function ActionRowEditor({
row,
guildId,
onChange,
actions,
onActionsChange,
t
}: {
row: ComponentActionRow;
guildId?: string;
onChange: (next: ComponentActionRow) => void;
actions: MessageComponentsV2['actions'];
onActionsChange: (next: MessageComponentsV2['actions']) => void;
t: (key: string) => string;
}) {
return (
<div className="space-y-3">
{row.components.map((child, index) => (
<div key={index} className="space-y-2 rounded-md border border-dashed border-border p-2">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-medium uppercase text-muted-foreground">
{child.type === 'button'
? t('componentsV2Builder.blockTypes.button')
: t(`componentsV2Builder.selectTypes.${child.type}`)}
</p>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() =>
onChange({ ...row, components: row.components.filter((_, i) => i !== index) as typeof row.components })
}
>
<Trash2 className="size-4" />
</Button>
</div>
{child.type === 'button' ? (
<ButtonEditor
guildId={guildId}
button={child}
onChange={(button) => {
const components = row.components.map((entry, i) => (i === index ? button : entry)) as typeof row.components;
onChange({ ...row, components });
}}
actions={actions}
onActionsChange={onActionsChange}
t={t}
/>
) : (
<SelectEditor
guildId={guildId}
select={child}
onChange={(select) => {
const components = row.components.map((entry, i) => (i === index ? select : entry)) as typeof row.components;
onChange({ ...row, components });
}}
actions={actions}
onActionsChange={onActionsChange}
t={t}
/>
)}
</div>
))}
{row.components.length < 5 ? (
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
onChange({
...row,
components: [
...row.components,
{ type: 'button', style: 'Secondary', label: t('componentsV2Builder.defaultButtonLabel') }
] as typeof row.components
})
}
>
<Plus className="size-4" />
{t('componentsV2Builder.addButton')}
</Button>
<Select
onValueChange={(type) =>
onChange({
...row,
components: [...row.components, defaultSelect(type as SelectType)] as typeof row.components
})
}
>
<SelectTrigger className="h-8 w-auto min-w-[8rem]">
<SelectValue placeholder={t('componentsV2Builder.addSelect')} />
</SelectTrigger>
<SelectContent>
{SELECT_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{t(`componentsV2Builder.selectTypes.${type}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
</div>
);
}
function ButtonEditor({
button,
guildId,
onChange,
actions,
onActionsChange,
t
}: {
button: ComponentButton;
guildId?: string;
onChange: (next: ComponentButton) => void;
actions: MessageComponentsV2['actions'];
onActionsChange: (next: MessageComponentsV2['actions']) => void;
t: (key: string) => string;
}) {
const isLink = button.style === 'Link';
function assignActionId(actionId: string) {
onChange({ ...button, actionId });
onActionsChange(ensureAction(actions, actionId));
}
return (
<div className="space-y-2">
<div className="grid gap-2 sm:grid-cols-2">
<Input
value={button.label}
maxLength={80}
placeholder={t('componentsV2Builder.buttonLabel')}
onChange={(event) => onChange({ ...button, label: event.target.value })}
/>
<Select
value={button.style}
onValueChange={(style) =>
onChange({
...button,
style: style as ComponentButtonStyle,
url: style === 'Link' ? button.url ?? 'https://' : undefined,
actionId: style === 'Link' ? undefined : button.actionId ?? newActionId('btn')
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BUTTON_STYLES.map((style) => (
<SelectItem key={style} value={style}>
{t(`componentsV2Builder.buttonStyles.${style}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isLink ? (
<Input
value={button.url ?? ''}
placeholder={t('componentsV2Builder.linkUrl')}
onChange={(event) => onChange({ ...button, url: event.target.value })}
/>
) : (
<div className="grid gap-2 sm:grid-cols-2">
<Input
value={button.actionId ?? ''}
placeholder={t('componentsV2Builder.actionId')}
onChange={(event) => assignActionId(event.target.value.trim() || newActionId('btn'))}
/>
<Select
value={button.actionId ? actions[button.actionId]?.type ?? 'NONE' : 'NONE'}
onValueChange={(type) => {
const actionId = button.actionId ?? newActionId('btn');
assignActionId(actionId);
onActionsChange(
ensureAction(actions, actionId, defaultAction(type as ComponentActionType))
);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTION_TYPES.filter((type) => type !== 'ASSIGN_SELECTED_ROLES').map((type) => (
<SelectItem key={type} value={type}>
{t(`componentsV2Builder.actionTypes.${type}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{!isLink && button.actionId && guildId ? (
<ActionEditor
guildId={guildId}
actionId={button.actionId}
action={actions[button.actionId] ?? { type: 'NONE' }}
onChange={(next) => onActionsChange({ ...actions, [button.actionId!]: next })}
compact
t={t}
/>
) : null}
</div>
);
}
function SelectEditor({
select,
guildId,
onChange,
actions,
onActionsChange,
t
}: {
select: ComponentSelect;
guildId?: string;
onChange: (next: ComponentSelect) => void;
actions: MessageComponentsV2['actions'];
onActionsChange: (next: MessageComponentsV2['actions']) => void;
t: (key: string) => string;
}) {
if (select.type === 'string_select') {
return (
<StringSelectEditor
select={select}
onChange={onChange}
actions={actions}
onActionsChange={onActionsChange}
t={t}
/>
);
}
const actionId = select.actionId ?? '';
function patchSelect(next: ComponentSelect) {
onChange(next);
if ('actionId' in next && next.actionId) {
const defaultType = next.type === 'role_select' ? 'ASSIGN_SELECTED_ROLES' : 'NONE';
onActionsChange(ensureAction(actions, next.actionId, defaultAction(defaultType)));
}
}
return (
<div className="space-y-2">
<Input
value={select.placeholder ?? ''}
placeholder={t('componentsV2Builder.selectPlaceholder')}
onChange={(event) =>
patchSelect({
...select,
actionId: select.actionId ?? newActionId(select.type),
placeholder: event.target.value || undefined
})
}
/>
<Input
value={actionId}
placeholder={t('componentsV2Builder.actionId')}
onChange={(event) =>
patchSelect({ ...select, actionId: event.target.value.trim() || newActionId(select.type) })
}
onFocus={() => {
if (!select.actionId?.trim()) {
patchSelect({ ...select, actionId: newActionId(select.type) });
}
}}
/>
{guildId && select.actionId ? (
<ActionEditor
guildId={guildId}
actionId={select.actionId}
action={
actions[select.actionId] ??
defaultAction(select.type === 'role_select' ? 'ASSIGN_SELECTED_ROLES' : 'NONE')
}
onChange={(next) => onActionsChange({ ...actions, [select.actionId!]: next })}
compact
t={t}
/>
) : null}
</div>
);
}
function StringSelectEditor({
select,
onChange,
actions,
onActionsChange,
t
}: {
select: ComponentStringSelect;
onChange: (next: ComponentStringSelect) => void;
actions: MessageComponentsV2['actions'];
onActionsChange: (next: MessageComponentsV2['actions']) => void;
t: (key: string) => string;
}) {
function updateOption(index: number, patch: Partial<StringSelectOption>) {
onChange({
...select,
options: select.options.map((option, i) => (i === index ? { ...option, ...patch } : option))
});
}
return (
<div className="space-y-2">
<Input
value={select.placeholder ?? ''}
placeholder={t('componentsV2Builder.selectPlaceholder')}
onChange={(event) => onChange({ ...select, placeholder: event.target.value || undefined })}
/>
<Input
value={select.actionId ?? ''}
placeholder={t('componentsV2Builder.centralActionId')}
onChange={(event) => {
const actionId = event.target.value.trim() || undefined;
onChange({ ...select, actionId });
if (actionId) {
onActionsChange(ensureAction(actions, actionId));
}
}}
/>
{select.options.map((option, index) => (
<div key={index} className="grid gap-2 rounded-md border border-border p-2 sm:grid-cols-2">
<Input
value={option.label}
placeholder={t('componentsV2Builder.optionLabel')}
onChange={(event) => updateOption(index, { label: event.target.value })}
/>
<Input
value={option.value}
placeholder={t('componentsV2Builder.optionValue')}
onChange={(event) => updateOption(index, { value: event.target.value })}
/>
<Input
className="sm:col-span-2"
value={option.actionId ?? ''}
placeholder={t('componentsV2Builder.optionActionId')}
onChange={(event) => {
const actionId = event.target.value.trim() || undefined;
updateOption(index, { actionId });
if (actionId) {
onActionsChange(ensureAction(actions, actionId));
}
}}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="sm:col-span-2"
onClick={() => onChange({ ...select, options: select.options.filter((_, i) => i !== index) })}
>
<Trash2 className="size-4" />
{t('componentsV2Builder.removeOption')}
</Button>
</div>
))}
{select.options.length < 25 ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
onChange({
...select,
options: [...select.options, { label: `Option ${select.options.length + 1}`, value: `opt${select.options.length + 1}` }]
})
}
>
<Plus className="size-4" />
{t('componentsV2Builder.addOption')}
</Button>
) : null}
</div>
);
}
function ActionEditor({
actionId,
action,
onChange,
guildId,
compact = false,
t
}: {
actionId: string;
action: ComponentAction;
onChange: (next: ComponentAction) => void;
guildId?: string;
compact?: boolean;
t: (key: string) => string;
}) {
const needsContent = action.type === 'EPHEMERAL_REPLY' || action.type === 'PUBLIC_REPLY';
const needsRole =
action.type === 'ASSIGN_ROLE' || action.type === 'REMOVE_ROLE' || action.type === 'TOGGLE_ROLE';
return (
<div className={cn('space-y-2 rounded-md border border-border p-2', compact && 'border-dashed')}>
{!compact ? (
<p className="text-[11px] font-mono text-muted-foreground">{actionId}</p>
) : null}
<Select
value={action.type}
onValueChange={(type) => onChange(defaultAction(type as ComponentActionType))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTION_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{t(`componentsV2Builder.actionTypes.${type}`)}
</SelectItem>
))}
</SelectContent>
</Select>
{needsContent ? (
<Textarea
rows={2}
maxLength={2000}
value={action.content ?? ''}
placeholder={t('componentsV2Builder.replyContent')}
onChange={(event) => onChange({ ...action, content: event.target.value })}
/>
) : null}
{needsRole && guildId ? (
<DiscordRoleSelect
guildId={guildId}
value={action.roleId ?? ''}
onChange={(roleId) => onChange({ ...action, roleId: roleId || undefined })}
placeholder={t('componentsV2Builder.roleAction')}
/>
) : null}
</div>
);
}
function buttonStyleClass(style: ComponentButtonStyle): string {
switch (style) {
case 'Primary':
return 'bg-[#5865F2] text-white';
case 'Success':
return 'bg-[#248046] text-white';
case 'Danger':
return 'bg-[#DA373C] text-white';
case 'Link':
return 'bg-transparent text-[#00a8fc] underline';
default:
return 'bg-[#4E5058] text-white';
}
}
function ComponentsV2Preview({
payload,
vars,
t
}: {
payload: MessageComponentsV2;
vars: Record<string, string>;
t: (key: string) => string;
}) {
return (
<div className="rounded-md border border-[#1e1f22] bg-[#313338] p-3">
<div className="space-y-2">
{payload.components.map((node, index) => (
<PreviewNode key={index} node={node} vars={vars} t={t} />
))}
</div>
</div>
);
}
function PreviewNode({
node,
vars,
t
}: {
node: ComponentV2Node;
vars: Record<string, string>;
t: (key: string) => string;
}) {
switch (node.type) {
case 'text_display':
return (
<p className="whitespace-pre-wrap text-sm leading-5 text-[#dbdee1]">
{applyPreviewVars(node.content, vars) || t('componentsV2Builder.previewEmptyText')}
</p>
);
case 'separator':
return (
<div
className={cn(
'py-1',
node.divider !== false && 'border-t border-[#3f4147]',
node.spacing === 'Large' && 'py-3'
)}
/>
);
case 'container': {
const accent =
typeof node.accentColor === 'number'
? `#${Math.max(0, Math.min(0xffffff, node.accentColor)).toString(16).padStart(6, '0')}`
: undefined;
return (
<div
className="space-y-2 rounded-[4px] bg-[#2b2d31] p-3"
style={accent ? { borderLeft: `4px solid ${accent}` } : undefined}
>
{node.components.map((child, index) => (
<PreviewNode key={index} node={child} vars={vars} t={t} />
))}
</div>
);
}
case 'section': {
const text = Array.isArray(node.text)
? node.text.map((part) => applyPreviewVars(part, vars)).join('\n')
: applyPreviewVars(node.text, vars);
const thumb =
node.accessory.type === 'thumbnail' ? previewUrl(node.accessory.url, vars) : undefined;
return (
<div className="flex gap-3">
<p className="min-w-0 flex-1 whitespace-pre-wrap text-sm text-[#dbdee1]">{text}</p>
{node.accessory.type === 'button' ? (
<span
className={cn(
'inline-flex h-8 shrink-0 items-center rounded px-3 text-xs font-medium',
buttonStyleClass(node.accessory.style)
)}
>
{applyPreviewVars(node.accessory.label, vars)}
</span>
) : thumb ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={thumb} alt="" className="size-16 shrink-0 rounded object-cover" />
) : null}
</div>
);
}
case 'media_gallery':
return (
<div className="grid grid-cols-2 gap-2">
{node.items.map((item, index) => {
const url = previewUrl(item.url, vars);
return url ? (
// eslint-disable-next-line @next/next/no-img-element
<img key={index} src={url} alt="" className="aspect-video w-full rounded object-cover" />
) : (
<div
key={index}
className="flex aspect-video items-center justify-center rounded bg-[#2b2d31] text-xs text-[#949ba4]"
>
{t('componentsV2Builder.previewInvalidUrl')}
</div>
);
})}
</div>
);
case 'action_row':
return (
<div className="flex flex-wrap gap-2">
{node.components.map((child, index) =>
child.type === 'button' ? (
<span
key={index}
className={cn(
'inline-flex h-8 items-center rounded px-3 text-xs font-medium',
buttonStyleClass(child.style)
)}
>
{applyPreviewVars(child.label, vars)}
</span>
) : (
<span
key={index}
className="inline-flex h-8 min-w-[10rem] items-center rounded bg-[#4E5058] px-3 text-xs text-[#dbdee1]"
>
{child.placeholder ?? t(`componentsV2Builder.selectTypes.${child.type}`)}
</span>
)
)}
</div>
);
default:
return null;
}
}