Refactor embed handling and enhance user experience across components

- Integrated the new `buildEmbedFromPayload` function in Scheduler, Tags, and Welcome components to streamline embed creation and management.
- Updated validation schemas to require embed content checks, ensuring robust data integrity for embeds.
- Enhanced localization files to include new placeholder descriptions and improve user guidance for embed fields.
- Refactored embed parsing logic to utilize the `WelcomeEmbedSchema`, improving consistency and validation across the application.
This commit is contained in:
TheOnlyMace
2026-07-22 22:30:46 +02:00
parent e7a3162494
commit 95eed45ec4
19 changed files with 500 additions and 146 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import type { ScheduledMessageDashboard, WelcomeEmbed } from '@nexumi/shared';
import { embedHasContent, type ScheduledMessageDashboard, type WelcomeEmbed } from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
@@ -48,6 +48,12 @@ function schedulePreview(schedule: ScheduledMessageDashboard, t: (key: string) =
if (schedule.embed?.description?.trim()) {
return schedule.embed.description;
}
if (schedule.embed?.authorName?.trim()) {
return schedule.embed.authorName;
}
if (schedule.embed?.footerText?.trim()) {
return schedule.embed.footerText;
}
return t('modulePages.scheduler.noContent');
}
@@ -64,7 +70,8 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
async function handleCreate() {
const embed = normalizeEmbed(draft.embed);
const hasContent = Boolean(draft.content.trim()) || Boolean(embed?.title) || Boolean(embed?.description);
const hasContent =
Boolean(draft.content.trim()) || embedHasContent(embed) || embed?.color !== undefined;
if (!draft.channelId.trim() || !hasContent || (!draft.cron.trim() && !draft.runAt)) {
toast.error(t('modulePages.scheduler.formIncomplete'));
return;

View File

@@ -1,6 +1,6 @@
'use client';
import type { TagDashboard, TagResponseType, WelcomeEmbed } from '@nexumi/shared';
import { embedHasContent, type TagDashboard, type TagResponseType, type WelcomeEmbed } from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
@@ -57,7 +57,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
return;
}
const embed = draft.responseType === 'EMBED' ? normalizeEmbed(draft.embed) : null;
if (draft.responseType === 'EMBED' && !embed?.title && !embed?.description) {
if (draft.responseType === 'EMBED' && !embedHasContent(embed) && embed?.color === undefined) {
toast.error(t('modulePages.tags.embedRequired'));
return;
}
@@ -99,7 +99,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
async function handleSave(tag: LocalTag) {
const embed = tag.responseType === 'EMBED' ? normalizeEmbed(tag.embed ?? null) : null;
if (tag.responseType === 'EMBED' && !embed?.title && !embed?.description) {
if (tag.responseType === 'EMBED' && !embedHasContent(embed) && embed?.color === undefined) {
toast.error(t('modulePages.tags.embedRequired'));
return;
}

View File

@@ -25,7 +25,9 @@ const WELCOME_PREVIEW_VARS = {
'user.name': 'Alex',
'user.tag': 'Alex',
'user.id': '123456789012345678',
'user.avatar': 'https://cdn.discordapp.com/embed/avatars/0.png',
server: 'Nexumi',
'server.icon': 'https://cdn.discordapp.com/embed/avatars/1.png',
memberCount: '128'
};

View File

@@ -1,21 +1,38 @@
'use client';
import type { WelcomeEmbed } from '@nexumi/shared';
import { embedHasContent, type WelcomeEmbed } from '@nexumi/shared';
import { ChevronDown } from 'lucide-react';
import { useId, useState } from 'react';
import { useTranslations } from '@/components/locale-provider';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PlaceholderHelp } from '@/components/ui/placeholder-help';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import type { PlaceholderPresetId } from '@/lib/placeholder-presets';
import { cn } from '@/lib/utils';
export const DEFAULT_EMBED_COLOR = 0x6366f1;
function trimOrUndefined(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
export function emptyEmbed(): WelcomeEmbed {
return {
title: '',
description: '',
color: DEFAULT_EMBED_COLOR
color: DEFAULT_EMBED_COLOR,
url: '',
authorName: '',
authorIconUrl: '',
authorUrl: '',
thumbnailUrl: '',
imageUrl: '',
footerText: '',
footerIconUrl: '',
timestamp: false
};
}
@@ -23,13 +40,24 @@ export function normalizeEmbed(value: WelcomeEmbed | null | undefined): WelcomeE
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) {
const normalized: WelcomeEmbed = {
title: trimOrUndefined(value.title),
description: trimOrUndefined(value.description),
url: trimOrUndefined(value.url),
color: typeof value.color === 'number' && Number.isFinite(value.color) ? value.color : undefined,
authorName: trimOrUndefined(value.authorName),
authorIconUrl: trimOrUndefined(value.authorIconUrl),
authorUrl: trimOrUndefined(value.authorUrl),
thumbnailUrl: trimOrUndefined(value.thumbnailUrl),
imageUrl: trimOrUndefined(value.imageUrl),
footerText: trimOrUndefined(value.footerText),
footerIconUrl: trimOrUndefined(value.footerIconUrl),
timestamp: value.timestamp ? true : undefined
};
if (!embedHasContent(normalized) && normalized.color === undefined) {
return null;
}
return { title, description, color };
return normalized;
}
export function embedFromUnknown(raw: unknown): WelcomeEmbed | null {
@@ -40,7 +68,16 @@ export function embedFromUnknown(raw: unknown): WelcomeEmbed | null {
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
url: typeof record.url === 'string' ? record.url : undefined,
color: typeof record.color === 'number' ? record.color : undefined,
authorName: typeof record.authorName === 'string' ? record.authorName : undefined,
authorIconUrl: typeof record.authorIconUrl === 'string' ? record.authorIconUrl : undefined,
authorUrl: typeof record.authorUrl === 'string' ? record.authorUrl : undefined,
thumbnailUrl: typeof record.thumbnailUrl === 'string' ? record.thumbnailUrl : undefined,
imageUrl: typeof record.imageUrl === 'string' ? record.imageUrl : undefined,
footerText: typeof record.footerText === 'string' ? record.footerText : undefined,
footerIconUrl: typeof record.footerIconUrl === 'string' ? record.footerIconUrl : undefined,
timestamp: typeof record.timestamp === 'boolean' ? record.timestamp : undefined
});
}
@@ -60,22 +97,28 @@ function applyPreviewVars(template: string, vars: Record<string, string>): strin
(acc, [key, value]) => acc.replaceAll(`{${key}}`, value),
template
);
// Show bracket channel mentions as #id in the dashboard preview.
return withVars.replace(/\["(\d{17,20})"\]/g, '#$1').replace(/<#(\d{17,20})>/g, '#$1');
}
function previewUrl(value: string | undefined, vars: Record<string, string>): string | undefined {
if (!value?.trim()) {
return undefined;
}
const rendered = applyPreviewVars(value, vars).trim();
if (!/^https?:\/\//i.test(rendered)) {
return undefined;
}
return rendered;
}
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;
/** Which placeholders apply to this embed context. */
placeholderPreset?: PlaceholderPresetId;
/** Override default embed title input placeholder. */
titlePlaceholder?: string;
/** Override default embed description input placeholder. */
descriptionPlaceholder?: string;
}
@@ -90,23 +133,31 @@ export function DiscordEmbedBuilder({
descriptionPlaceholder
}: DiscordEmbedBuilderProps) {
const t = useTranslations();
const advancedId = useId();
const [advancedOpen, setAdvancedOpen] = useState(false);
const draft = value ?? emptyEmbed();
const vars = previewVars ?? {};
const colorHex = colorToHex(draft.color);
const previewAuthor = draft.authorName ? applyPreviewVars(draft.authorName, vars) : '';
const previewTitle = draft.title
? applyPreviewVars(draft.title, previewVars ?? {})
? applyPreviewVars(draft.title, vars)
: t('embedBuilder.previewTitlePlaceholder');
const previewDescription = draft.description
? applyPreviewVars(draft.description, previewVars ?? {})
? applyPreviewVars(draft.description, vars)
: t('embedBuilder.previewDescriptionPlaceholder');
const hasContent = Boolean(draft.title?.trim() || draft.description?.trim());
const previewFooter = draft.footerText ? applyPreviewVars(draft.footerText, vars) : '';
const authorIcon = previewUrl(draft.authorIconUrl, vars);
const thumbnail = previewUrl(draft.thumbnailUrl, vars) ?? previewUrl(vars['user.avatar'], {});
const image = previewUrl(draft.imageUrl, vars);
const footerIcon = previewUrl(draft.footerIconUrl, vars);
const hasContent = embedHasContent(draft) || Boolean(draft.color !== undefined);
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);
onChange({
...draft,
...next
});
}
return (
@@ -156,6 +207,86 @@ export function DiscordEmbedBuilder({
/>
</div>
</div>
<div className="rounded-md border border-border">
<button
type="button"
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:text-foreground"
aria-expanded={advancedOpen}
aria-controls={advancedId}
onClick={() => setAdvancedOpen((prev) => !prev)}
>
<span>{t('embedBuilder.advancedToggle')}</span>
<ChevronDown className={cn('size-4 shrink-0 transition-transform', advancedOpen && 'rotate-180')} />
</button>
{advancedOpen ? (
<div id={advancedId} className="space-y-4 border-t border-border px-3 py-3">
<div className="space-y-2">
<p className="text-xs font-medium text-foreground">{t('embedBuilder.authorSection')}</p>
<Input
value={draft.authorName ?? ''}
maxLength={256}
placeholder={t('embedBuilder.authorNamePlaceholder')}
onChange={(event) => patch({ authorName: event.target.value })}
/>
<Input
value={draft.authorIconUrl ?? ''}
placeholder={t('embedBuilder.authorIconPlaceholder')}
onChange={(event) => patch({ authorIconUrl: event.target.value })}
/>
<Input
value={draft.authorUrl ?? ''}
placeholder={t('embedBuilder.authorUrlPlaceholder')}
onChange={(event) => patch({ authorUrl: event.target.value })}
/>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-foreground">{t('embedBuilder.mediaSection')}</p>
<Input
value={draft.thumbnailUrl ?? ''}
placeholder={t('embedBuilder.thumbnailPlaceholder')}
onChange={(event) => patch({ thumbnailUrl: event.target.value })}
/>
<Input
value={draft.imageUrl ?? ''}
placeholder={t('embedBuilder.imagePlaceholder')}
onChange={(event) => patch({ imageUrl: event.target.value })}
/>
<Input
value={draft.url ?? ''}
placeholder={t('embedBuilder.titleUrlPlaceholder')}
onChange={(event) => patch({ url: event.target.value })}
/>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-foreground">{t('embedBuilder.footerSection')}</p>
<Input
value={draft.footerText ?? ''}
maxLength={2048}
placeholder={t('embedBuilder.footerTextPlaceholder')}
onChange={(event) => patch({ footerText: event.target.value })}
/>
<Input
value={draft.footerIconUrl ?? ''}
placeholder={t('embedBuilder.footerIconPlaceholder')}
onChange={(event) => patch({ footerIconUrl: event.target.value })}
/>
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
<Label htmlFor="embed-timestamp">{t('embedBuilder.timestamp')}</Label>
<Switch
id="embed-timestamp"
checked={Boolean(draft.timestamp)}
onCheckedChange={(checked) => patch({ timestamp: checked })}
/>
</div>
</div>
<p className="text-[11px] text-muted-foreground">{t('embedBuilder.mediaHint')}</p>
</div>
) : null}
</div>
{showClearHint ? (
<p className="text-xs text-muted-foreground">{t('embedBuilder.clearHint')}</p>
) : null}
@@ -173,29 +304,63 @@ export function DiscordEmbedBuilder({
<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'
)}
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>
{previewAuthor ? (
<div className="flex items-center gap-2">
{authorIcon ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={authorIcon} alt="" className="size-6 rounded-full object-cover" />
) : null}
<p className="text-sm font-medium text-white">{previewAuthor}</p>
</div>
) : null}
<div className={cn('flex gap-3', thumbnail && 'items-start')}>
<div className="min-w-0 flex-1 space-y-1">
<p
className={cn(
'text-base font-semibold leading-5',
draft.title?.trim() ? 'text-white' : 'text-[#b5bac1]'
)}
>
{previewTitle || '\u00a0'}
</p>
<p
className={cn(
'whitespace-pre-wrap text-sm leading-5',
draft.description?.trim() ? 'text-[#dbdee1]' : 'text-[#949ba4]'
)}
>
{previewDescription || '\u00a0'}
</p>
</div>
{thumbnail ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={thumbnail} alt="" className="size-16 shrink-0 rounded object-cover" />
) : null}
</div>
{image ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={image} alt="" className="mt-1 w-full rounded object-cover" />
) : null}
{previewFooter || draft.timestamp ? (
<div className="flex items-center gap-2 border-t border-[#3f4147] pt-2">
{footerIcon ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={footerIcon} alt="" className="size-5 rounded-full object-cover" />
) : null}
<p className="text-xs text-[#949ba4]">
{[previewFooter, draft.timestamp ? t('embedBuilder.timestampPreview') : null]
.filter(Boolean)
.join(' · ')}
</p>
</div>
) : null}
</div>
</div>
</div>

View File

@@ -1,4 +1,9 @@
import type { ScheduledMessageDashboard, ScheduledMessageDashboardCreate, WelcomeEmbed } from '@nexumi/shared';
import {
WelcomeEmbedSchema,
type ScheduledMessageDashboard,
type ScheduledMessageDashboardCreate,
type WelcomeEmbed
} from '@nexumi/shared';
import { Prisma, type ScheduledMessage } from '@prisma/client';
import { prisma } from '../prisma';
import { getScheduleQueue } from '../queues';
@@ -10,17 +15,8 @@ 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 };
const parsed = WelcomeEmbedSchema.safeParse(raw);
return parsed.success ? parsed.data : null;
}
function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {

View File

@@ -1,4 +1,10 @@
import type { TagDashboard, TagDashboardCreate, TagDashboardUpdate, WelcomeEmbed } from '@nexumi/shared';
import {
WelcomeEmbedSchema,
type TagDashboard,
type TagDashboardCreate,
type TagDashboardUpdate,
type WelcomeEmbed
} from '@nexumi/shared';
import { Prisma, type Tag } from '@prisma/client';
import { prisma } from '../prisma';
import { assertPremiumLimit, PremiumLimitError } from '../premium';
@@ -16,17 +22,8 @@ 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 };
const parsed = WelcomeEmbedSchema.safeParse(raw);
return parsed.success ? parsed.data : null;
}
function toDashboard(tag: Tag): TagDashboard {

View File

@@ -1,4 +1,9 @@
import type { WelcomeConfigDashboard, WelcomeConfigDashboardPatch, WelcomeEmbed } from '@nexumi/shared';
import {
WelcomeEmbedSchema,
type WelcomeConfigDashboard,
type WelcomeConfigDashboardPatch,
type WelcomeEmbed
} from '@nexumi/shared';
import { Prisma } from '@prisma/client';
import { prisma } from '../prisma';
@@ -12,17 +17,8 @@ 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 };
const parsed = WelcomeEmbedSchema.safeParse(raw);
return parsed.success ? parsed.data : null;
}
function toDashboard(config: Awaited<ReturnType<typeof ensureWelcomeConfig>>): WelcomeConfigDashboard {

View File

@@ -33,7 +33,9 @@ const USER_SERVER: PlaceholderDefinition[] = [
{ token: '{user.name}', descriptionKey: 'placeholders.desc.userName' },
{ token: '{user.tag}', descriptionKey: 'placeholders.desc.userTag' },
{ token: '{user.id}', descriptionKey: 'placeholders.desc.userId' },
{ token: '{user.avatar}', descriptionKey: 'placeholders.desc.userAvatar' },
{ token: '{server}', descriptionKey: 'placeholders.desc.server' },
{ token: '{server.icon}', descriptionKey: 'placeholders.desc.serverIcon' },
{ token: '{memberCount}', descriptionKey: 'placeholders.desc.memberCount' }
];
@@ -49,7 +51,9 @@ export const PLACEHOLDER_PRESETS: Record<PlaceholderPresetId, PlaceholderDefinit
{ token: '{user.name}', descriptionKey: 'placeholders.desc.userName' },
{ token: '{user.tag}', descriptionKey: 'placeholders.desc.userTag' },
{ token: '{user.id}', descriptionKey: 'placeholders.desc.userId' },
{ token: '{user.avatar}', descriptionKey: 'placeholders.desc.userAvatar' },
{ token: '{server}', descriptionKey: 'placeholders.desc.server' },
{ token: '{server.icon}', descriptionKey: 'placeholders.desc.serverIcon' },
{ token: '{args}', descriptionKey: 'placeholders.desc.args' },
{ token: '{random:a|b|c}', descriptionKey: 'placeholders.desc.random' },
...CHANNEL_MENTIONS

View File

@@ -33,7 +33,22 @@
"previewTitlePlaceholder": "Embed-Titel",
"previewDescriptionPlaceholder": "Embed-Beschreibung erscheint hier…",
"clear": "Embed leeren",
"clearHint": "Leere Felder speichern kein Embed."
"clearHint": "Leere Felder speichern kein Embed.",
"advancedToggle": "Erweiterte Embed-Optionen (Author, Bilder, Footer)",
"authorSection": "Author (Kopfzeile)",
"authorNamePlaceholder": "Author-Name, z. B. {server}",
"authorIconPlaceholder": "Author-Icon-URL oder {server.icon}",
"authorUrlPlaceholder": "Author-Link (https://…)",
"mediaSection": "Medien",
"thumbnailPlaceholder": "Thumbnail-URL oder {user.avatar}",
"imagePlaceholder": "Großes Bild / Banner-URL (https://…)",
"titleUrlPlaceholder": "Titel-Link (https://…)",
"footerSection": "Footer",
"footerTextPlaceholder": "Footer-Text, z. B. Nexumi",
"footerIconPlaceholder": "Footer-Icon-URL oder {server.icon}",
"timestamp": "Zeitstempel anzeigen",
"timestampPreview": "gerade eben",
"mediaHint": "Bild-URLs müssen mit http(s) beginnen. Für Welcome kannst du {user.avatar} und {server.icon} nutzen."
},
"placeholders": {
"toggle": "Verfügbare Platzhalter anzeigen",
@@ -59,7 +74,9 @@
"feedTitle": "Titel des Feed-Eintrags",
"feedUrl": "URL des Feed-Eintrags",
"channelBracket": "Kanal-Erwähnung (Dashboard-Schreibweise, ID ersetzen)",
"channelNative": "Kanal-Erwähnung (Discord-Syntax, ID ersetzen)"
"channelNative": "Kanal-Erwähnung (Discord-Syntax, ID ersetzen)",
"userAvatar": "Avatar-URL des Mitglieds (für Thumbnail/Icons)",
"serverIcon": "Server-Icon-URL (für Author/Footer)"
}
},
"nav": {

View File

@@ -33,7 +33,22 @@
"previewTitlePlaceholder": "Embed title",
"previewDescriptionPlaceholder": "Embed description appears here…",
"clear": "Clear embed",
"clearHint": "Empty fields do not save an embed."
"clearHint": "Empty fields do not save an embed.",
"advancedToggle": "Advanced embed options (author, images, footer)",
"authorSection": "Author (header)",
"authorNamePlaceholder": "Author name, e.g. {server}",
"authorIconPlaceholder": "Author icon URL or {server.icon}",
"authorUrlPlaceholder": "Author link (https://…)",
"mediaSection": "Media",
"thumbnailPlaceholder": "Thumbnail URL or {user.avatar}",
"imagePlaceholder": "Large image / banner URL (https://…)",
"titleUrlPlaceholder": "Title link (https://…)",
"footerSection": "Footer",
"footerTextPlaceholder": "Footer text, e.g. Nexumi",
"footerIconPlaceholder": "Footer icon URL or {server.icon}",
"timestamp": "Show timestamp",
"timestampPreview": "just now",
"mediaHint": "Image URLs must start with http(s). For Welcome you can use {user.avatar} and {server.icon}."
},
"placeholders": {
"toggle": "Show available placeholders",
@@ -59,7 +74,9 @@
"feedTitle": "Feed item title",
"feedUrl": "Feed item URL",
"channelBracket": "Channel mention (dashboard syntax — replace the ID)",
"channelNative": "Channel mention (Discord syntax — replace the ID)"
"channelNative": "Channel mention (Discord syntax — replace the ID)",
"userAvatar": "Member avatar URL (for thumbnail/icons)",
"serverIcon": "Server icon URL (for author/footer)"
}
},
"nav": {