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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ScheduledMessageDashboard, ScheduledMessageDashboardCreate } from '@nexumi/shared';
|
||||
import type { ScheduledMessage } from '@prisma/client';
|
||||
import type { ScheduledMessageDashboard, ScheduledMessageDashboardCreate, WelcomeEmbed } from '@nexumi/shared';
|
||||
import { Prisma, type ScheduledMessage } from '@prisma/client';
|
||||
import { prisma } from '../prisma';
|
||||
import { getScheduleQueue } from '../queues';
|
||||
|
||||
@@ -9,12 +9,27 @@ export class SchedulerValidationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function parseEmbed(raw: unknown): WelcomeEmbed | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return null;
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
const title = typeof record.title === 'string' ? record.title : undefined;
|
||||
const description = typeof record.description === 'string' ? record.description : undefined;
|
||||
const color = typeof record.color === 'number' ? record.color : undefined;
|
||||
if (!title && !description && color === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { title, description, color };
|
||||
}
|
||||
|
||||
function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {
|
||||
return {
|
||||
id: schedule.id,
|
||||
channelId: schedule.channelId,
|
||||
creatorId: schedule.creatorId,
|
||||
content: schedule.content,
|
||||
embed: parseEmbed(schedule.embed),
|
||||
rolePingId: schedule.rolePingId,
|
||||
cron: schedule.cron,
|
||||
runAt: schedule.runAt?.toISOString() ?? null,
|
||||
@@ -69,6 +84,10 @@ export async function createScheduledMessageDashboard(
|
||||
channelId: input.channelId,
|
||||
creatorId,
|
||||
content: input.content?.trim() || null,
|
||||
embed:
|
||||
input.embed === undefined || input.embed === null
|
||||
? Prisma.JsonNull
|
||||
: (input.embed as Prisma.InputJsonValue),
|
||||
rolePingId: input.rolePingId || null,
|
||||
cron,
|
||||
runAt
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TagDashboard, TagDashboardCreate, TagDashboardUpdate } from '@nexumi/shared';
|
||||
import type { Tag } from '@prisma/client';
|
||||
import type { TagDashboard, TagDashboardCreate, TagDashboardUpdate, WelcomeEmbed } from '@nexumi/shared';
|
||||
import { Prisma, type Tag } from '@prisma/client';
|
||||
import { prisma } from '../prisma';
|
||||
import { assertPremiumLimit, PremiumLimitError } from '../premium';
|
||||
|
||||
@@ -15,11 +15,26 @@ export class TagPremiumError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function parseEmbed(raw: unknown): WelcomeEmbed | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return null;
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
const title = typeof record.title === 'string' ? record.title : undefined;
|
||||
const description = typeof record.description === 'string' ? record.description : undefined;
|
||||
const color = typeof record.color === 'number' ? record.color : undefined;
|
||||
if (!title && !description && color === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { title, description, color };
|
||||
}
|
||||
|
||||
function toDashboard(tag: Tag): TagDashboard {
|
||||
return {
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
content: tag.content,
|
||||
embed: parseEmbed(tag.embed),
|
||||
responseType: tag.responseType as TagDashboard['responseType'],
|
||||
triggerWord: tag.triggerWord,
|
||||
allowedRoleIds: tag.allowedRoleIds,
|
||||
@@ -53,6 +68,10 @@ export async function createTag(
|
||||
guildId,
|
||||
name: input.name,
|
||||
content: input.content ?? null,
|
||||
embed:
|
||||
input.embed === undefined || input.embed === null
|
||||
? Prisma.JsonNull
|
||||
: (input.embed as Prisma.InputJsonValue),
|
||||
responseType: input.responseType,
|
||||
triggerWord: input.triggerWord ?? null,
|
||||
allowedRoleIds: input.allowedRoleIds,
|
||||
@@ -85,6 +104,9 @@ export async function updateTag(
|
||||
data: {
|
||||
...(patch.name !== undefined ? { name: patch.name } : {}),
|
||||
...(patch.content !== undefined ? { content: patch.content } : {}),
|
||||
...(patch.embed !== undefined
|
||||
? { embed: patch.embed === null ? Prisma.JsonNull : (patch.embed as Prisma.InputJsonValue) }
|
||||
: {}),
|
||||
...(patch.responseType !== undefined ? { responseType: patch.responseType } : {}),
|
||||
...(patch.triggerWord !== undefined ? { triggerWord: patch.triggerWord } : {}),
|
||||
...(patch.allowedRoleIds !== undefined ? { allowedRoleIds: patch.allowedRoleIds } : {}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WelcomeConfigDashboard, WelcomeConfigDashboardPatch } from '@nexumi/shared';
|
||||
import type { WelcomeConfigDashboard, WelcomeConfigDashboardPatch, WelcomeEmbed } from '@nexumi/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { prisma } from '../prisma';
|
||||
|
||||
@@ -11,6 +11,20 @@ async function ensureWelcomeConfig(guildId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function parseEmbed(raw: unknown): WelcomeEmbed | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return null;
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
const title = typeof record.title === 'string' ? record.title : undefined;
|
||||
const description = typeof record.description === 'string' ? record.description : undefined;
|
||||
const color = typeof record.color === 'number' ? record.color : undefined;
|
||||
if (!title && !description && color === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { title, description, color };
|
||||
}
|
||||
|
||||
function toDashboard(config: Awaited<ReturnType<typeof ensureWelcomeConfig>>): WelcomeConfigDashboard {
|
||||
return {
|
||||
welcomeEnabled: config.welcomeEnabled,
|
||||
@@ -19,9 +33,9 @@ function toDashboard(config: Awaited<ReturnType<typeof ensureWelcomeConfig>>): W
|
||||
leaveChannelId: config.leaveChannelId ?? '',
|
||||
welcomeType: config.welcomeType as WelcomeConfigDashboard['welcomeType'],
|
||||
welcomeContent: config.welcomeContent,
|
||||
welcomeEmbed: (config.welcomeEmbed as Record<string, unknown> | null) ?? null,
|
||||
welcomeEmbed: parseEmbed(config.welcomeEmbed),
|
||||
leaveContent: config.leaveContent,
|
||||
leaveEmbed: (config.leaveEmbed as Record<string, unknown> | null) ?? null,
|
||||
leaveEmbed: parseEmbed(config.leaveEmbed),
|
||||
welcomeDmEnabled: config.welcomeDmEnabled,
|
||||
welcomeDmContent: config.welcomeDmContent,
|
||||
userAutoroleIds: config.userAutoroleIds,
|
||||
|
||||
@@ -21,7 +21,19 @@
|
||||
"close": "Schließen",
|
||||
"searchSelect": "Suchen und auswählen…",
|
||||
"noResults": "Keine Treffer",
|
||||
"clearSelection": "Auswahl löschen"
|
||||
"clearSelection": "Auswahl löschen"
|
||||
},
|
||||
"embedBuilder": {
|
||||
"title": "Titel",
|
||||
"titlePlaceholder": "Willkommen!",
|
||||
"description": "Beschreibung",
|
||||
"descriptionPlaceholder": "Schön, dass du da bist, {user}.",
|
||||
"color": "Farbe",
|
||||
"preview": "Vorschau",
|
||||
"previewTitlePlaceholder": "Embed-Titel",
|
||||
"previewDescriptionPlaceholder": "Embed-Beschreibung erscheint hier…",
|
||||
"clear": "Embed leeren",
|
||||
"clearHint": "Leere Felder speichern kein Embed."
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -276,7 +288,7 @@
|
||||
"welcomeContent": "Nachrichtentext",
|
||||
"contentPlaceholder": "Willkommen {user} auf {server}!",
|
||||
"placeholdersHint": "Verfügbare Platzhalter: {user}, {user.name}, {user.tag}, {user.id}, {server}, {memberCount}",
|
||||
"welcomeEmbed": "Embed (JSON, optional)",
|
||||
"welcomeEmbed": "Embed",
|
||||
"welcomeDmEnabled": "Willkommens-DM aktiviert",
|
||||
"dmContentPlaceholder": "Optionale private Nachricht an neue Mitglieder",
|
||||
"userAutoroles": "Autoroles für Nutzer",
|
||||
@@ -288,7 +300,7 @@
|
||||
"leaveEnabledLabel": "Abschiedsnachricht aktiviert",
|
||||
"leaveChannelId": "Abschieds-Kanal-ID",
|
||||
"leaveContent": "Nachrichtentext",
|
||||
"leaveEmbed": "Embed (JSON, optional)"
|
||||
"leaveEmbed": "Embed"
|
||||
},
|
||||
"verification": {
|
||||
"title": "Verifizierung",
|
||||
@@ -461,7 +473,9 @@
|
||||
},
|
||||
"triggerWord": "Auslöse-Wort (optional)",
|
||||
"content": "Inhalt",
|
||||
"allowedRoleIds": "Erlaubte Rollen-IDs",
|
||||
"embed": "Embed",
|
||||
"embedRequired": "Für Embed-Tags sind Titel oder Beschreibung erforderlich.",
|
||||
"allowedRoleIds": "Erlaubte Rollen",
|
||||
"allowedChannelIds": "Erlaubte Kanal-IDs",
|
||||
"idsPlaceholder": "Eine oder mehrere IDs, kommagetrennt",
|
||||
"create": "Tag erstellen",
|
||||
@@ -562,10 +576,11 @@
|
||||
"cron": "Cron-Ausdruck",
|
||||
"runAt": "Ausführen am",
|
||||
"content": "Nachrichtentext",
|
||||
"cronHint": "Cron-Ausdruck für wiederkehrende Nachrichten ODER ein einmaliges Datum/Uhrzeit angeben - nicht beides.",
|
||||
"embed": "Embed (optional)",
|
||||
"cronHint": "Cron-Ausdruck für wiederkehrende Nachrichten ODER ein einmaliges Datum/Uhrzeit angeben - nicht beides. Text und/oder Embed sind erforderlich.",
|
||||
"create": "Nachricht planen",
|
||||
"created": "Nachricht geplant.",
|
||||
"formIncomplete": "Bitte Kanal, Inhalt sowie Cron-Ausdruck oder Datum/Uhrzeit ausfüllen.",
|
||||
"formIncomplete": "Bitte Kanal, Inhalt oder Embed sowie Cron-Ausdruck oder Datum/Uhrzeit ausfüllen.",
|
||||
"confirmDelete": "Diesen Zeitplan wirklich löschen?",
|
||||
"listTitle": "Geplante Nachrichten",
|
||||
"listDescription": "Anstehende und wiederkehrende geplante Nachrichten.",
|
||||
|
||||
@@ -21,7 +21,19 @@
|
||||
"optional": "Optional",
|
||||
"searchSelect": "Search and select…",
|
||||
"noResults": "No results",
|
||||
"clearSelection": "Clear selection"
|
||||
"clearSelection": "Clear selection"
|
||||
},
|
||||
"embedBuilder": {
|
||||
"title": "Title",
|
||||
"titlePlaceholder": "Welcome!",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Glad you're here, {user}.",
|
||||
"color": "Color",
|
||||
"preview": "Preview",
|
||||
"previewTitlePlaceholder": "Embed title",
|
||||
"previewDescriptionPlaceholder": "Embed description appears here…",
|
||||
"clear": "Clear embed",
|
||||
"clearHint": "Empty fields do not save an embed."
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -276,7 +288,7 @@
|
||||
"welcomeContent": "Message content",
|
||||
"contentPlaceholder": "Welcome {user} to {server}!",
|
||||
"placeholdersHint": "Available placeholders: {user}, {user.name}, {user.tag}, {user.id}, {server}, {memberCount}",
|
||||
"welcomeEmbed": "Embed (JSON, optional)",
|
||||
"welcomeEmbed": "Embed",
|
||||
"welcomeDmEnabled": "Welcome DM enabled",
|
||||
"dmContentPlaceholder": "Optional private message to new members",
|
||||
"userAutoroles": "Autoroles for users",
|
||||
@@ -288,7 +300,7 @@
|
||||
"leaveEnabledLabel": "Leave message enabled",
|
||||
"leaveChannelId": "Leave channel ID",
|
||||
"leaveContent": "Message content",
|
||||
"leaveEmbed": "Embed (JSON, optional)"
|
||||
"leaveEmbed": "Embed"
|
||||
},
|
||||
"verification": {
|
||||
"title": "Verification",
|
||||
@@ -461,7 +473,9 @@
|
||||
},
|
||||
"triggerWord": "Trigger word (optional)",
|
||||
"content": "Content",
|
||||
"allowedRoleIds": "Allowed role IDs",
|
||||
"embed": "Embed",
|
||||
"embedRequired": "Embed tags need a title or description.",
|
||||
"allowedRoleIds": "Allowed roles",
|
||||
"allowedChannelIds": "Allowed channel IDs",
|
||||
"idsPlaceholder": "One or more IDs, comma-separated",
|
||||
"create": "Create tag",
|
||||
@@ -562,10 +576,11 @@
|
||||
"cron": "Cron expression",
|
||||
"runAt": "Run at",
|
||||
"content": "Message content",
|
||||
"cronHint": "Set a cron expression for recurring messages, or a one-time date/time - not both.",
|
||||
"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.",
|
||||
"create": "Schedule message",
|
||||
"created": "Message scheduled.",
|
||||
"formIncomplete": "Please fill in the channel, content and either a cron expression or a date/time.",
|
||||
"formIncomplete": "Please fill channel, content or embed, and either a cron expression or a date/time.",
|
||||
"confirmDelete": "Really delete this schedule?",
|
||||
"listTitle": "Scheduled messages",
|
||||
"listDescription": "Upcoming and recurring scheduled messages.",
|
||||
|
||||
@@ -126,6 +126,21 @@
|
||||
- [ ] Per-Command Channel/Rollen per Dropdown setzen
|
||||
- [ ] Deaktivierter Command / Cooldown antwortet ephemeral
|
||||
|
||||
## Post-Phase – Discord Embed-Builder (Status: implementiert)
|
||||
|
||||
### Abgeschlossen (Code)
|
||||
|
||||
- Wiederverwendbare `DiscordEmbedBuilder`-Komponente (Titel, Beschreibung, Farbe) inkl. Live-Vorschau
|
||||
- Welcome/Leave: JSON-Textareas durch Embed-Builder ersetzt
|
||||
- Tags: Embed-Felder bei Antworttyp EMBED (Schema + Persistenz)
|
||||
- Scheduler: optionales Embed beim Erstellen (Schema + Persistenz)
|
||||
|
||||
### Manuell testen
|
||||
|
||||
- [ ] Welcome/Leave-Embed speichern und in Discord prüfen
|
||||
- [ ] Tag mit Antworttyp Embed erstellen/bearbeiten
|
||||
- [ ] Geplante Nachricht mit Embed (optional mit Text) anlegen
|
||||
|
||||
## Nächster geplanter Schritt
|
||||
|
||||
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { WelcomeEmbedSchema } from './phase2.js';
|
||||
import {
|
||||
SelfRoleBehaviorSchema,
|
||||
SelfRoleEntrySchema,
|
||||
@@ -9,6 +10,10 @@ import {
|
||||
} from './phase4.js';
|
||||
import { SocialFeedTypeSchema } from './phase5.js';
|
||||
|
||||
/** Dashboard embed payload shared by Welcome, Tags, Scheduler, etc. */
|
||||
export const DashboardEmbedSchema = WelcomeEmbedSchema;
|
||||
export type DashboardEmbed = z.infer<typeof DashboardEmbedSchema>;
|
||||
|
||||
/**
|
||||
* Discord snowflake ID: 17-20 digit numeric string.
|
||||
*/
|
||||
@@ -192,9 +197,9 @@ export const WelcomeConfigDashboardSchema = z.object({
|
||||
leaveChannelId: OptionalSnowflakeSchema,
|
||||
welcomeType: WelcomeMessageTypeDashboardSchema,
|
||||
welcomeContent: z.string().max(2000).nullable().optional(),
|
||||
welcomeEmbed: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
welcomeEmbed: DashboardEmbedSchema.nullable().optional(),
|
||||
leaveContent: z.string().max(2000).nullable().optional(),
|
||||
leaveEmbed: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
leaveEmbed: DashboardEmbedSchema.nullable().optional(),
|
||||
welcomeDmEnabled: z.boolean(),
|
||||
welcomeDmContent: z.string().max(2000).nullable().optional(),
|
||||
userAutoroleIds: z.array(z.string()),
|
||||
@@ -390,6 +395,7 @@ export const TagDashboardSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().min(1).max(50),
|
||||
content: z.string().max(2000).nullable().optional(),
|
||||
embed: DashboardEmbedSchema.nullable().optional(),
|
||||
responseType: TagResponseTypeSchema,
|
||||
triggerWord: z.string().max(100).nullable().optional(),
|
||||
allowedRoleIds: z.array(z.string()),
|
||||
@@ -397,10 +403,16 @@ export const TagDashboardSchema = z.object({
|
||||
});
|
||||
export type TagDashboard = z.infer<typeof TagDashboardSchema>;
|
||||
|
||||
export const TagDashboardCreateSchema = TagDashboardSchema.omit({ id: true });
|
||||
export const TagDashboardCreateSchema = TagDashboardSchema.omit({ id: true }).refine(
|
||||
(value) =>
|
||||
value.responseType !== 'EMBED' ||
|
||||
Boolean(value.embed?.title?.trim()) ||
|
||||
Boolean(value.embed?.description?.trim()),
|
||||
{ message: 'Embed title or description is required for EMBED tags', path: ['embed'] }
|
||||
);
|
||||
export type TagDashboardCreate = z.infer<typeof TagDashboardCreateSchema>;
|
||||
|
||||
export const TagDashboardUpdateSchema = nonEmptyPatch(TagDashboardCreateSchema);
|
||||
export const TagDashboardUpdateSchema = nonEmptyPatch(TagDashboardSchema.omit({ id: true }));
|
||||
export type TagDashboardUpdate = z.infer<typeof TagDashboardUpdateSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -547,6 +559,7 @@ export const ScheduledMessageDashboardSchema = z.object({
|
||||
channelId: z.string(),
|
||||
creatorId: z.string(),
|
||||
content: z.string().nullable(),
|
||||
embed: DashboardEmbedSchema.nullable().optional(),
|
||||
rolePingId: z.string().nullable(),
|
||||
cron: z.string().nullable(),
|
||||
runAt: z.string().nullable(),
|
||||
@@ -559,6 +572,7 @@ export const ScheduledMessageDashboardCreateSchema = z
|
||||
.object({
|
||||
channelId: SnowflakeSchema,
|
||||
content: z.string().max(2000).nullable().optional(),
|
||||
embed: DashboardEmbedSchema.nullable().optional(),
|
||||
rolePingId: OptionalSnowflakeSchema.optional(),
|
||||
cron: z.string().min(1).max(100).nullable().optional(),
|
||||
runAt: z
|
||||
@@ -570,9 +584,13 @@ export const ScheduledMessageDashboardCreateSchema = z
|
||||
.refine((value) => Boolean(value.cron?.trim()) || Boolean(value.runAt), {
|
||||
message: 'Either cron or runAt is required'
|
||||
})
|
||||
.refine((value) => Boolean(value.content?.trim()), {
|
||||
message: 'Content is required'
|
||||
});
|
||||
.refine(
|
||||
(value) =>
|
||||
Boolean(value.content?.trim()) ||
|
||||
Boolean(value.embed?.title?.trim()) ||
|
||||
Boolean(value.embed?.description?.trim()),
|
||||
{ message: 'Content or embed is required' }
|
||||
);
|
||||
export type ScheduledMessageDashboardCreate = z.infer<typeof ScheduledMessageDashboardCreateSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user