deploy #1
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ScheduledMessageDashboard } from '@nexumi/shared';
|
import type { ScheduledMessageDashboard, WelcomeEmbed } from '@nexumi/shared';
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -9,6 +9,7 @@ import { useTranslations } from '@/components/locale-provider';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
|
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 { DiscordRoleSelect } from '@/components/ui/discord-role-select';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -17,6 +18,7 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
const EMPTY_DRAFT = {
|
const EMPTY_DRAFT = {
|
||||||
channelId: '',
|
channelId: '',
|
||||||
content: '',
|
content: '',
|
||||||
|
embed: null as WelcomeEmbed | null,
|
||||||
rolePingId: '',
|
rolePingId: '',
|
||||||
cron: '',
|
cron: '',
|
||||||
runAt: ''
|
runAt: ''
|
||||||
@@ -35,6 +37,19 @@ function formatTiming(schedule: ScheduledMessageDashboard, t: (key: string) => s
|
|||||||
return '—';
|
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 {
|
interface SchedulerManagerProps {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
initialSchedules: ScheduledMessageDashboard[];
|
initialSchedules: ScheduledMessageDashboard[];
|
||||||
@@ -47,7 +62,9 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
|||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
async function handleCreate() {
|
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'));
|
toast.error(t('modulePages.scheduler.formIncomplete'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -58,7 +75,8 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
channelId: draft.channelId.trim(),
|
channelId: draft.channelId.trim(),
|
||||||
content: draft.content.trim(),
|
content: draft.content.trim() || null,
|
||||||
|
embed,
|
||||||
rolePingId: draft.rolePingId.trim() || undefined,
|
rolePingId: draft.rolePingId.trim() || undefined,
|
||||||
cron: draft.cron.trim() || null,
|
cron: draft.cron.trim() || null,
|
||||||
runAt: draft.cron.trim() ? null : draft.runAt ? new Date(draft.runAt).toISOString() : 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}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<p className="text-xs text-muted-foreground">{t('modulePages.scheduler.cronHint')}</p>
|
||||||
<Button type="button" onClick={handleCreate} disabled={creating}>
|
<Button type="button" onClick={handleCreate} disabled={creating}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
@@ -172,7 +194,7 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
|||||||
{schedules.map((schedule) => (
|
{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 key={schedule.id} className="flex flex-wrap items-start justify-between gap-3 rounded-md border border-border p-4">
|
||||||
<div>
|
<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">
|
<p className="text-xs text-muted-foreground">
|
||||||
#{schedule.channelId} · {formatTiming(schedule, t)}
|
#{schedule.channelId} · {formatTiming(schedule, t)}
|
||||||
{schedule.rolePingId ? ` · @${schedule.rolePingId}` : ''}
|
{schedule.rolePingId ? ` · @${schedule.rolePingId}` : ''}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { WelcomeConfigDashboard } from '@nexumi/shared';
|
import type { WelcomeConfigDashboard, WelcomeEmbed } from '@nexumi/shared';
|
||||||
import { FieldAnchor } from '@/components/layout/field-anchor';
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
|
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 { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
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 type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||||
import { SettingsForm } from '@/components/settings/settings-form';
|
import { SettingsForm } from '@/components/settings/settings-form';
|
||||||
|
|
||||||
interface LocalWelcomeValue extends Omit<WelcomeConfigDashboard, 'welcomeEmbed' | 'leaveEmbed'> {
|
const WELCOME_PREVIEW_VARS = {
|
||||||
welcomeEmbedText: string;
|
user: '@Alex',
|
||||||
leaveEmbedText: string;
|
'user.name': 'Alex',
|
||||||
}
|
'user.tag': 'Alex',
|
||||||
|
'user.id': '123456789012345678',
|
||||||
|
server: 'Nexumi',
|
||||||
|
memberCount: '128'
|
||||||
|
};
|
||||||
|
|
||||||
function toJsonText(value: Record<string, unknown> | null | undefined): string {
|
interface LocalWelcomeValue extends Omit<WelcomeConfigDashboard, 'welcomeEmbed' | 'leaveEmbed'> {
|
||||||
return value ? JSON.stringify(value, null, 2) : '';
|
welcomeEmbed: WelcomeEmbed | null;
|
||||||
|
leaveEmbed: WelcomeEmbed | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toLocal(config: WelcomeConfigDashboard): LocalWelcomeValue {
|
function toLocal(config: WelcomeConfigDashboard): LocalWelcomeValue {
|
||||||
const { welcomeEmbed, leaveEmbed, ...rest } = config;
|
|
||||||
return {
|
return {
|
||||||
...rest,
|
...config,
|
||||||
welcomeEmbedText: toJsonText(welcomeEmbed),
|
welcomeEmbed: embedFromUnknown(config.welcomeEmbed),
|
||||||
leaveEmbedText: toJsonText(leaveEmbed)
|
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> {
|
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 = {
|
const payload: WelcomeConfigDashboard = {
|
||||||
...rest,
|
...value,
|
||||||
welcomeEmbed: welcomeEmbed.value,
|
welcomeEmbed: normalizeEmbed(value.welcomeEmbed),
|
||||||
leaveEmbed: leaveEmbed.value
|
leaveEmbed: normalizeEmbed(value.leaveEmbed)
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -154,12 +137,10 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
<FieldAnchor id="field-welcome-embed">
|
<FieldAnchor id="field-welcome-embed">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.welcome.welcomeEmbed')}</Label>
|
<Label>{t('modulePages.welcome.welcomeEmbed')}</Label>
|
||||||
<Textarea
|
<DiscordEmbedBuilder
|
||||||
className="font-mono"
|
value={value.welcomeEmbed}
|
||||||
rows={6}
|
onChange={(welcomeEmbed) => setValue((prev) => ({ ...prev, welcomeEmbed }))}
|
||||||
value={value.welcomeEmbedText}
|
previewVars={WELCOME_PREVIEW_VARS}
|
||||||
onChange={(event) => setValue((prev) => ({ ...prev, welcomeEmbedText: event.target.value }))}
|
|
||||||
placeholder='{ "title": "Welcome!", "color": 6591981 }'
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
@@ -260,12 +241,10 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
<FieldAnchor id="field-leave-embed">
|
<FieldAnchor id="field-leave-embed">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.welcome.leaveEmbed')}</Label>
|
<Label>{t('modulePages.welcome.leaveEmbed')}</Label>
|
||||||
<Textarea
|
<DiscordEmbedBuilder
|
||||||
className="font-mono"
|
value={value.leaveEmbed}
|
||||||
rows={6}
|
onChange={(leaveEmbed) => setValue((prev) => ({ ...prev, leaveEmbed }))}
|
||||||
value={value.leaveEmbedText}
|
previewVars={WELCOME_PREVIEW_VARS}
|
||||||
onChange={(event) => setValue((prev) => ({ ...prev, leaveEmbedText: event.target.value }))}
|
|
||||||
placeholder='{ "title": "Goodbye", "color": 15158332 }'
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</FieldAnchor>
|
</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 { ScheduledMessageDashboard, ScheduledMessageDashboardCreate, WelcomeEmbed } from '@nexumi/shared';
|
||||||
import type { ScheduledMessage } from '@prisma/client';
|
import { Prisma, type ScheduledMessage } from '@prisma/client';
|
||||||
import { prisma } from '../prisma';
|
import { prisma } from '../prisma';
|
||||||
import { getScheduleQueue } from '../queues';
|
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 {
|
function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {
|
||||||
return {
|
return {
|
||||||
id: schedule.id,
|
id: schedule.id,
|
||||||
channelId: schedule.channelId,
|
channelId: schedule.channelId,
|
||||||
creatorId: schedule.creatorId,
|
creatorId: schedule.creatorId,
|
||||||
content: schedule.content,
|
content: schedule.content,
|
||||||
|
embed: parseEmbed(schedule.embed),
|
||||||
rolePingId: schedule.rolePingId,
|
rolePingId: schedule.rolePingId,
|
||||||
cron: schedule.cron,
|
cron: schedule.cron,
|
||||||
runAt: schedule.runAt?.toISOString() ?? null,
|
runAt: schedule.runAt?.toISOString() ?? null,
|
||||||
@@ -69,6 +84,10 @@ export async function createScheduledMessageDashboard(
|
|||||||
channelId: input.channelId,
|
channelId: input.channelId,
|
||||||
creatorId,
|
creatorId,
|
||||||
content: input.content?.trim() || null,
|
content: input.content?.trim() || null,
|
||||||
|
embed:
|
||||||
|
input.embed === undefined || input.embed === null
|
||||||
|
? Prisma.JsonNull
|
||||||
|
: (input.embed as Prisma.InputJsonValue),
|
||||||
rolePingId: input.rolePingId || null,
|
rolePingId: input.rolePingId || null,
|
||||||
cron,
|
cron,
|
||||||
runAt
|
runAt
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { TagDashboard, TagDashboardCreate, TagDashboardUpdate } from '@nexumi/shared';
|
import type { TagDashboard, TagDashboardCreate, TagDashboardUpdate, WelcomeEmbed } from '@nexumi/shared';
|
||||||
import type { Tag } from '@prisma/client';
|
import { Prisma, type Tag } from '@prisma/client';
|
||||||
import { prisma } from '../prisma';
|
import { prisma } from '../prisma';
|
||||||
import { assertPremiumLimit, PremiumLimitError } from '../premium';
|
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 {
|
function toDashboard(tag: Tag): TagDashboard {
|
||||||
return {
|
return {
|
||||||
id: tag.id,
|
id: tag.id,
|
||||||
name: tag.name,
|
name: tag.name,
|
||||||
content: tag.content,
|
content: tag.content,
|
||||||
|
embed: parseEmbed(tag.embed),
|
||||||
responseType: tag.responseType as TagDashboard['responseType'],
|
responseType: tag.responseType as TagDashboard['responseType'],
|
||||||
triggerWord: tag.triggerWord,
|
triggerWord: tag.triggerWord,
|
||||||
allowedRoleIds: tag.allowedRoleIds,
|
allowedRoleIds: tag.allowedRoleIds,
|
||||||
@@ -53,6 +68,10 @@ export async function createTag(
|
|||||||
guildId,
|
guildId,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
content: input.content ?? null,
|
content: input.content ?? null,
|
||||||
|
embed:
|
||||||
|
input.embed === undefined || input.embed === null
|
||||||
|
? Prisma.JsonNull
|
||||||
|
: (input.embed as Prisma.InputJsonValue),
|
||||||
responseType: input.responseType,
|
responseType: input.responseType,
|
||||||
triggerWord: input.triggerWord ?? null,
|
triggerWord: input.triggerWord ?? null,
|
||||||
allowedRoleIds: input.allowedRoleIds,
|
allowedRoleIds: input.allowedRoleIds,
|
||||||
@@ -85,6 +104,9 @@ export async function updateTag(
|
|||||||
data: {
|
data: {
|
||||||
...(patch.name !== undefined ? { name: patch.name } : {}),
|
...(patch.name !== undefined ? { name: patch.name } : {}),
|
||||||
...(patch.content !== undefined ? { content: patch.content } : {}),
|
...(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.responseType !== undefined ? { responseType: patch.responseType } : {}),
|
||||||
...(patch.triggerWord !== undefined ? { triggerWord: patch.triggerWord } : {}),
|
...(patch.triggerWord !== undefined ? { triggerWord: patch.triggerWord } : {}),
|
||||||
...(patch.allowedRoleIds !== undefined ? { allowedRoleIds: patch.allowedRoleIds } : {}),
|
...(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/client';
|
||||||
import { prisma } from '../prisma';
|
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 {
|
function toDashboard(config: Awaited<ReturnType<typeof ensureWelcomeConfig>>): WelcomeConfigDashboard {
|
||||||
return {
|
return {
|
||||||
welcomeEnabled: config.welcomeEnabled,
|
welcomeEnabled: config.welcomeEnabled,
|
||||||
@@ -19,9 +33,9 @@ function toDashboard(config: Awaited<ReturnType<typeof ensureWelcomeConfig>>): W
|
|||||||
leaveChannelId: config.leaveChannelId ?? '',
|
leaveChannelId: config.leaveChannelId ?? '',
|
||||||
welcomeType: config.welcomeType as WelcomeConfigDashboard['welcomeType'],
|
welcomeType: config.welcomeType as WelcomeConfigDashboard['welcomeType'],
|
||||||
welcomeContent: config.welcomeContent,
|
welcomeContent: config.welcomeContent,
|
||||||
welcomeEmbed: (config.welcomeEmbed as Record<string, unknown> | null) ?? null,
|
welcomeEmbed: parseEmbed(config.welcomeEmbed),
|
||||||
leaveContent: config.leaveContent,
|
leaveContent: config.leaveContent,
|
||||||
leaveEmbed: (config.leaveEmbed as Record<string, unknown> | null) ?? null,
|
leaveEmbed: parseEmbed(config.leaveEmbed),
|
||||||
welcomeDmEnabled: config.welcomeDmEnabled,
|
welcomeDmEnabled: config.welcomeDmEnabled,
|
||||||
welcomeDmContent: config.welcomeDmContent,
|
welcomeDmContent: config.welcomeDmContent,
|
||||||
userAutoroleIds: config.userAutoroleIds,
|
userAutoroleIds: config.userAutoroleIds,
|
||||||
|
|||||||
@@ -21,7 +21,19 @@
|
|||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
"searchSelect": "Suchen und auswählen…",
|
"searchSelect": "Suchen und auswählen…",
|
||||||
"noResults": "Keine Treffer",
|
"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": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@@ -276,7 +288,7 @@
|
|||||||
"welcomeContent": "Nachrichtentext",
|
"welcomeContent": "Nachrichtentext",
|
||||||
"contentPlaceholder": "Willkommen {user} auf {server}!",
|
"contentPlaceholder": "Willkommen {user} auf {server}!",
|
||||||
"placeholdersHint": "Verfügbare Platzhalter: {user}, {user.name}, {user.tag}, {user.id}, {server}, {memberCount}",
|
"placeholdersHint": "Verfügbare Platzhalter: {user}, {user.name}, {user.tag}, {user.id}, {server}, {memberCount}",
|
||||||
"welcomeEmbed": "Embed (JSON, optional)",
|
"welcomeEmbed": "Embed",
|
||||||
"welcomeDmEnabled": "Willkommens-DM aktiviert",
|
"welcomeDmEnabled": "Willkommens-DM aktiviert",
|
||||||
"dmContentPlaceholder": "Optionale private Nachricht an neue Mitglieder",
|
"dmContentPlaceholder": "Optionale private Nachricht an neue Mitglieder",
|
||||||
"userAutoroles": "Autoroles für Nutzer",
|
"userAutoroles": "Autoroles für Nutzer",
|
||||||
@@ -288,7 +300,7 @@
|
|||||||
"leaveEnabledLabel": "Abschiedsnachricht aktiviert",
|
"leaveEnabledLabel": "Abschiedsnachricht aktiviert",
|
||||||
"leaveChannelId": "Abschieds-Kanal-ID",
|
"leaveChannelId": "Abschieds-Kanal-ID",
|
||||||
"leaveContent": "Nachrichtentext",
|
"leaveContent": "Nachrichtentext",
|
||||||
"leaveEmbed": "Embed (JSON, optional)"
|
"leaveEmbed": "Embed"
|
||||||
},
|
},
|
||||||
"verification": {
|
"verification": {
|
||||||
"title": "Verifizierung",
|
"title": "Verifizierung",
|
||||||
@@ -461,7 +473,9 @@
|
|||||||
},
|
},
|
||||||
"triggerWord": "Auslöse-Wort (optional)",
|
"triggerWord": "Auslöse-Wort (optional)",
|
||||||
"content": "Inhalt",
|
"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",
|
"allowedChannelIds": "Erlaubte Kanal-IDs",
|
||||||
"idsPlaceholder": "Eine oder mehrere IDs, kommagetrennt",
|
"idsPlaceholder": "Eine oder mehrere IDs, kommagetrennt",
|
||||||
"create": "Tag erstellen",
|
"create": "Tag erstellen",
|
||||||
@@ -562,10 +576,11 @@
|
|||||||
"cron": "Cron-Ausdruck",
|
"cron": "Cron-Ausdruck",
|
||||||
"runAt": "Ausführen am",
|
"runAt": "Ausführen am",
|
||||||
"content": "Nachrichtentext",
|
"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",
|
"create": "Nachricht planen",
|
||||||
"created": "Nachricht geplant.",
|
"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?",
|
"confirmDelete": "Diesen Zeitplan wirklich löschen?",
|
||||||
"listTitle": "Geplante Nachrichten",
|
"listTitle": "Geplante Nachrichten",
|
||||||
"listDescription": "Anstehende und wiederkehrende geplante Nachrichten.",
|
"listDescription": "Anstehende und wiederkehrende geplante Nachrichten.",
|
||||||
|
|||||||
@@ -21,7 +21,19 @@
|
|||||||
"optional": "Optional",
|
"optional": "Optional",
|
||||||
"searchSelect": "Search and select…",
|
"searchSelect": "Search and select…",
|
||||||
"noResults": "No results",
|
"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": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@@ -276,7 +288,7 @@
|
|||||||
"welcomeContent": "Message content",
|
"welcomeContent": "Message content",
|
||||||
"contentPlaceholder": "Welcome {user} to {server}!",
|
"contentPlaceholder": "Welcome {user} to {server}!",
|
||||||
"placeholdersHint": "Available placeholders: {user}, {user.name}, {user.tag}, {user.id}, {server}, {memberCount}",
|
"placeholdersHint": "Available placeholders: {user}, {user.name}, {user.tag}, {user.id}, {server}, {memberCount}",
|
||||||
"welcomeEmbed": "Embed (JSON, optional)",
|
"welcomeEmbed": "Embed",
|
||||||
"welcomeDmEnabled": "Welcome DM enabled",
|
"welcomeDmEnabled": "Welcome DM enabled",
|
||||||
"dmContentPlaceholder": "Optional private message to new members",
|
"dmContentPlaceholder": "Optional private message to new members",
|
||||||
"userAutoroles": "Autoroles for users",
|
"userAutoroles": "Autoroles for users",
|
||||||
@@ -288,7 +300,7 @@
|
|||||||
"leaveEnabledLabel": "Leave message enabled",
|
"leaveEnabledLabel": "Leave message enabled",
|
||||||
"leaveChannelId": "Leave channel ID",
|
"leaveChannelId": "Leave channel ID",
|
||||||
"leaveContent": "Message content",
|
"leaveContent": "Message content",
|
||||||
"leaveEmbed": "Embed (JSON, optional)"
|
"leaveEmbed": "Embed"
|
||||||
},
|
},
|
||||||
"verification": {
|
"verification": {
|
||||||
"title": "Verification",
|
"title": "Verification",
|
||||||
@@ -461,7 +473,9 @@
|
|||||||
},
|
},
|
||||||
"triggerWord": "Trigger word (optional)",
|
"triggerWord": "Trigger word (optional)",
|
||||||
"content": "Content",
|
"content": "Content",
|
||||||
"allowedRoleIds": "Allowed role IDs",
|
"embed": "Embed",
|
||||||
|
"embedRequired": "Embed tags need a title or description.",
|
||||||
|
"allowedRoleIds": "Allowed roles",
|
||||||
"allowedChannelIds": "Allowed channel IDs",
|
"allowedChannelIds": "Allowed channel IDs",
|
||||||
"idsPlaceholder": "One or more IDs, comma-separated",
|
"idsPlaceholder": "One or more IDs, comma-separated",
|
||||||
"create": "Create tag",
|
"create": "Create tag",
|
||||||
@@ -562,10 +576,11 @@
|
|||||||
"cron": "Cron expression",
|
"cron": "Cron expression",
|
||||||
"runAt": "Run at",
|
"runAt": "Run at",
|
||||||
"content": "Message content",
|
"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",
|
"create": "Schedule message",
|
||||||
"created": "Message scheduled.",
|
"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?",
|
"confirmDelete": "Really delete this schedule?",
|
||||||
"listTitle": "Scheduled messages",
|
"listTitle": "Scheduled messages",
|
||||||
"listDescription": "Upcoming and recurring scheduled messages.",
|
"listDescription": "Upcoming and recurring scheduled messages.",
|
||||||
|
|||||||
@@ -126,6 +126,21 @@
|
|||||||
- [ ] Per-Command Channel/Rollen per Dropdown setzen
|
- [ ] Per-Command Channel/Rollen per Dropdown setzen
|
||||||
- [ ] Deaktivierter Command / Cooldown antwortet ephemeral
|
- [ ] 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
|
## Nächster geplanter Schritt
|
||||||
|
|
||||||
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
|
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { WelcomeEmbedSchema } from './phase2.js';
|
||||||
import {
|
import {
|
||||||
SelfRoleBehaviorSchema,
|
SelfRoleBehaviorSchema,
|
||||||
SelfRoleEntrySchema,
|
SelfRoleEntrySchema,
|
||||||
@@ -9,6 +10,10 @@ import {
|
|||||||
} from './phase4.js';
|
} from './phase4.js';
|
||||||
import { SocialFeedTypeSchema } from './phase5.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.
|
* Discord snowflake ID: 17-20 digit numeric string.
|
||||||
*/
|
*/
|
||||||
@@ -192,9 +197,9 @@ export const WelcomeConfigDashboardSchema = z.object({
|
|||||||
leaveChannelId: OptionalSnowflakeSchema,
|
leaveChannelId: OptionalSnowflakeSchema,
|
||||||
welcomeType: WelcomeMessageTypeDashboardSchema,
|
welcomeType: WelcomeMessageTypeDashboardSchema,
|
||||||
welcomeContent: z.string().max(2000).nullable().optional(),
|
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(),
|
leaveContent: z.string().max(2000).nullable().optional(),
|
||||||
leaveEmbed: z.record(z.string(), z.unknown()).nullable().optional(),
|
leaveEmbed: DashboardEmbedSchema.nullable().optional(),
|
||||||
welcomeDmEnabled: z.boolean(),
|
welcomeDmEnabled: z.boolean(),
|
||||||
welcomeDmContent: z.string().max(2000).nullable().optional(),
|
welcomeDmContent: z.string().max(2000).nullable().optional(),
|
||||||
userAutoroleIds: z.array(z.string()),
|
userAutoroleIds: z.array(z.string()),
|
||||||
@@ -390,6 +395,7 @@ export const TagDashboardSchema = z.object({
|
|||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
name: z.string().min(1).max(50),
|
name: z.string().min(1).max(50),
|
||||||
content: z.string().max(2000).nullable().optional(),
|
content: z.string().max(2000).nullable().optional(),
|
||||||
|
embed: DashboardEmbedSchema.nullable().optional(),
|
||||||
responseType: TagResponseTypeSchema,
|
responseType: TagResponseTypeSchema,
|
||||||
triggerWord: z.string().max(100).nullable().optional(),
|
triggerWord: z.string().max(100).nullable().optional(),
|
||||||
allowedRoleIds: z.array(z.string()),
|
allowedRoleIds: z.array(z.string()),
|
||||||
@@ -397,10 +403,16 @@ export const TagDashboardSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type TagDashboard = z.infer<typeof TagDashboardSchema>;
|
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 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>;
|
export type TagDashboardUpdate = z.infer<typeof TagDashboardUpdateSchema>;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -547,6 +559,7 @@ export const ScheduledMessageDashboardSchema = z.object({
|
|||||||
channelId: z.string(),
|
channelId: z.string(),
|
||||||
creatorId: z.string(),
|
creatorId: z.string(),
|
||||||
content: z.string().nullable(),
|
content: z.string().nullable(),
|
||||||
|
embed: DashboardEmbedSchema.nullable().optional(),
|
||||||
rolePingId: z.string().nullable(),
|
rolePingId: z.string().nullable(),
|
||||||
cron: z.string().nullable(),
|
cron: z.string().nullable(),
|
||||||
runAt: z.string().nullable(),
|
runAt: z.string().nullable(),
|
||||||
@@ -559,6 +572,7 @@ export const ScheduledMessageDashboardCreateSchema = z
|
|||||||
.object({
|
.object({
|
||||||
channelId: SnowflakeSchema,
|
channelId: SnowflakeSchema,
|
||||||
content: z.string().max(2000).nullable().optional(),
|
content: z.string().max(2000).nullable().optional(),
|
||||||
|
embed: DashboardEmbedSchema.nullable().optional(),
|
||||||
rolePingId: OptionalSnowflakeSchema.optional(),
|
rolePingId: OptionalSnowflakeSchema.optional(),
|
||||||
cron: z.string().min(1).max(100).nullable().optional(),
|
cron: z.string().min(1).max(100).nullable().optional(),
|
||||||
runAt: z
|
runAt: z
|
||||||
@@ -570,9 +584,13 @@ export const ScheduledMessageDashboardCreateSchema = z
|
|||||||
.refine((value) => Boolean(value.cron?.trim()) || Boolean(value.runAt), {
|
.refine((value) => Boolean(value.cron?.trim()) || Boolean(value.runAt), {
|
||||||
message: 'Either cron or runAt is required'
|
message: 'Either cron or runAt is required'
|
||||||
})
|
})
|
||||||
.refine((value) => Boolean(value.content?.trim()), {
|
.refine(
|
||||||
message: 'Content is required'
|
(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>;
|
export type ScheduledMessageDashboardCreate = z.infer<typeof ScheduledMessageDashboardCreateSchema>;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user