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:
41
apps/webui/src/app/api/guilds/[guildId]/messages/route.ts
Normal file
41
apps/webui/src/app/api/guilds/[guildId]/messages/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { DashboardMessageSendSchema } from '@nexumi/shared';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||
import { writeDashboardAudit } from '@/lib/audit';
|
||||
import { MessageSendTimeoutError, sendDashboardMessageFromWebui } from '@/lib/module-configs/messages';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ guildId: string }>;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
const { guildId } = await params;
|
||||
try {
|
||||
const session = await requireGuildAccess(guildId);
|
||||
const body = await request.json();
|
||||
const input = DashboardMessageSendSchema.parse(body);
|
||||
|
||||
const result = await sendDashboardMessageFromWebui(guildId, session.user.id, input);
|
||||
|
||||
await writeDashboardAudit(prisma, {
|
||||
guildId,
|
||||
actorUserId: session.user.id,
|
||||
action: 'messages.send',
|
||||
path: `/dashboard/${guildId}/messages`,
|
||||
after: {
|
||||
channelId: result.channelId,
|
||||
mode: input.mode,
|
||||
messageId: result.messageId,
|
||||
bindingId: result.bindingId
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof MessageSendTimeoutError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 504 });
|
||||
}
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
13
apps/webui/src/app/dashboard/[guildId]/messages/loading.tsx
Normal file
13
apps/webui/src/app/dashboard/[guildId]/messages/loading.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export default function MessagesLoading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-80" />
|
||||
</div>
|
||||
<Skeleton className="h-96 rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
apps/webui/src/app/dashboard/[guildId]/messages/page.tsx
Normal file
21
apps/webui/src/app/dashboard/[guildId]/messages/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { MessagesManager } from '@/components/modules/messages-manager';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
|
||||
interface MessagesPageProps {
|
||||
params: Promise<{ guildId: string }>;
|
||||
}
|
||||
|
||||
export default async function MessagesPage({ params }: MessagesPageProps) {
|
||||
const { guildId } = await params;
|
||||
const locale = await getLocale();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'modules.messages.label')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'modules.messages.description')}</p>
|
||||
</div>
|
||||
<MessagesManager guildId={guildId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -242,6 +242,14 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
|
||||
href: 'welcome',
|
||||
hash: 'field-welcome-embed'
|
||||
},
|
||||
{
|
||||
id: 'setting-welcome-components',
|
||||
kind: 'setting',
|
||||
labelKey: 'modulePages.welcome.welcomeComponents',
|
||||
href: 'welcome',
|
||||
hash: 'field-welcome-components',
|
||||
keywords: ['components v2', 'layout']
|
||||
},
|
||||
{
|
||||
id: 'setting-welcome-dm',
|
||||
kind: 'setting',
|
||||
@@ -292,6 +300,13 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
|
||||
href: 'welcome',
|
||||
hash: 'field-leave-channel'
|
||||
},
|
||||
{
|
||||
id: 'setting-leave-type',
|
||||
kind: 'setting',
|
||||
labelKey: 'modulePages.welcome.leaveType',
|
||||
href: 'welcome',
|
||||
hash: 'field-leave-type'
|
||||
},
|
||||
{
|
||||
id: 'setting-leave-content',
|
||||
kind: 'setting',
|
||||
@@ -306,6 +321,14 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
|
||||
href: 'welcome',
|
||||
hash: 'field-leave-embed'
|
||||
},
|
||||
{
|
||||
id: 'setting-leave-components',
|
||||
kind: 'setting',
|
||||
labelKey: 'modulePages.welcome.leaveComponents',
|
||||
href: 'welcome',
|
||||
hash: 'field-leave-components',
|
||||
keywords: ['components v2', 'layout']
|
||||
},
|
||||
// Verification
|
||||
{
|
||||
id: 'setting-verify-enabled',
|
||||
@@ -825,6 +848,15 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
|
||||
href: 'scheduler',
|
||||
hash: 'field-scheduler-create'
|
||||
},
|
||||
// Messages
|
||||
{
|
||||
id: 'setting-messages-send',
|
||||
kind: 'setting',
|
||||
labelKey: 'modulePages.messages.sendTitle',
|
||||
href: 'messages',
|
||||
hash: 'field-messages-send',
|
||||
keywords: ['send', 'post', 'embed', 'components v2']
|
||||
},
|
||||
// Backup
|
||||
{
|
||||
id: 'setting-backup',
|
||||
|
||||
73
apps/webui/src/lib/module-configs/messages.ts
Normal file
73
apps/webui/src/lib/module-configs/messages.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
DashboardMessageSendResultSchema,
|
||||
MessageComponentsV2Schema,
|
||||
type DashboardMessageSend,
|
||||
type DashboardMessageSendResult
|
||||
} from '@nexumi/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { prisma } from '../prisma';
|
||||
import { addJobAndAwait, getMessagesQueue, getMessagesQueueEvents } from '../queues';
|
||||
|
||||
export class MessageSendTimeoutError extends Error {
|
||||
constructor() {
|
||||
super('The bot did not confirm the message in time. It may still have been sent — check the channel shortly.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a dashboard message via the bot's `messages` BullMQ queue
|
||||
* (`dashboardMessageSend` job). For Components V2, a binding row is created
|
||||
* first so interaction custom IDs can reference a stable ref id.
|
||||
*/
|
||||
export async function sendDashboardMessageFromWebui(
|
||||
guildId: string,
|
||||
createdById: string,
|
||||
input: DashboardMessageSend
|
||||
): Promise<DashboardMessageSendResult> {
|
||||
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
|
||||
|
||||
let bindingId: string | null = null;
|
||||
if (input.mode === 'COMPONENTS_V2') {
|
||||
const components = MessageComponentsV2Schema.parse(input.components);
|
||||
const binding = await prisma.componentMessageBinding.create({
|
||||
data: {
|
||||
guildId,
|
||||
channelId: input.channelId,
|
||||
payload: components as Prisma.InputJsonValue,
|
||||
createdById
|
||||
}
|
||||
});
|
||||
bindingId = binding.id;
|
||||
}
|
||||
|
||||
const { confirmed, result } = await addJobAndAwait<{
|
||||
bindingId: string | null;
|
||||
messageId: string | null;
|
||||
channelId: string;
|
||||
}>(
|
||||
getMessagesQueue(),
|
||||
getMessagesQueueEvents(),
|
||||
'dashboardMessageSend',
|
||||
{
|
||||
guildId,
|
||||
channelId: input.channelId,
|
||||
createdById,
|
||||
mode: input.mode,
|
||||
embed: input.embed ?? null,
|
||||
components: input.components ?? null,
|
||||
bindingId
|
||||
},
|
||||
{ removeOnComplete: true, removeOnFail: 25 }
|
||||
);
|
||||
|
||||
if (!confirmed || !result) {
|
||||
throw new MessageSendTimeoutError();
|
||||
}
|
||||
|
||||
return DashboardMessageSendResultSchema.parse({
|
||||
bindingId: result.bindingId ?? bindingId,
|
||||
messageId: result.messageId,
|
||||
channelId: result.channelId,
|
||||
confirmed: true
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
MessageComponentsV2Schema,
|
||||
WelcomeEmbedSchema,
|
||||
type MessageComponentsV2,
|
||||
type ScheduledMessageDashboard,
|
||||
type ScheduledMessageDashboardCreate,
|
||||
type WelcomeEmbed
|
||||
@@ -19,6 +21,11 @@ function parseEmbed(raw: unknown): WelcomeEmbed | null {
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function parseComponents(raw: unknown): MessageComponentsV2 | null {
|
||||
const parsed = MessageComponentsV2Schema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {
|
||||
return {
|
||||
id: schedule.id,
|
||||
@@ -26,6 +33,7 @@ function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {
|
||||
creatorId: schedule.creatorId,
|
||||
content: schedule.content,
|
||||
embed: parseEmbed(schedule.embed),
|
||||
components: parseComponents(schedule.components),
|
||||
rolePingId: schedule.rolePingId,
|
||||
cron: schedule.cron,
|
||||
runAt: schedule.runAt?.toISOString() ?? null,
|
||||
@@ -84,6 +92,10 @@ export async function createScheduledMessageDashboard(
|
||||
input.embed === undefined || input.embed === null
|
||||
? Prisma.JsonNull
|
||||
: (input.embed as Prisma.InputJsonValue),
|
||||
components:
|
||||
input.components === undefined || input.components === null
|
||||
? Prisma.JsonNull
|
||||
: (input.components as Prisma.InputJsonValue),
|
||||
rolePingId: input.rolePingId || null,
|
||||
cron,
|
||||
runAt
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
MessageComponentsV2Schema,
|
||||
WelcomeEmbedSchema,
|
||||
type MessageComponentsV2,
|
||||
type TagDashboard,
|
||||
type TagDashboardCreate,
|
||||
type TagDashboardUpdate,
|
||||
@@ -26,12 +28,18 @@ function parseEmbed(raw: unknown): WelcomeEmbed | null {
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function parseComponents(raw: unknown): MessageComponentsV2 | null {
|
||||
const parsed = MessageComponentsV2Schema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function toDashboard(tag: Tag): TagDashboard {
|
||||
return {
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
content: tag.content,
|
||||
embed: parseEmbed(tag.embed),
|
||||
components: parseComponents(tag.components),
|
||||
responseType: tag.responseType as TagDashboard['responseType'],
|
||||
triggerWord: tag.triggerWord,
|
||||
allowedRoleIds: tag.allowedRoleIds,
|
||||
@@ -69,6 +77,10 @@ export async function createTag(
|
||||
input.embed === undefined || input.embed === null
|
||||
? Prisma.JsonNull
|
||||
: (input.embed as Prisma.InputJsonValue),
|
||||
components:
|
||||
input.components === undefined || input.components === null
|
||||
? Prisma.JsonNull
|
||||
: (input.components as Prisma.InputJsonValue),
|
||||
responseType: input.responseType,
|
||||
triggerWord: input.triggerWord ?? null,
|
||||
allowedRoleIds: input.allowedRoleIds,
|
||||
@@ -104,6 +116,12 @@ export async function updateTag(
|
||||
...(patch.embed !== undefined
|
||||
? { embed: patch.embed === null ? Prisma.JsonNull : (patch.embed as Prisma.InputJsonValue) }
|
||||
: {}),
|
||||
...(patch.components !== undefined
|
||||
? {
|
||||
components:
|
||||
patch.components === null ? Prisma.JsonNull : (patch.components as Prisma.InputJsonValue)
|
||||
}
|
||||
: {}),
|
||||
...(patch.responseType !== undefined ? { responseType: patch.responseType } : {}),
|
||||
...(patch.triggerWord !== undefined ? { triggerWord: patch.triggerWord } : {}),
|
||||
...(patch.allowedRoleIds !== undefined ? { allowedRoleIds: patch.allowedRoleIds } : {}),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
MessageComponentsV2Schema,
|
||||
WelcomeEmbedSchema,
|
||||
type MessageComponentsV2,
|
||||
type WelcomeConfigDashboard,
|
||||
type WelcomeConfigDashboardPatch,
|
||||
type WelcomeEmbed
|
||||
@@ -21,6 +23,11 @@ function parseEmbed(raw: unknown): WelcomeEmbed | null {
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function parseComponents(raw: unknown): MessageComponentsV2 | null {
|
||||
const parsed = MessageComponentsV2Schema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function toDashboard(config: Awaited<ReturnType<typeof ensureWelcomeConfig>>): WelcomeConfigDashboard {
|
||||
return {
|
||||
welcomeEnabled: config.welcomeEnabled,
|
||||
@@ -30,8 +37,11 @@ function toDashboard(config: Awaited<ReturnType<typeof ensureWelcomeConfig>>): W
|
||||
welcomeType: config.welcomeType as WelcomeConfigDashboard['welcomeType'],
|
||||
welcomeContent: config.welcomeContent,
|
||||
welcomeEmbed: parseEmbed(config.welcomeEmbed),
|
||||
welcomeComponents: parseComponents(config.welcomeComponents),
|
||||
leaveType: config.leaveType as WelcomeConfigDashboard['leaveType'],
|
||||
leaveContent: config.leaveContent,
|
||||
leaveEmbed: parseEmbed(config.leaveEmbed),
|
||||
leaveComponents: parseComponents(config.leaveComponents),
|
||||
welcomeDmEnabled: config.welcomeDmEnabled,
|
||||
welcomeDmContent: config.welcomeDmContent,
|
||||
userAutoroleIds: config.userAutoroleIds,
|
||||
@@ -52,7 +62,15 @@ export async function updateWelcomeDashboard(
|
||||
): Promise<WelcomeConfigDashboard> {
|
||||
await ensureWelcomeConfig(guildId);
|
||||
|
||||
const { welcomeChannelId, leaveChannelId, welcomeEmbed, leaveEmbed, ...rest } = patch;
|
||||
const {
|
||||
welcomeChannelId,
|
||||
leaveChannelId,
|
||||
welcomeEmbed,
|
||||
leaveEmbed,
|
||||
welcomeComponents,
|
||||
leaveComponents,
|
||||
...rest
|
||||
} = patch;
|
||||
|
||||
const data: Prisma.WelcomeConfigUpdateInput = { ...rest };
|
||||
if (welcomeChannelId !== undefined) {
|
||||
@@ -67,6 +85,14 @@ export async function updateWelcomeDashboard(
|
||||
if (leaveEmbed !== undefined) {
|
||||
data.leaveEmbed = leaveEmbed === null ? Prisma.JsonNull : (leaveEmbed as Prisma.InputJsonValue);
|
||||
}
|
||||
if (welcomeComponents !== undefined) {
|
||||
data.welcomeComponents =
|
||||
welcomeComponents === null ? Prisma.JsonNull : (welcomeComponents as Prisma.InputJsonValue);
|
||||
}
|
||||
if (leaveComponents !== undefined) {
|
||||
data.leaveComponents =
|
||||
leaveComponents === null ? Prisma.JsonNull : (leaveComponents as Prisma.InputJsonValue);
|
||||
}
|
||||
|
||||
const updated = await prisma.welcomeConfig.update({ where: { guildId }, data });
|
||||
return toDashboard(updated);
|
||||
|
||||
@@ -12,7 +12,8 @@ const REDIS_ONLY_MODULES = new Set<DashboardModuleId>([
|
||||
'selfroles',
|
||||
'feeds',
|
||||
'scheduler',
|
||||
'guildbackup'
|
||||
'guildbackup',
|
||||
'messages'
|
||||
]);
|
||||
|
||||
function redisModulesKey(guildId: string): string {
|
||||
@@ -121,7 +122,8 @@ export async function getModuleStatuses(guildId: string): Promise<ModuleStatus[]
|
||||
stats: statsConfig?.enabled ?? false,
|
||||
feeds: redisFlags.feeds ?? true,
|
||||
scheduler: redisFlags.scheduler ?? true,
|
||||
guildbackup: redisFlags.guildbackup ?? true
|
||||
guildbackup: redisFlags.guildbackup ?? true,
|
||||
messages: redisFlags.messages ?? true
|
||||
};
|
||||
|
||||
return DASHBOARD_MODULES.map((module) => ({
|
||||
|
||||
@@ -18,15 +18,18 @@ export const giveawayQueueName = 'giveaways';
|
||||
export const scheduleQueueName = 'schedules';
|
||||
export const guildBackupQueueName = 'guild-backups';
|
||||
export const suggestionsQueueName = 'suggestions';
|
||||
export const messagesQueueName = 'messages';
|
||||
|
||||
const globalForQueues = globalThis as unknown as {
|
||||
__nexumiGiveawayQueue?: Queue;
|
||||
__nexumiScheduleQueue?: Queue;
|
||||
__nexumiGuildBackupQueue?: Queue;
|
||||
__nexumiSuggestionsQueue?: Queue;
|
||||
__nexumiMessagesQueue?: Queue;
|
||||
__nexumiGiveawayQueueEvents?: QueueEvents;
|
||||
__nexumiGuildBackupQueueEvents?: QueueEvents;
|
||||
__nexumiSuggestionsQueueEvents?: QueueEvents;
|
||||
__nexumiMessagesQueueEvents?: QueueEvents;
|
||||
};
|
||||
|
||||
function queueConnection() {
|
||||
@@ -66,6 +69,13 @@ export function getSuggestionsQueue(): Queue {
|
||||
return globalForQueues.__nexumiSuggestionsQueue;
|
||||
}
|
||||
|
||||
export function getMessagesQueue(): Queue {
|
||||
globalForQueues.__nexumiMessagesQueue ??= new Queue(messagesQueueName, {
|
||||
connection: queueConnection()
|
||||
});
|
||||
return globalForQueues.__nexumiMessagesQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* QueueEvents instances are only needed for jobs where the dashboard needs
|
||||
* to wait for the bot to finish (e.g. ending a giveaway so the winners list
|
||||
@@ -92,6 +102,13 @@ export function getSuggestionsQueueEvents(): QueueEvents {
|
||||
return globalForQueues.__nexumiSuggestionsQueueEvents;
|
||||
}
|
||||
|
||||
export function getMessagesQueueEvents(): QueueEvents {
|
||||
globalForQueues.__nexumiMessagesQueueEvents ??= new QueueEvents(messagesQueueName, {
|
||||
connection: queueEventsConnection()
|
||||
});
|
||||
return globalForQueues.__nexumiMessagesQueueEvents;
|
||||
}
|
||||
|
||||
const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,6 +50,78 @@
|
||||
"timestampPreview": "gerade eben",
|
||||
"mediaHint": "Bild-URLs müssen mit http(s) beginnen. Für Welcome kannst du {user.avatar} und {server.icon} nutzen."
|
||||
},
|
||||
"componentsV2Builder": {
|
||||
"blocks": "Layout-Blöcke",
|
||||
"addBlock": "Block hinzufügen…",
|
||||
"addNestedBlock": "Verschachtelten Block hinzufügen…",
|
||||
"empty": "Noch keine Blöcke — füge einen Container oder Textblock hinzu.",
|
||||
"preview": "Vorschau",
|
||||
"previewEmptyText": "Textinhalt…",
|
||||
"previewInvalidUrl": "Ungültige Bild-URL",
|
||||
"clear": "Layout leeren",
|
||||
"clearHint": "Ungültige Blöcke werden beim Speichern verworfen.",
|
||||
"actionsTitle": "Button- & Select-Aktionen",
|
||||
"textPlaceholder": "Text (Discord-Formatierung)",
|
||||
"divider": "Trennlinie anzeigen",
|
||||
"spacing": "Abstand",
|
||||
"spacingSmall": "Klein",
|
||||
"spacingLarge": "Groß",
|
||||
"accentColor": "Akzentfarbe (Dezimalwert)",
|
||||
"spoiler": "Spoiler-Container",
|
||||
"sectionText": "Abschnittstext",
|
||||
"accessoryType": "Zubehör",
|
||||
"accessoryThumbnail": "Thumbnail",
|
||||
"accessoryButton": "Button",
|
||||
"urlPlaceholder": "https://… oder {user.avatar}",
|
||||
"addMediaItem": "Bild hinzufügen",
|
||||
"addButton": "Button hinzufügen",
|
||||
"addSelect": "Select hinzufügen…",
|
||||
"buttonLabel": "Button-Label",
|
||||
"linkUrl": "Link-URL (https://…)",
|
||||
"actionId": "Action-ID",
|
||||
"centralActionId": "Zentrale Action-ID (optional)",
|
||||
"optionLabel": "Options-Label",
|
||||
"optionValue": "Options-Wert",
|
||||
"optionActionId": "Action-ID pro Option (optional)",
|
||||
"addOption": "Option hinzufügen",
|
||||
"removeOption": "Option entfernen",
|
||||
"selectPlaceholder": "Select-Platzhalter",
|
||||
"replyContent": "Antwortnachricht",
|
||||
"roleAction": "Zielrolle",
|
||||
"defaultButtonLabel": "Button",
|
||||
"blockTypes": {
|
||||
"container": "Container",
|
||||
"text_display": "Text",
|
||||
"separator": "Trenner",
|
||||
"section": "Abschnitt",
|
||||
"media_gallery": "Medien-Galerie",
|
||||
"action_row": "Action Row",
|
||||
"button": "Button"
|
||||
},
|
||||
"buttonStyles": {
|
||||
"Primary": "Primär",
|
||||
"Secondary": "Sekundär",
|
||||
"Success": "Erfolg",
|
||||
"Danger": "Gefahr",
|
||||
"Link": "Link"
|
||||
},
|
||||
"selectTypes": {
|
||||
"string_select": "String-Select",
|
||||
"user_select": "User-Select",
|
||||
"role_select": "Rollen-Select",
|
||||
"channel_select": "Kanal-Select",
|
||||
"mentionable_select": "Mentionable-Select"
|
||||
},
|
||||
"actionTypes": {
|
||||
"EPHEMERAL_REPLY": "Ephemere Antwort",
|
||||
"PUBLIC_REPLY": "Öffentliche Antwort",
|
||||
"ASSIGN_ROLE": "Rolle zuweisen",
|
||||
"REMOVE_ROLE": "Rolle entfernen",
|
||||
"TOGGLE_ROLE": "Rolle umschalten",
|
||||
"ASSIGN_SELECTED_ROLES": "Ausgewählte Rollen zuweisen",
|
||||
"NONE": "Keine Aktion"
|
||||
}
|
||||
},
|
||||
"placeholders": {
|
||||
"toggle": "Verfügbare Platzhalter anzeigen",
|
||||
"hint": "Zum Einfügen auf Kopieren tippen. Nur die hier gelisteten Platzhalter funktionieren in diesem Feld.",
|
||||
@@ -174,6 +246,10 @@
|
||||
"guildbackup": {
|
||||
"label": "Backups",
|
||||
"description": "Server-Backup und -Wiederherstellung"
|
||||
},
|
||||
"messages": {
|
||||
"label": "Nachrichten",
|
||||
"description": "Embeds oder Components-V2-Layouts in einen Kanal senden"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
@@ -361,12 +437,14 @@
|
||||
"type": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed",
|
||||
"IMAGE": "Bild-Karte"
|
||||
"IMAGE": "Bild-Karte",
|
||||
"COMPONENTS_V2": "Components V2"
|
||||
},
|
||||
"welcomeContent": "Nachrichtentext",
|
||||
"contentPlaceholder": "Willkommen {user} auf {server}!",
|
||||
"placeholdersHint": "Verfügbare Platzhalter: siehe Liste unten.",
|
||||
"welcomeEmbed": "Embed",
|
||||
"welcomeComponents": "Components-V2-Layout",
|
||||
"welcomeDmEnabled": "Willkommens-DM aktiviert",
|
||||
"dmContentPlaceholder": "Optionale private Nachricht an neue Mitglieder",
|
||||
"userAutoroles": "Autoroles für Nutzer",
|
||||
@@ -377,9 +455,16 @@
|
||||
"leaveDescription": "Nachricht und Kanal, wenn ein Mitglied den Server verlässt.",
|
||||
"leaveEnabledLabel": "Abschiedsnachricht aktiviert",
|
||||
"leaveChannelId": "Abschieds-Kanal-ID",
|
||||
"leaveType": "Nachrichtentyp",
|
||||
"leaveTypes": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed",
|
||||
"COMPONENTS_V2": "Components V2"
|
||||
},
|
||||
"leaveContent": "Nachrichtentext",
|
||||
"leaveContentPlaceholder": "Tschüss {user.tag} — bis bald auf {server}!",
|
||||
"leaveEmbed": "Embed",
|
||||
"leaveComponents": "Components-V2-Layout",
|
||||
"leaveEmbedTitlePlaceholder": "Auf Wiedersehen",
|
||||
"leaveEmbedDescriptionPlaceholder": "{user.tag} hat {server} verlassen."
|
||||
},
|
||||
@@ -550,12 +635,15 @@
|
||||
"responseType": "Antworttyp",
|
||||
"responseTypes": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed"
|
||||
"EMBED": "Embed",
|
||||
"COMPONENTS_V2": "Components V2"
|
||||
},
|
||||
"triggerWord": "Auslöse-Wort (optional)",
|
||||
"content": "Inhalt",
|
||||
"embed": "Embed",
|
||||
"components": "Components-V2-Layout",
|
||||
"embedRequired": "Für Embed-Tags sind Titel oder Beschreibung erforderlich.",
|
||||
"componentsRequired": "Components-V2-Tags benötigen mindestens einen Block.",
|
||||
"allowedRoleIds": "Erlaubte Rollen",
|
||||
"allowedChannelIds": "Erlaubte Kanal-IDs",
|
||||
"idsPlaceholder": "Eine oder mehrere IDs, kommagetrennt",
|
||||
@@ -658,10 +746,18 @@
|
||||
"runAt": "Ausführen am",
|
||||
"content": "Nachrichtentext",
|
||||
"embed": "Embed (optional)",
|
||||
"cronHint": "Cron-Ausdruck für wiederkehrende Nachrichten ODER ein einmaliges Datum/Uhrzeit angeben - nicht beides. Text und/oder Embed sind erforderlich.",
|
||||
"components": "Components-V2-Layout",
|
||||
"contentMode": "Nachrichtenformat",
|
||||
"contentModes": {
|
||||
"text": "Text",
|
||||
"embed": "Embed",
|
||||
"components_v2": "Components V2"
|
||||
},
|
||||
"componentsPreview": "(Components V2)",
|
||||
"cronHint": "Cron-Ausdruck für wiederkehrende Nachrichten ODER ein einmaliges Datum/Uhrzeit — nicht beides. Wähle Text, Embed oder Components V2 (gegenseitig ausgeschlossen).",
|
||||
"create": "Nachricht planen",
|
||||
"created": "Nachricht geplant.",
|
||||
"formIncomplete": "Bitte Kanal, Inhalt oder Embed sowie Cron-Ausdruck oder Datum/Uhrzeit ausfüllen.",
|
||||
"formIncomplete": "Bitte Kanal, Nachrichtenformat mit Inhalt sowie Cron-Ausdruck oder Datum/Uhrzeit ausfüllen.",
|
||||
"confirmDelete": "Diesen Zeitplan wirklich löschen?",
|
||||
"listTitle": "Geplante Nachrichten",
|
||||
"listDescription": "Anstehende und wiederkehrende geplante Nachrichten.",
|
||||
@@ -692,6 +788,23 @@
|
||||
"confirmDelete": "Dieses Backup wirklich löschen?",
|
||||
"ownerOnlyBadge": "Wiederherstellung nur durch Owner"
|
||||
},
|
||||
"messages": {
|
||||
"sendTitle": "Nachricht senden",
|
||||
"sendDescription": "Embed oder Components-V2-Layout über den Bot in einen Kanal posten.",
|
||||
"channelId": "Zielkanal",
|
||||
"channelRequired": "Bitte zuerst einen Kanal wählen.",
|
||||
"mode": "Nachrichtentyp",
|
||||
"modes": {
|
||||
"EMBED": "Embed",
|
||||
"COMPONENTS_V2": "Components V2"
|
||||
},
|
||||
"embedRequired": "Embed-Modus benötigt Titel, Beschreibung oder eine Farbe.",
|
||||
"componentsRequired": "Füge mindestens einen gültigen Components-V2-Block hinzu.",
|
||||
"send": "Nachricht senden",
|
||||
"sending": "Sende…",
|
||||
"sent": "Nachricht gesendet.",
|
||||
"sentWithId": "Nachricht gesendet (ID: {id})."
|
||||
},
|
||||
"commands": {
|
||||
"title": "Commands",
|
||||
"description": "Einzelne Slash-Commands aktivieren/deaktivieren und per Rolle, Kanal oder Cooldown einschränken.",
|
||||
|
||||
@@ -50,6 +50,78 @@
|
||||
"timestampPreview": "just now",
|
||||
"mediaHint": "Image URLs must start with http(s). For Welcome you can use {user.avatar} and {server.icon}."
|
||||
},
|
||||
"componentsV2Builder": {
|
||||
"blocks": "Layout blocks",
|
||||
"addBlock": "Add block…",
|
||||
"addNestedBlock": "Add nested block…",
|
||||
"empty": "No blocks yet — add a container or text block to get started.",
|
||||
"preview": "Preview",
|
||||
"previewEmptyText": "Text content…",
|
||||
"previewInvalidUrl": "Invalid image URL",
|
||||
"clear": "Clear layout",
|
||||
"clearHint": "Invalid blocks are dropped when saving.",
|
||||
"actionsTitle": "Button & select actions",
|
||||
"textPlaceholder": "Markdown-style text (Discord formatting)",
|
||||
"divider": "Show divider line",
|
||||
"spacing": "Spacing",
|
||||
"spacingSmall": "Small",
|
||||
"spacingLarge": "Large",
|
||||
"accentColor": "Accent color (decimal)",
|
||||
"spoiler": "Spoiler container",
|
||||
"sectionText": "Section text",
|
||||
"accessoryType": "Accessory",
|
||||
"accessoryThumbnail": "Thumbnail",
|
||||
"accessoryButton": "Button",
|
||||
"urlPlaceholder": "https://… or {user.avatar}",
|
||||
"addMediaItem": "Add image",
|
||||
"addButton": "Add button",
|
||||
"addSelect": "Add select…",
|
||||
"buttonLabel": "Button label",
|
||||
"linkUrl": "Link URL (https://…)",
|
||||
"actionId": "Action ID",
|
||||
"centralActionId": "Central action ID (optional)",
|
||||
"optionLabel": "Option label",
|
||||
"optionValue": "Option value",
|
||||
"optionActionId": "Per-option action ID (optional)",
|
||||
"addOption": "Add option",
|
||||
"removeOption": "Remove option",
|
||||
"selectPlaceholder": "Select placeholder",
|
||||
"replyContent": "Reply message",
|
||||
"roleAction": "Target role",
|
||||
"defaultButtonLabel": "Button",
|
||||
"blockTypes": {
|
||||
"container": "Container",
|
||||
"text_display": "Text",
|
||||
"separator": "Separator",
|
||||
"section": "Section",
|
||||
"media_gallery": "Media gallery",
|
||||
"action_row": "Action row",
|
||||
"button": "Button"
|
||||
},
|
||||
"buttonStyles": {
|
||||
"Primary": "Primary",
|
||||
"Secondary": "Secondary",
|
||||
"Success": "Success",
|
||||
"Danger": "Danger",
|
||||
"Link": "Link"
|
||||
},
|
||||
"selectTypes": {
|
||||
"string_select": "String select",
|
||||
"user_select": "User select",
|
||||
"role_select": "Role select",
|
||||
"channel_select": "Channel select",
|
||||
"mentionable_select": "Mentionable select"
|
||||
},
|
||||
"actionTypes": {
|
||||
"EPHEMERAL_REPLY": "Ephemeral reply",
|
||||
"PUBLIC_REPLY": "Public reply",
|
||||
"ASSIGN_ROLE": "Assign role",
|
||||
"REMOVE_ROLE": "Remove role",
|
||||
"TOGGLE_ROLE": "Toggle role",
|
||||
"ASSIGN_SELECTED_ROLES": "Assign selected roles",
|
||||
"NONE": "No action"
|
||||
}
|
||||
},
|
||||
"placeholders": {
|
||||
"toggle": "Show available placeholders",
|
||||
"hint": "Tap copy to insert. Only the placeholders listed here work in this field.",
|
||||
@@ -174,6 +246,10 @@
|
||||
"guildbackup": {
|
||||
"label": "Backups",
|
||||
"description": "Server backup and restore"
|
||||
},
|
||||
"messages": {
|
||||
"label": "Messages",
|
||||
"description": "Send embeds or Components V2 layouts to a channel"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
@@ -361,12 +437,14 @@
|
||||
"type": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed",
|
||||
"IMAGE": "Image card"
|
||||
"IMAGE": "Image card",
|
||||
"COMPONENTS_V2": "Components V2"
|
||||
},
|
||||
"welcomeContent": "Message content",
|
||||
"contentPlaceholder": "Welcome {user} to {server}!",
|
||||
"placeholdersHint": "Available placeholders: see the list below.",
|
||||
"welcomeEmbed": "Embed",
|
||||
"welcomeComponents": "Components V2 layout",
|
||||
"welcomeDmEnabled": "Welcome DM enabled",
|
||||
"dmContentPlaceholder": "Optional private message to new members",
|
||||
"userAutoroles": "Autoroles for users",
|
||||
@@ -377,9 +455,16 @@
|
||||
"leaveDescription": "Message and channel when a member leaves the server.",
|
||||
"leaveEnabledLabel": "Leave message enabled",
|
||||
"leaveChannelId": "Leave channel ID",
|
||||
"leaveType": "Message type",
|
||||
"leaveTypes": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed",
|
||||
"COMPONENTS_V2": "Components V2"
|
||||
},
|
||||
"leaveContent": "Message content",
|
||||
"leaveContentPlaceholder": "Goodbye {user.tag} — see you around on {server}!",
|
||||
"leaveEmbed": "Embed",
|
||||
"leaveComponents": "Components V2 layout",
|
||||
"leaveEmbedTitlePlaceholder": "Goodbye",
|
||||
"leaveEmbedDescriptionPlaceholder": "{user.tag} left {server}."
|
||||
},
|
||||
@@ -550,12 +635,15 @@
|
||||
"responseType": "Response type",
|
||||
"responseTypes": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed"
|
||||
"EMBED": "Embed",
|
||||
"COMPONENTS_V2": "Components V2"
|
||||
},
|
||||
"triggerWord": "Trigger word (optional)",
|
||||
"content": "Content",
|
||||
"embed": "Embed",
|
||||
"components": "Components V2 layout",
|
||||
"embedRequired": "Embed tags need a title or description.",
|
||||
"componentsRequired": "Components V2 tags need at least one block.",
|
||||
"allowedRoleIds": "Allowed roles",
|
||||
"allowedChannelIds": "Allowed channel IDs",
|
||||
"idsPlaceholder": "One or more IDs, comma-separated",
|
||||
@@ -658,10 +746,18 @@
|
||||
"runAt": "Run at",
|
||||
"content": "Message content",
|
||||
"embed": "Embed (optional)",
|
||||
"cronHint": "Set a cron expression for recurring messages, or a one-time date/time - not both. Text and/or embed is required.",
|
||||
"components": "Components V2 layout",
|
||||
"contentMode": "Message format",
|
||||
"contentModes": {
|
||||
"text": "Text",
|
||||
"embed": "Embed",
|
||||
"components_v2": "Components V2"
|
||||
},
|
||||
"componentsPreview": "(Components V2)",
|
||||
"cronHint": "Set a cron expression for recurring messages, or a one-time date/time — not both. Choose text, embed, or Components V2 (mutually exclusive).",
|
||||
"create": "Schedule message",
|
||||
"created": "Message scheduled.",
|
||||
"formIncomplete": "Please fill channel, content or embed, and either a cron expression or a date/time.",
|
||||
"formIncomplete": "Please fill channel, choose a message format with content, and either a cron expression or a date/time.",
|
||||
"confirmDelete": "Really delete this schedule?",
|
||||
"listTitle": "Scheduled messages",
|
||||
"listDescription": "Upcoming and recurring scheduled messages.",
|
||||
@@ -692,6 +788,23 @@
|
||||
"confirmDelete": "Really delete this backup?",
|
||||
"ownerOnlyBadge": "Owner-only restore"
|
||||
},
|
||||
"messages": {
|
||||
"sendTitle": "Send a message",
|
||||
"sendDescription": "Post an embed or Components V2 layout to a channel via the bot.",
|
||||
"channelId": "Target channel",
|
||||
"channelRequired": "Select a channel first.",
|
||||
"mode": "Message type",
|
||||
"modes": {
|
||||
"EMBED": "Embed",
|
||||
"COMPONENTS_V2": "Components V2"
|
||||
},
|
||||
"embedRequired": "Embed mode needs title, description, or a color.",
|
||||
"componentsRequired": "Add at least one valid Components V2 block.",
|
||||
"send": "Send message",
|
||||
"sending": "Sending…",
|
||||
"sent": "Message sent.",
|
||||
"sentWithId": "Message sent (ID: {id})."
|
||||
},
|
||||
"commands": {
|
||||
"title": "Commands",
|
||||
"description": "Enable/disable individual slash commands and restrict them by role, channel or cooldown.",
|
||||
|
||||
Reference in New Issue
Block a user