- 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.
1301 lines
41 KiB
TypeScript
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;
|
|
}
|
|
}
|