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.
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
Gift,
|
||||
Hash,
|
||||
LayoutGrid,
|
||||
MessageSquare,
|
||||
MessageSquareQuote,
|
||||
MessagesSquare,
|
||||
PartyPopper,
|
||||
@@ -63,7 +64,8 @@ const MODULE_ICONS: Record<DashboardModuleId, LucideIcon> = {
|
||||
stats: BarChart3,
|
||||
feeds: Rss,
|
||||
scheduler: CalendarClock,
|
||||
guildbackup: Archive
|
||||
guildbackup: Archive,
|
||||
messages: MessageSquare
|
||||
};
|
||||
|
||||
interface SidebarNavProps {
|
||||
|
||||
147
apps/webui/src/components/modules/messages-manager.tsx
Normal file
147
apps/webui/src/components/modules/messages-manager.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
componentsV2HasContent,
|
||||
embedHasContent,
|
||||
type DashboardMessageMode,
|
||||
type MessageComponentsV2,
|
||||
type WelcomeEmbed
|
||||
} from '@nexumi/shared';
|
||||
import { Send } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
|
||||
import {
|
||||
DiscordComponentsV2Builder,
|
||||
emptyComponentsV2,
|
||||
normalizeComponentsV2
|
||||
} from '@/components/ui/discord-components-v2-builder';
|
||||
import { DiscordEmbedBuilder, emptyEmbed, normalizeEmbed } from '@/components/ui/discord-embed-builder';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MessagesManagerProps {
|
||||
guildId: string;
|
||||
}
|
||||
|
||||
export function MessagesManager({ guildId }: MessagesManagerProps) {
|
||||
const t = useTranslations();
|
||||
const [channelId, setChannelId] = useState('');
|
||||
const [mode, setMode] = useState<DashboardMessageMode>('EMBED');
|
||||
const [embed, setEmbed] = useState<WelcomeEmbed | null>(emptyEmbed());
|
||||
const [components, setComponents] = useState<MessageComponentsV2 | null>(emptyComponentsV2());
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
async function handleSend() {
|
||||
if (!channelId.trim()) {
|
||||
toast.error(t('modulePages.messages.channelRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedEmbed = mode === 'EMBED' ? normalizeEmbed(embed) : null;
|
||||
const normalizedComponents = mode === 'COMPONENTS_V2' ? normalizeComponentsV2(components) : null;
|
||||
|
||||
if (
|
||||
mode === 'EMBED' &&
|
||||
!embedHasContent(normalizedEmbed) &&
|
||||
normalizedEmbed?.color === undefined
|
||||
) {
|
||||
toast.error(t('modulePages.messages.embedRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'COMPONENTS_V2' && !componentsV2HasContent(normalizedComponents)) {
|
||||
toast.error(t('modulePages.messages.componentsRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
channelId: channelId.trim(),
|
||||
mode,
|
||||
embed: normalizedEmbed,
|
||||
components: normalizedComponents
|
||||
})
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as { error?: string; messageId?: string | null } | null;
|
||||
if (!response.ok || !body) {
|
||||
toast.error(body?.error ?? t('common.saveError'));
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
body.messageId
|
||||
? t('modulePages.messages.sentWithId', { id: body.messageId })
|
||||
: t('modulePages.messages.sent')
|
||||
);
|
||||
} catch {
|
||||
toast.error(t('common.saveError'));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldAnchor id="field-messages-send">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.messages.sendTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.messages.sendDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.messages.channelId')}</Label>
|
||||
<DiscordChannelSelect guildId={guildId} value={channelId} onChange={setChannelId} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.messages.mode')}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['EMBED', 'COMPONENTS_V2'] as DashboardMessageMode[]).map((entry) => (
|
||||
<Button
|
||||
key={entry}
|
||||
type="button"
|
||||
variant={mode === entry ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn(mode !== entry && 'text-muted-foreground')}
|
||||
onClick={() => setMode(entry)}
|
||||
>
|
||||
{t(`modulePages.messages.modes.${entry}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'EMBED' ? (
|
||||
<DiscordEmbedBuilder
|
||||
value={embed}
|
||||
onChange={setEmbed}
|
||||
placeholderPreset="scheduler"
|
||||
showClearHint={false}
|
||||
/>
|
||||
) : (
|
||||
<DiscordComponentsV2Builder
|
||||
guildId={guildId}
|
||||
value={components}
|
||||
onChange={setComponents}
|
||||
placeholderPreset="scheduler"
|
||||
showClearHint={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button type="button" onClick={handleSend} disabled={sending}>
|
||||
<Send className="size-4" />
|
||||
{sending ? t('modulePages.messages.sending') : t('modulePages.messages.send')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FieldAnchor>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { embedHasContent, type ScheduledMessageDashboard, type WelcomeEmbed } from '@nexumi/shared';
|
||||
import {
|
||||
componentsV2HasContent,
|
||||
embedHasContent,
|
||||
type MessageComponentsV2,
|
||||
type ScheduledMessageDashboard,
|
||||
type WelcomeEmbed
|
||||
} from '@nexumi/shared';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -9,17 +15,27 @@ import { useTranslations } from '@/components/locale-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
|
||||
import {
|
||||
DiscordComponentsV2Builder,
|
||||
emptyComponentsV2,
|
||||
normalizeComponentsV2
|
||||
} from '@/components/ui/discord-components-v2-builder';
|
||||
import { DiscordEmbedBuilder, normalizeEmbed } from '@/components/ui/discord-embed-builder';
|
||||
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 { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ScheduleContentMode = 'text' | 'embed' | 'components_v2';
|
||||
|
||||
const EMPTY_DRAFT = {
|
||||
channelId: '',
|
||||
contentMode: 'text' as ScheduleContentMode,
|
||||
content: '',
|
||||
embed: null as WelcomeEmbed | null,
|
||||
components: null as MessageComponentsV2 | null,
|
||||
rolePingId: '',
|
||||
cron: '',
|
||||
runAt: ''
|
||||
@@ -30,7 +46,7 @@ function formatTiming(schedule: ScheduledMessageDashboard, t: (key: string) => s
|
||||
return `${t('modulePages.scheduler.cron')}: ${schedule.cron}`;
|
||||
}
|
||||
if (schedule.runAt) {
|
||||
return `${t('modulePages.scheduler.runAt')}: ${new Date(schedule.runAt).toLocaleString(undefined, {
|
||||
return `${new Date(schedule.runAt).toLocaleString(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
})}`;
|
||||
@@ -42,6 +58,9 @@ function schedulePreview(schedule: ScheduledMessageDashboard, t: (key: string) =
|
||||
if (schedule.content?.trim()) {
|
||||
return schedule.content;
|
||||
}
|
||||
if (componentsV2HasContent(schedule.components)) {
|
||||
return t('modulePages.scheduler.componentsPreview');
|
||||
}
|
||||
if (schedule.embed?.title?.trim()) {
|
||||
return schedule.embed.title;
|
||||
}
|
||||
@@ -69,13 +88,21 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
async function handleCreate() {
|
||||
const embed = normalizeEmbed(draft.embed);
|
||||
const embed = draft.contentMode === 'embed' ? normalizeEmbed(draft.embed) : null;
|
||||
const components =
|
||||
draft.contentMode === 'components_v2' ? normalizeComponentsV2(draft.components ?? emptyComponentsV2()) : null;
|
||||
const content = draft.contentMode === 'text' ? draft.content.trim() || null : null;
|
||||
|
||||
const hasContent =
|
||||
Boolean(draft.content.trim()) || embedHasContent(embed) || embed?.color !== undefined;
|
||||
Boolean(content) ||
|
||||
(draft.contentMode === 'embed' && (embedHasContent(embed) || embed?.color !== undefined)) ||
|
||||
(draft.contentMode === 'components_v2' && componentsV2HasContent(components));
|
||||
|
||||
if (!draft.channelId.trim() || !hasContent || (!draft.cron.trim() && !draft.runAt)) {
|
||||
toast.error(t('modulePages.scheduler.formIncomplete'));
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/scheduler`, {
|
||||
@@ -83,8 +110,9 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
channelId: draft.channelId.trim(),
|
||||
content: draft.content.trim() || null,
|
||||
content,
|
||||
embed,
|
||||
components,
|
||||
rolePingId: draft.rolePingId.trim() || undefined,
|
||||
cron: draft.cron.trim() || null,
|
||||
runAt: draft.cron.trim() ? null : draft.runAt ? new Date(draft.runAt).toISOString() : null
|
||||
@@ -169,23 +197,60 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.scheduler.content')}</Label>
|
||||
<Textarea
|
||||
value={draft.content}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, content: event.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
<PlaceholderHelp preset="scheduler" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.scheduler.embed')}</Label>
|
||||
<DiscordEmbedBuilder
|
||||
value={draft.embed}
|
||||
onChange={(embed) => setDraft((prev) => ({ ...prev, embed }))}
|
||||
placeholderPreset="scheduler"
|
||||
/>
|
||||
<Label>{t('modulePages.scheduler.contentMode')}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['text', 'embed', 'components_v2'] as ScheduleContentMode[]).map((mode) => (
|
||||
<Button
|
||||
key={mode}
|
||||
type="button"
|
||||
variant={draft.contentMode === mode ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn(draft.contentMode !== mode && 'text-muted-foreground')}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, contentMode: mode }))}
|
||||
>
|
||||
{t(`modulePages.scheduler.contentModes.${mode}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draft.contentMode === 'text' ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.scheduler.content')}</Label>
|
||||
<Textarea
|
||||
value={draft.content}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, content: event.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
<PlaceholderHelp preset="scheduler" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{draft.contentMode === 'embed' ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.scheduler.embed')}</Label>
|
||||
<DiscordEmbedBuilder
|
||||
value={draft.embed}
|
||||
onChange={(embed) => setDraft((prev) => ({ ...prev, embed }))}
|
||||
placeholderPreset="scheduler"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{draft.contentMode === 'components_v2' ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.scheduler.components')}</Label>
|
||||
<DiscordComponentsV2Builder
|
||||
guildId={guildId}
|
||||
value={draft.components ?? emptyComponentsV2()}
|
||||
onChange={(components) => setDraft((prev) => ({ ...prev, components }))}
|
||||
placeholderPreset="scheduler"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.scheduler.cronHint')}</p>
|
||||
<Button type="button" onClick={handleCreate} disabled={creating}>
|
||||
<Plus className="size-4" />
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { embedHasContent, type TagDashboard, type TagResponseType, type WelcomeEmbed } from '@nexumi/shared';
|
||||
import {
|
||||
componentsV2HasContent,
|
||||
embedHasContent,
|
||||
type MessageComponentsV2,
|
||||
type TagDashboard,
|
||||
type TagResponseType,
|
||||
type WelcomeEmbed
|
||||
} from '@nexumi/shared';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -9,6 +16,10 @@ import { useTranslations } from '@/components/locale-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { DiscordChannelMultiSelect } from '@/components/ui/discord-channel-select';
|
||||
import {
|
||||
DiscordComponentsV2Builder,
|
||||
normalizeComponentsV2
|
||||
} from '@/components/ui/discord-components-v2-builder';
|
||||
import { DiscordEmbedBuilder, normalizeEmbed } from '@/components/ui/discord-embed-builder';
|
||||
import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -17,7 +28,7 @@ import { PlaceholderHelp } from '@/components/ui/placeholder-help';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
const RESPONSE_TYPES: TagResponseType[] = ['TEXT', 'EMBED'];
|
||||
const RESPONSE_TYPES: TagResponseType[] = ['TEXT', 'EMBED', 'COMPONENTS_V2'];
|
||||
|
||||
interface LocalTag extends TagDashboard {
|
||||
clientKey: string;
|
||||
@@ -28,6 +39,7 @@ const EMPTY_DRAFT = {
|
||||
name: '',
|
||||
content: '',
|
||||
embed: null as WelcomeEmbed | null,
|
||||
components: null as MessageComponentsV2 | null,
|
||||
responseType: 'TEXT' as TagResponseType,
|
||||
triggerWord: '',
|
||||
allowedRoleIds: [] as string[],
|
||||
@@ -45,20 +57,35 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
||||
initialTags.map((tag, index) => ({
|
||||
...tag,
|
||||
embed: tag.embed ?? null,
|
||||
components: tag.components ?? null,
|
||||
clientKey: tag.id ?? `existing-${index}`
|
||||
}))
|
||||
);
|
||||
const [draft, setDraft] = useState(EMPTY_DRAFT);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
function validateTagPayload(responseType: TagResponseType, embed: WelcomeEmbed | null, components: MessageComponentsV2 | null) {
|
||||
const normalizedEmbed = responseType === 'EMBED' ? normalizeEmbed(embed) : null;
|
||||
const normalizedComponents = responseType === 'COMPONENTS_V2' ? normalizeComponentsV2(components) : null;
|
||||
|
||||
if (responseType === 'EMBED' && !embedHasContent(normalizedEmbed) && normalizedEmbed?.color === undefined) {
|
||||
toast.error(t('modulePages.tags.embedRequired'));
|
||||
return null;
|
||||
}
|
||||
if (responseType === 'COMPONENTS_V2' && !componentsV2HasContent(normalizedComponents)) {
|
||||
toast.error(t('modulePages.tags.componentsRequired'));
|
||||
return null;
|
||||
}
|
||||
return { embed: normalizedEmbed, components: normalizedComponents };
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!draft.name.trim()) {
|
||||
toast.error(t('modulePages.tags.nameRequired'));
|
||||
return;
|
||||
}
|
||||
const embed = draft.responseType === 'EMBED' ? normalizeEmbed(draft.embed) : null;
|
||||
if (draft.responseType === 'EMBED' && !embedHasContent(embed) && embed?.color === undefined) {
|
||||
toast.error(t('modulePages.tags.embedRequired'));
|
||||
const validated = validateTagPayload(draft.responseType, draft.embed, draft.components);
|
||||
if (!validated) {
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
@@ -68,8 +95,9 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: draft.name.trim(),
|
||||
content: draft.content.trim() || null,
|
||||
embed,
|
||||
content: draft.responseType === 'TEXT' ? draft.content.trim() || null : null,
|
||||
embed: validated.embed,
|
||||
components: validated.components,
|
||||
responseType: draft.responseType,
|
||||
triggerWord: draft.triggerWord.trim() || null,
|
||||
allowedRoleIds: draft.allowedRoleIds,
|
||||
@@ -98,9 +126,8 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
||||
}
|
||||
|
||||
async function handleSave(tag: LocalTag) {
|
||||
const embed = tag.responseType === 'EMBED' ? normalizeEmbed(tag.embed ?? null) : null;
|
||||
if (tag.responseType === 'EMBED' && !embedHasContent(embed) && embed?.color === undefined) {
|
||||
toast.error(t('modulePages.tags.embedRequired'));
|
||||
const validated = validateTagPayload(tag.responseType, tag.embed ?? null, tag.components ?? null);
|
||||
if (!validated) {
|
||||
return;
|
||||
}
|
||||
setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, saving: true } : entry)));
|
||||
@@ -110,8 +137,9 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: tag.name,
|
||||
content: tag.content,
|
||||
embed,
|
||||
content: tag.responseType === 'TEXT' ? tag.content : null,
|
||||
embed: validated.embed,
|
||||
components: validated.components,
|
||||
responseType: tag.responseType,
|
||||
triggerWord: tag.triggerWord,
|
||||
allowedRoleIds: tag.allowedRoleIds,
|
||||
@@ -155,6 +183,45 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderResponseEditor(
|
||||
responseType: TagResponseType,
|
||||
embed: WelcomeEmbed | null,
|
||||
components: MessageComponentsV2 | null,
|
||||
content: string,
|
||||
onEmbedChange: (embed: WelcomeEmbed | null) => void,
|
||||
onComponentsChange: (components: MessageComponentsV2 | null) => void,
|
||||
onContentChange: (content: string) => void
|
||||
) {
|
||||
if (responseType === 'TEXT') {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.tags.content')}</Label>
|
||||
<Textarea rows={3} value={content} onChange={(event) => onContentChange(event.target.value)} />
|
||||
<PlaceholderHelp preset="tags" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (responseType === 'EMBED') {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.tags.embed')}</Label>
|
||||
<DiscordEmbedBuilder value={embed} onChange={onEmbedChange} placeholderPreset="tags" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.tags.components')}</Label>
|
||||
<DiscordComponentsV2Builder
|
||||
guildId={guildId}
|
||||
value={components}
|
||||
onChange={onComponentsChange}
|
||||
placeholderPreset="tags"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<FieldAnchor id="field-tags-create">
|
||||
@@ -189,21 +256,14 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
||||
<Input value={draft.triggerWord} onChange={(event) => setDraft((prev) => ({ ...prev, triggerWord: event.target.value }))} placeholder={t('common.optional')} />
|
||||
</div>
|
||||
</div>
|
||||
{draft.responseType === 'TEXT' ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.tags.content')}</Label>
|
||||
<Textarea rows={3} value={draft.content} onChange={(event) => setDraft((prev) => ({ ...prev, content: event.target.value }))} />
|
||||
<PlaceholderHelp preset="tags" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.tags.embed')}</Label>
|
||||
<DiscordEmbedBuilder
|
||||
value={draft.embed}
|
||||
onChange={(embed) => setDraft((prev) => ({ ...prev, embed }))}
|
||||
placeholderPreset="tags"
|
||||
/>
|
||||
</div>
|
||||
{renderResponseEditor(
|
||||
draft.responseType,
|
||||
draft.embed,
|
||||
draft.components,
|
||||
draft.content,
|
||||
(embed) => setDraft((prev) => ({ ...prev, embed })),
|
||||
(components) => setDraft((prev) => ({ ...prev, components })),
|
||||
(content) => setDraft((prev) => ({ ...prev, content }))
|
||||
)}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
@@ -266,25 +326,23 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
||||
<Input value={tag.triggerWord ?? ''} onChange={(event) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, triggerWord: event.target.value || null } : entry)))} />
|
||||
</div>
|
||||
</div>
|
||||
{tag.responseType === 'TEXT' ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.tags.content')}</Label>
|
||||
<Textarea rows={3} value={tag.content ?? ''} onChange={(event) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, content: event.target.value } : entry)))} />
|
||||
<PlaceholderHelp preset="tags" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.tags.embed')}</Label>
|
||||
<DiscordEmbedBuilder
|
||||
value={tag.embed ?? null}
|
||||
onChange={(embed) =>
|
||||
setTags((prev) =>
|
||||
prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, embed } : entry))
|
||||
)
|
||||
}
|
||||
placeholderPreset="tags"
|
||||
/>
|
||||
</div>
|
||||
{renderResponseEditor(
|
||||
tag.responseType,
|
||||
tag.embed ?? null,
|
||||
tag.components ?? null,
|
||||
tag.content ?? '',
|
||||
(embed) =>
|
||||
setTags((prev) =>
|
||||
prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, embed } : entry))
|
||||
),
|
||||
(components) =>
|
||||
setTags((prev) =>
|
||||
prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, components } : entry))
|
||||
),
|
||||
(content) =>
|
||||
setTags((prev) =>
|
||||
prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, content } : entry))
|
||||
)
|
||||
)}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import type { WelcomeConfigDashboard, WelcomeEmbed } from '@nexumi/shared';
|
||||
import type { MessageComponentsV2, WelcomeConfigDashboard, WelcomeEmbed } from '@nexumi/shared';
|
||||
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
|
||||
import {
|
||||
DiscordComponentsV2Builder,
|
||||
componentsV2FromUnknown,
|
||||
normalizeComponentsV2
|
||||
} from '@/components/ui/discord-components-v2-builder';
|
||||
import {
|
||||
DiscordEmbedBuilder,
|
||||
embedFromUnknown,
|
||||
@@ -31,16 +36,24 @@ const WELCOME_PREVIEW_VARS = {
|
||||
memberCount: '128'
|
||||
};
|
||||
|
||||
interface LocalWelcomeValue extends Omit<WelcomeConfigDashboard, 'welcomeEmbed' | 'leaveEmbed'> {
|
||||
interface LocalWelcomeValue extends Omit<
|
||||
WelcomeConfigDashboard,
|
||||
'welcomeEmbed' | 'leaveEmbed' | 'welcomeComponents' | 'leaveComponents'
|
||||
> {
|
||||
welcomeEmbed: WelcomeEmbed | null;
|
||||
leaveEmbed: WelcomeEmbed | null;
|
||||
welcomeComponents: MessageComponentsV2 | null;
|
||||
leaveComponents: MessageComponentsV2 | null;
|
||||
}
|
||||
|
||||
function toLocal(config: WelcomeConfigDashboard): LocalWelcomeValue {
|
||||
return {
|
||||
...config,
|
||||
welcomeEmbed: embedFromUnknown(config.welcomeEmbed),
|
||||
leaveEmbed: embedFromUnknown(config.leaveEmbed)
|
||||
leaveEmbed: embedFromUnknown(config.leaveEmbed),
|
||||
welcomeComponents: componentsV2FromUnknown(config.welcomeComponents),
|
||||
leaveComponents: componentsV2FromUnknown(config.leaveComponents),
|
||||
leaveType: config.leaveType ?? 'TEXT'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,7 +61,9 @@ async function saveWelcome(guildId: string, value: LocalWelcomeValue): Promise<S
|
||||
const payload: WelcomeConfigDashboard = {
|
||||
...value,
|
||||
welcomeEmbed: normalizeEmbed(value.welcomeEmbed),
|
||||
leaveEmbed: normalizeEmbed(value.leaveEmbed)
|
||||
leaveEmbed: normalizeEmbed(value.leaveEmbed),
|
||||
welcomeComponents: normalizeComponentsV2(value.welcomeComponents),
|
||||
leaveComponents: normalizeComponentsV2(value.leaveComponents)
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -121,33 +136,52 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
||||
<SelectItem value="TEXT">{t('modulePages.welcome.type.TEXT')}</SelectItem>
|
||||
<SelectItem value="EMBED">{t('modulePages.welcome.type.EMBED')}</SelectItem>
|
||||
<SelectItem value="IMAGE">{t('modulePages.welcome.type.IMAGE')}</SelectItem>
|
||||
<SelectItem value="COMPONENTS_V2">{t('modulePages.welcome.type.COMPONENTS_V2')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
</div>
|
||||
<FieldAnchor id="field-welcome-content">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.welcomeContent')}</Label>
|
||||
<Textarea
|
||||
value={value.welcomeContent ?? ''}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, welcomeContent: event.target.value || null }))}
|
||||
placeholder={t('modulePages.welcome.contentPlaceholder')}
|
||||
/>
|
||||
<PlaceholderHelp preset="welcome" />
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
<FieldAnchor id="field-welcome-embed">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.welcomeEmbed')}</Label>
|
||||
<DiscordEmbedBuilder
|
||||
value={value.welcomeEmbed}
|
||||
onChange={(welcomeEmbed) => setValue((prev) => ({ ...prev, welcomeEmbed }))}
|
||||
previewVars={WELCOME_PREVIEW_VARS}
|
||||
placeholderPreset="welcome"
|
||||
/>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
{value.welcomeType === 'TEXT' ? (
|
||||
<FieldAnchor id="field-welcome-content">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.welcomeContent')}</Label>
|
||||
<Textarea
|
||||
value={value.welcomeContent ?? ''}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, welcomeContent: event.target.value || null }))}
|
||||
placeholder={t('modulePages.welcome.contentPlaceholder')}
|
||||
/>
|
||||
<PlaceholderHelp preset="welcome" />
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
) : null}
|
||||
{value.welcomeType === 'EMBED' ? (
|
||||
<FieldAnchor id="field-welcome-embed">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.welcomeEmbed')}</Label>
|
||||
<DiscordEmbedBuilder
|
||||
value={value.welcomeEmbed}
|
||||
onChange={(welcomeEmbed) => setValue((prev) => ({ ...prev, welcomeEmbed }))}
|
||||
previewVars={WELCOME_PREVIEW_VARS}
|
||||
placeholderPreset="welcome"
|
||||
/>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
) : null}
|
||||
{value.welcomeType === 'COMPONENTS_V2' ? (
|
||||
<FieldAnchor id="field-welcome-components">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.welcomeComponents')}</Label>
|
||||
<DiscordComponentsV2Builder
|
||||
guildId={guildId}
|
||||
value={value.welcomeComponents}
|
||||
onChange={(welcomeComponents) => setValue((prev) => ({ ...prev, welcomeComponents }))}
|
||||
previewVars={WELCOME_PREVIEW_VARS}
|
||||
placeholderPreset="welcome"
|
||||
/>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
) : null}
|
||||
<FieldAnchor id="field-welcome-dm">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
@@ -185,27 +219,31 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
||||
/>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FieldAnchor id="field-welcome-image-title">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.imageTitle')}</Label>
|
||||
<Input
|
||||
value={value.imageTitle ?? ''}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, imageTitle: event.target.value || null }))}
|
||||
/>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
<FieldAnchor id="field-welcome-image-subtitle">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.imageSubtitle')}</Label>
|
||||
<Input
|
||||
value={value.imageSubtitle ?? ''}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, imageSubtitle: event.target.value || null }))}
|
||||
/>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
</div>
|
||||
<PlaceholderHelp preset="welcome" />
|
||||
{value.welcomeType === 'IMAGE' ? (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FieldAnchor id="field-welcome-image-title">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.imageTitle')}</Label>
|
||||
<Input
|
||||
value={value.imageTitle ?? ''}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, imageTitle: event.target.value || null }))}
|
||||
/>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
<FieldAnchor id="field-welcome-image-subtitle">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.imageSubtitle')}</Label>
|
||||
<Input
|
||||
value={value.imageSubtitle ?? ''}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, imageSubtitle: event.target.value || null }))}
|
||||
/>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
</div>
|
||||
<PlaceholderHelp preset="welcome" />
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -234,30 +272,68 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
||||
/>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
<FieldAnchor id="field-leave-content">
|
||||
<FieldAnchor id="field-leave-type">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.leaveContent')}</Label>
|
||||
<Textarea
|
||||
value={value.leaveContent ?? ''}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, leaveContent: event.target.value || null }))}
|
||||
placeholder={t('modulePages.welcome.leaveContentPlaceholder')}
|
||||
/>
|
||||
<PlaceholderHelp preset="welcome" />
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
<FieldAnchor id="field-leave-embed">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.leaveEmbed')}</Label>
|
||||
<DiscordEmbedBuilder
|
||||
value={value.leaveEmbed}
|
||||
onChange={(leaveEmbed) => setValue((prev) => ({ ...prev, leaveEmbed }))}
|
||||
previewVars={WELCOME_PREVIEW_VARS}
|
||||
placeholderPreset="welcome"
|
||||
titlePlaceholder={t('modulePages.welcome.leaveEmbedTitlePlaceholder')}
|
||||
descriptionPlaceholder={t('modulePages.welcome.leaveEmbedDescriptionPlaceholder')}
|
||||
/>
|
||||
<Label>{t('modulePages.welcome.leaveType')}</Label>
|
||||
<Select
|
||||
value={value.leaveType}
|
||||
onValueChange={(leaveType) =>
|
||||
setValue((prev) => ({ ...prev, leaveType: leaveType as WelcomeConfigDashboard['leaveType'] }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="TEXT">{t('modulePages.welcome.leaveTypes.TEXT')}</SelectItem>
|
||||
<SelectItem value="EMBED">{t('modulePages.welcome.leaveTypes.EMBED')}</SelectItem>
|
||||
<SelectItem value="COMPONENTS_V2">{t('modulePages.welcome.leaveTypes.COMPONENTS_V2')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
{value.leaveType === 'TEXT' ? (
|
||||
<FieldAnchor id="field-leave-content">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.leaveContent')}</Label>
|
||||
<Textarea
|
||||
value={value.leaveContent ?? ''}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, leaveContent: event.target.value || null }))}
|
||||
placeholder={t('modulePages.welcome.leaveContentPlaceholder')}
|
||||
/>
|
||||
<PlaceholderHelp preset="welcome" />
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
) : null}
|
||||
{value.leaveType === 'EMBED' ? (
|
||||
<FieldAnchor id="field-leave-embed">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.leaveEmbed')}</Label>
|
||||
<DiscordEmbedBuilder
|
||||
value={value.leaveEmbed}
|
||||
onChange={(leaveEmbed) => setValue((prev) => ({ ...prev, leaveEmbed }))}
|
||||
previewVars={WELCOME_PREVIEW_VARS}
|
||||
placeholderPreset="welcome"
|
||||
titlePlaceholder={t('modulePages.welcome.leaveEmbedTitlePlaceholder')}
|
||||
descriptionPlaceholder={t('modulePages.welcome.leaveEmbedDescriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
) : null}
|
||||
{value.leaveType === 'COMPONENTS_V2' ? (
|
||||
<FieldAnchor id="field-leave-components">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.leaveComponents')}</Label>
|
||||
<DiscordComponentsV2Builder
|
||||
guildId={guildId}
|
||||
value={value.leaveComponents}
|
||||
onChange={(leaveComponents) => setValue((prev) => ({ ...prev, leaveComponents }))}
|
||||
previewVars={WELCOME_PREVIEW_VARS}
|
||||
placeholderPreset="welcome"
|
||||
/>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
1300
apps/webui/src/components/ui/discord-components-v2-builder.tsx
Normal file
1300
apps/webui/src/components/ui/discord-components-v2-builder.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user