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:
TheOnlyMace
2026-07-22 22:55:37 +02:00
parent 95eed45ec4
commit 4e135bcf43
46 changed files with 4505 additions and 194 deletions

View 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);
}
}

View 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>
);
}

View 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>
);
}

View File

@@ -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 {

View 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>
);
}

View File

@@ -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" />

View File

@@ -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">

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

@@ -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',

View 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
});
}

View File

@@ -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

View File

@@ -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 } : {}),

View File

@@ -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);

View File

@@ -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) => ({

View File

@@ -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;
/**

View File

@@ -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.",

View File

@@ -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.",