deploy #1

Merged
smueller merged 30 commits from deploy into main 2026-07-24 08:41:09 +00:00
10 changed files with 392 additions and 82 deletions
Showing only changes of commit 55a4f7bf09 - Show all commits

View File

@@ -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}` : ''}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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