Enhance embed functionality across scheduler and welcome components
- Updated SchedulerManager and WelcomeForm to integrate DiscordEmbedBuilder for managing embeds, replacing JSON text areas with a more user-friendly interface. - Modified data handling in the scheduler and welcome configurations to support embed structures, ensuring proper parsing and validation. - Updated localization files to reflect changes in embed handling, improving user guidance and clarity in both English and German. - Enhanced validation schemas to require embed titles or descriptions where necessary, ensuring robust data integrity.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type { ScheduledMessageDashboard } from '@nexumi/shared';
|
||||
import type { ScheduledMessageDashboard, WelcomeEmbed } from '@nexumi/shared';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -9,6 +9,7 @@ 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 { 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';
|
||||
@@ -17,6 +18,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
const EMPTY_DRAFT = {
|
||||
channelId: '',
|
||||
content: '',
|
||||
embed: null as WelcomeEmbed | null,
|
||||
rolePingId: '',
|
||||
cron: '',
|
||||
runAt: ''
|
||||
@@ -35,6 +37,19 @@ function formatTiming(schedule: ScheduledMessageDashboard, t: (key: string) => s
|
||||
return '—';
|
||||
}
|
||||
|
||||
function schedulePreview(schedule: ScheduledMessageDashboard, t: (key: string) => string): string {
|
||||
if (schedule.content?.trim()) {
|
||||
return schedule.content;
|
||||
}
|
||||
if (schedule.embed?.title?.trim()) {
|
||||
return schedule.embed.title;
|
||||
}
|
||||
if (schedule.embed?.description?.trim()) {
|
||||
return schedule.embed.description;
|
||||
}
|
||||
return t('modulePages.scheduler.noContent');
|
||||
}
|
||||
|
||||
interface SchedulerManagerProps {
|
||||
guildId: string;
|
||||
initialSchedules: ScheduledMessageDashboard[];
|
||||
@@ -47,7 +62,9 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!draft.channelId.trim() || !draft.content.trim() || (!draft.cron.trim() && !draft.runAt)) {
|
||||
const embed = normalizeEmbed(draft.embed);
|
||||
const hasContent = Boolean(draft.content.trim()) || Boolean(embed?.title) || Boolean(embed?.description);
|
||||
if (!draft.channelId.trim() || !hasContent || (!draft.cron.trim() && !draft.runAt)) {
|
||||
toast.error(t('modulePages.scheduler.formIncomplete'));
|
||||
return;
|
||||
}
|
||||
@@ -58,7 +75,8 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
channelId: draft.channelId.trim(),
|
||||
content: draft.content.trim(),
|
||||
content: draft.content.trim() || null,
|
||||
embed,
|
||||
rolePingId: draft.rolePingId.trim() || undefined,
|
||||
cron: draft.cron.trim() || null,
|
||||
runAt: draft.cron.trim() ? null : draft.runAt ? new Date(draft.runAt).toISOString() : null
|
||||
@@ -151,6 +169,10 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.scheduler.embed')}</Label>
|
||||
<DiscordEmbedBuilder value={draft.embed} onChange={(embed) => setDraft((prev) => ({ ...prev, embed }))} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.scheduler.cronHint')}</p>
|
||||
<Button type="button" onClick={handleCreate} disabled={creating}>
|
||||
<Plus className="size-4" />
|
||||
@@ -172,7 +194,7 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
||||
{schedules.map((schedule) => (
|
||||
<div key={schedule.id} className="flex flex-wrap items-start justify-between gap-3 rounded-md border border-border p-4">
|
||||
<div>
|
||||
<p className="font-medium">{schedule.content ?? t('modulePages.scheduler.noContent')}</p>
|
||||
<p className="font-medium">{schedulePreview(schedule, t)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
#{schedule.channelId} · {formatTiming(schedule, t)}
|
||||
{schedule.rolePingId ? ` · @${schedule.rolePingId}` : ''}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import type { WelcomeConfigDashboard } from '@nexumi/shared';
|
||||
import type { 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 {
|
||||
DiscordEmbedBuilder,
|
||||
embedFromUnknown,
|
||||
normalizeEmbed
|
||||
} from '@/components/ui/discord-embed-builder';
|
||||
import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -14,55 +19,33 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||
import { SettingsForm } from '@/components/settings/settings-form';
|
||||
|
||||
interface LocalWelcomeValue extends Omit<WelcomeConfigDashboard, 'welcomeEmbed' | 'leaveEmbed'> {
|
||||
welcomeEmbedText: string;
|
||||
leaveEmbedText: string;
|
||||
}
|
||||
const WELCOME_PREVIEW_VARS = {
|
||||
user: '@Alex',
|
||||
'user.name': 'Alex',
|
||||
'user.tag': 'Alex',
|
||||
'user.id': '123456789012345678',
|
||||
server: 'Nexumi',
|
||||
memberCount: '128'
|
||||
};
|
||||
|
||||
function toJsonText(value: Record<string, unknown> | null | undefined): string {
|
||||
return value ? JSON.stringify(value, null, 2) : '';
|
||||
interface LocalWelcomeValue extends Omit<WelcomeConfigDashboard, 'welcomeEmbed' | 'leaveEmbed'> {
|
||||
welcomeEmbed: WelcomeEmbed | null;
|
||||
leaveEmbed: WelcomeEmbed | null;
|
||||
}
|
||||
|
||||
function toLocal(config: WelcomeConfigDashboard): LocalWelcomeValue {
|
||||
const { welcomeEmbed, leaveEmbed, ...rest } = config;
|
||||
return {
|
||||
...rest,
|
||||
welcomeEmbedText: toJsonText(welcomeEmbed),
|
||||
leaveEmbedText: toJsonText(leaveEmbed)
|
||||
...config,
|
||||
welcomeEmbed: embedFromUnknown(config.welcomeEmbed),
|
||||
leaveEmbed: embedFromUnknown(config.leaveEmbed)
|
||||
};
|
||||
}
|
||||
|
||||
function parseEmbedJson(text: string): { value: Record<string, unknown> | null; error?: string } {
|
||||
if (!text.trim()) {
|
||||
return { value: null };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return { value: parsed as Record<string, unknown> };
|
||||
}
|
||||
return { value: null, error: 'Invalid embed JSON (must be an object)' };
|
||||
} catch {
|
||||
return { value: null, error: 'Invalid embed JSON' };
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWelcome(guildId: string, value: LocalWelcomeValue): Promise<SettingsSaveResult> {
|
||||
const welcomeEmbed = parseEmbedJson(value.welcomeEmbedText);
|
||||
if (welcomeEmbed.error) {
|
||||
return { ok: false, error: welcomeEmbed.error };
|
||||
}
|
||||
const leaveEmbed = parseEmbedJson(value.leaveEmbedText);
|
||||
if (leaveEmbed.error) {
|
||||
return { ok: false, error: leaveEmbed.error };
|
||||
}
|
||||
|
||||
const { welcomeEmbedText: _welcomeEmbedText, leaveEmbedText: _leaveEmbedText, ...rest } = value;
|
||||
|
||||
const payload: WelcomeConfigDashboard = {
|
||||
...rest,
|
||||
welcomeEmbed: welcomeEmbed.value,
|
||||
leaveEmbed: leaveEmbed.value
|
||||
...value,
|
||||
welcomeEmbed: normalizeEmbed(value.welcomeEmbed),
|
||||
leaveEmbed: normalizeEmbed(value.leaveEmbed)
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -154,12 +137,10 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
||||
<FieldAnchor id="field-welcome-embed">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.welcomeEmbed')}</Label>
|
||||
<Textarea
|
||||
className="font-mono"
|
||||
rows={6}
|
||||
value={value.welcomeEmbedText}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, welcomeEmbedText: event.target.value }))}
|
||||
placeholder='{ "title": "Welcome!", "color": 6591981 }'
|
||||
<DiscordEmbedBuilder
|
||||
value={value.welcomeEmbed}
|
||||
onChange={(welcomeEmbed) => setValue((prev) => ({ ...prev, welcomeEmbed }))}
|
||||
previewVars={WELCOME_PREVIEW_VARS}
|
||||
/>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
@@ -260,12 +241,10 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
||||
<FieldAnchor id="field-leave-embed">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.leaveEmbed')}</Label>
|
||||
<Textarea
|
||||
className="font-mono"
|
||||
rows={6}
|
||||
value={value.leaveEmbedText}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, leaveEmbedText: event.target.value }))}
|
||||
placeholder='{ "title": "Goodbye", "color": 15158332 }'
|
||||
<DiscordEmbedBuilder
|
||||
value={value.leaveEmbed}
|
||||
onChange={(leaveEmbed) => setValue((prev) => ({ ...prev, leaveEmbed }))}
|
||||
previewVars={WELCOME_PREVIEW_VARS}
|
||||
/>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
|
||||
191
apps/webui/src/components/ui/discord-embed-builder.tsx
Normal file
191
apps/webui/src/components/ui/discord-embed-builder.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import type { WelcomeEmbed } from '@nexumi/shared';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const DEFAULT_EMBED_COLOR = 0x6366f1;
|
||||
|
||||
export function emptyEmbed(): WelcomeEmbed {
|
||||
return {
|
||||
title: '',
|
||||
description: '',
|
||||
color: DEFAULT_EMBED_COLOR
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeEmbed(value: WelcomeEmbed | null | undefined): WelcomeEmbed | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const title = value.title?.trim() || undefined;
|
||||
const description = value.description?.trim() || undefined;
|
||||
const color = typeof value.color === 'number' && Number.isFinite(value.color) ? value.color : undefined;
|
||||
if (!title && !description && color === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { title, description, color };
|
||||
}
|
||||
|
||||
export function embedFromUnknown(raw: unknown): WelcomeEmbed | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return null;
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
return normalizeEmbed({
|
||||
title: typeof record.title === 'string' ? record.title : undefined,
|
||||
description: typeof record.description === 'string' ? record.description : undefined,
|
||||
color: typeof record.color === 'number' ? record.color : undefined
|
||||
});
|
||||
}
|
||||
|
||||
function colorToHex(color: number | undefined): string {
|
||||
const value = typeof color === 'number' && Number.isFinite(color) ? color : DEFAULT_EMBED_COLOR;
|
||||
return `#${Math.max(0, Math.min(0xffffff, Math.trunc(value))).toString(16).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
function hexToColor(hex: string): number {
|
||||
const cleaned = hex.replace('#', '').trim();
|
||||
const parsed = Number.parseInt(cleaned, 16);
|
||||
return Number.isFinite(parsed) ? parsed : DEFAULT_EMBED_COLOR;
|
||||
}
|
||||
|
||||
function applyPreviewVars(template: string, vars: Record<string, string>): string {
|
||||
return Object.entries(vars).reduce(
|
||||
(acc, [key, value]) => acc.replaceAll(`{${key}}`, value),
|
||||
template
|
||||
);
|
||||
}
|
||||
|
||||
interface DiscordEmbedBuilderProps {
|
||||
value: WelcomeEmbed | null;
|
||||
onChange: (next: WelcomeEmbed | null) => void;
|
||||
className?: string;
|
||||
/** Sample values for preview placeholder substitution (e.g. user → @Alex). */
|
||||
previewVars?: Record<string, string>;
|
||||
showClearHint?: boolean;
|
||||
}
|
||||
|
||||
export function DiscordEmbedBuilder({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
previewVars,
|
||||
showClearHint = true
|
||||
}: DiscordEmbedBuilderProps) {
|
||||
const t = useTranslations();
|
||||
const draft = value ?? emptyEmbed();
|
||||
const colorHex = colorToHex(draft.color);
|
||||
const previewTitle = draft.title
|
||||
? applyPreviewVars(draft.title, previewVars ?? {})
|
||||
: t('embedBuilder.previewTitlePlaceholder');
|
||||
const previewDescription = draft.description
|
||||
? applyPreviewVars(draft.description, previewVars ?? {})
|
||||
: t('embedBuilder.previewDescriptionPlaceholder');
|
||||
const hasContent = Boolean(draft.title?.trim() || draft.description?.trim());
|
||||
|
||||
function patch(next: Partial<WelcomeEmbed>) {
|
||||
const merged: WelcomeEmbed = {
|
||||
title: next.title !== undefined ? next.title : draft.title,
|
||||
description: next.description !== undefined ? next.description : draft.description,
|
||||
color: next.color !== undefined ? next.color : draft.color
|
||||
};
|
||||
onChange(merged);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-4 lg:grid-cols-2', className)}>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="embed-title">{t('embedBuilder.title')}</Label>
|
||||
<Input
|
||||
id="embed-title"
|
||||
value={draft.title ?? ''}
|
||||
maxLength={256}
|
||||
placeholder={t('embedBuilder.titlePlaceholder')}
|
||||
onChange={(event) => patch({ title: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="embed-description">{t('embedBuilder.description')}</Label>
|
||||
<Textarea
|
||||
id="embed-description"
|
||||
rows={5}
|
||||
maxLength={4096}
|
||||
value={draft.description ?? ''}
|
||||
placeholder={t('embedBuilder.descriptionPlaceholder')}
|
||||
onChange={(event) => patch({ description: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="embed-color">{t('embedBuilder.color')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="embed-color"
|
||||
type="color"
|
||||
className="size-9 cursor-pointer rounded-md border border-input bg-transparent p-1"
|
||||
value={colorHex}
|
||||
onChange={(event) => patch({ color: hexToColor(event.target.value) })}
|
||||
/>
|
||||
<Input
|
||||
value={colorHex.toUpperCase()}
|
||||
maxLength={7}
|
||||
className="font-mono uppercase"
|
||||
onChange={(event) => {
|
||||
const next = event.target.value.trim();
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(next)) {
|
||||
patch({ color: hexToColor(next) });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showClearHint ? (
|
||||
<p className="text-xs text-muted-foreground">{t('embedBuilder.clearHint')}</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
{t('embedBuilder.clear')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('embedBuilder.preview')}</Label>
|
||||
<div className="rounded-md border border-[#1e1f22] bg-[#313338] p-3">
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-[4px] bg-[#2b2d31]',
|
||||
!hasContent && 'opacity-70'
|
||||
)}
|
||||
style={{ borderLeft: `4px solid ${colorHex}` }}
|
||||
>
|
||||
<div className="space-y-2 px-3 py-2">
|
||||
<p
|
||||
className={cn(
|
||||
'text-base font-semibold leading-5',
|
||||
hasContent && draft.title?.trim() ? 'text-white' : 'text-[#b5bac1]'
|
||||
)}
|
||||
>
|
||||
{previewTitle || '\u00a0'}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'whitespace-pre-wrap text-sm leading-5',
|
||||
hasContent && draft.description?.trim() ? 'text-[#dbdee1]' : 'text-[#949ba4]'
|
||||
)}
|
||||
>
|
||||
{previewDescription || '\u00a0'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user