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

View File

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

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

View File

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

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

View File

@@ -23,6 +23,18 @@
"noResults": "Keine Treffer",
"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",
"overview": "Übersicht",
@@ -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.",

View File

@@ -23,6 +23,18 @@
"noResults": "No results",
"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",
"overview": "Overview",
@@ -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.",

View File

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

View File

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