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