diff --git a/tests/modules/ai.test.js b/tests/modules/ai.test.js index b87073767..7b5a07a3a 100644 --- a/tests/modules/ai.test.js +++ b/tests/modules/ai.test.js @@ -49,8 +49,7 @@ describe('ai module', () => { addToHistory('ch1', 'user', 'hello'); const history = await getHistoryAsync('ch1'); expect(history.length).toBe(1); - expect(history[0]).toMatchObject({ role: 'user', content: 'hello' }); - expect(history[0].timestamp).toEqual(expect.any(Number)); + expect(history[0]).toEqual(expect.objectContaining({ role: 'user', content: 'hello' })); }); it('should hydrate DB history in-place when concurrent messages are added', async () => { @@ -75,8 +74,8 @@ describe('ai module', () => { resolveHydration({ rows: [ - { role: 'assistant', content: 'db reply', created_at: '2026-04-01T10:00:01.000Z' }, - { role: 'user', content: 'db message', created_at: '2026-04-01T10:00:00.000Z' }, + { role: 'assistant', content: 'db reply' }, + { role: 'user', content: 'db message' }, ], }); @@ -84,22 +83,11 @@ describe('ai module', () => { await asyncHistoryPromise; await vi.waitFor(() => { - expect(historyRef).toHaveLength(3); - expect(historyRef[0]).toMatchObject({ - role: 'user', - content: 'db message', - timestamp: Date.parse('2026-04-01T10:00:00.000Z'), - }); - expect(historyRef[1]).toMatchObject({ - role: 'assistant', - content: 'db reply', - timestamp: Date.parse('2026-04-01T10:00:01.000Z'), - }); - expect(historyRef[2]).toMatchObject({ - role: 'user', - content: 'concurrent message', - timestamp: expect.any(Number), - }); + expect(historyRef).toEqual([ + expect.objectContaining({ role: 'user', content: 'db message' }), + expect.objectContaining({ role: 'assistant', content: 'db reply' }), + expect.objectContaining({ role: 'user', content: 'concurrent message' }), + ]); expect(getConversationHistory().get('race-channel')).toBe(historyRef); }); }); @@ -119,7 +107,7 @@ describe('ai module', () => { expect(history[0].content).toBe('from db'); expect(history[1].content).toBe('response'); expect(mockQuery).toHaveBeenCalledWith( - expect.stringContaining('SELECT role, content, created_at FROM conversations'), + expect.stringContaining('SELECT role, content FROM conversations'), ['ch-new', 20], ); }); diff --git a/web/src/app/api/auth/[...nextauth]/route.ts b/web/src/app/api/auth/[...nextauth]/route.ts index 0a4c217bc..970218e37 100644 --- a/web/src/app/api/auth/[...nextauth]/route.ts +++ b/web/src/app/api/auth/[...nextauth]/route.ts @@ -1,6 +1,66 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; import NextAuth from 'next-auth'; -import { authOptions } from '@/lib/auth'; +import { getAuthOptions } from '@/lib/auth'; +import { logger } from '@/lib/logger'; -const handler = NextAuth(authOptions); +function fallbackAuthResponse(request: NextRequest, error: unknown) { + const pathname = request.nextUrl.pathname; + logger.warn('[auth] Auth route requested without valid environment configuration', { + pathname, + error: error instanceof Error ? error.message : String(error), + }); -export { handler as GET, handler as POST }; + if (pathname.endsWith('/session')) { + return NextResponse.json({}, { status: 200 }); + } + + if (pathname.endsWith('/providers')) { + return NextResponse.json({}, { status: 200 }); + } + + if (pathname.endsWith('/csrf')) { + return NextResponse.json({ csrfToken: '' }, { status: 200 }); + } + + return NextResponse.json({ error: 'AuthUnavailable' }, { status: 503 }); +} + +// Cache the NextAuth handler after the first successful creation to avoid +// reconstructing it on every request (getAuthOptions() is already cached internally). +let cachedHandler: ReturnType | undefined; + +function getHandler() { + if (!cachedHandler) { + cachedHandler = NextAuth(getAuthOptions()); + } + return cachedHandler; +} + +async function handleAuth( + request: NextRequest, + context: { params: Promise<{ nextauth: string[] }> }, +) { + try { + const handler = getHandler(); + return await handler(request, context); + } catch (error) { + // Reset cache on failure so next request retries handler creation + cachedHandler = undefined; + return fallbackAuthResponse(request, error); + } +} + +export async function GET( + request: NextRequest, + context: { params: Promise<{ nextauth: string[] }> }, +) { + return handleAuth(request, context); +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ nextauth: string[] }> }, +) { + return handleAuth(request, context); +} diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx index 5b87c850a..0c521238f 100644 --- a/web/src/components/dashboard/config-editor.tsx +++ b/web/src/components/dashboard/config-editor.tsx @@ -1,91 +1,180 @@ 'use client'; -import { Save } from 'lucide-react'; -import { ConfigProvider, useConfigContext } from '@/components/dashboard/config-context'; -import { Button } from '@/components/ui/button'; +import { useEffect, useState } from 'react'; +import { SELECTED_GUILD_KEY } from '@/lib/guild-selection'; +import type { GuildConfig } from './config-editor-utils'; +import { isGuildConfig } from './config-editor-utils'; import { DiscardChangesButton } from './reset-defaults-button'; import { SystemPromptEditor } from './system-prompt-editor'; -export function ConfigEditor() { - return ( - - - - ); +function getSelectedGuildId(): string { + try { + return localStorage.getItem(SELECTED_GUILD_KEY) ?? ''; + } catch { + return ''; + } } -function ConfigEditorContent() { - const { - guildId, - draftConfig, - loading, - error, - saving, - hasChanges, - hasValidationErrors, - openDiffModal, - discardChanges, - fetchConfig, - updateDraftConfig, - } = useConfigContext(); - - if (!guildId) { - return
Select a server to manage its configuration.
; - } +export function ConfigEditor() { + const [draftConfig, setDraftConfig] = useState(null); + const [savedConfig, setSavedConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [hasChanges, setHasChanges] = useState(false); + + useEffect(() => { + const guildId = getSelectedGuildId(); + if (!guildId) { + setLoading(false); + setDraftConfig({}); + return; + } + + let cancelled = false; + + async function loadConfig() { + setLoading(true); + setError(null); + + try { + const res = await fetch(`/api/guilds/${encodeURIComponent(guildId)}/config`, { + cache: 'no-store', + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + + const data: unknown = await res.json(); + if (!isGuildConfig(data)) { + throw new Error('Invalid config response'); + } + + if (!cancelled) { + setDraftConfig(data); + setSavedConfig(data); + setHasChanges(false); + } + } catch (err) { + if (!cancelled) { + setError((err as Error).message || 'Failed to load config'); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + void loadConfig(); + + return () => { + cancelled = true; + }; + }, []); if (loading) { - return
Loading configuration...
; + return
Loading configuration…
; } if (error) { - return ( -
-

{error}

- -
- ); + return
{error}
; + } + + async function saveChanges() { + const guildId = getSelectedGuildId(); + if (!guildId || !draftConfig) { + return; + } + + setSaving(true); + setError(null); + + try { + const res = await fetch(`/api/guilds/${encodeURIComponent(guildId)}/config`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + path: 'ai.systemPrompt', + value: draftConfig.ai?.systemPrompt ?? '', + }), + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + + const updatedSection: unknown = await res.json(); + const nextDraftConfig = { + ...(draftConfig ?? {}), + ai: updatedSection && typeof updatedSection === 'object' ? updatedSection : draftConfig.ai, + } satisfies GuildConfig; + + setDraftConfig(nextDraftConfig); + setSavedConfig(nextDraftConfig); + setHasChanges(false); + } catch (err) { + setError((err as Error).message || 'Failed to save config'); + } finally { + setSaving(false); + } } - if (!draftConfig) { - return null; + function discardChanges() { + if (!savedConfig) { + return; + } + + setDraftConfig(structuredClone(savedConfig)); + setHasChanges(false); } return ( -
-
+
+
-

Bot Configuration

-

Manage core bot settings in one place.

+

Bot Configuration

+

Manage guild configuration sections.

- - + +
-
-

AI Chat

+
+

AI Chat

- updateDraftConfig((prev) => ({ - ...prev, - ai: { ...prev.ai, systemPrompt: value }, - })) - } + value={draftConfig?.ai?.systemPrompt ?? ''} + onChange={(systemPrompt) => { + setDraftConfig((prev) => ({ + ...(prev ?? {}), + ai: { + ...(prev?.ai ?? {}), + systemPrompt, + }, + })); + setHasChanges(true); + }} />
- {/* TODO: Integrate DiscordMarkdownEditor for welcome message template editing */} -
-

Welcome Messages

+
+

Welcome Messages

- Configure welcome message templates for new server members. + Welcome message configuration is available in the settings workspace.

diff --git a/web/src/components/dashboard/config-sections/ReputationSection.tsx b/web/src/components/dashboard/config-sections/ReputationSection.tsx deleted file mode 100644 index 530cd54dd..000000000 --- a/web/src/components/dashboard/config-sections/ReputationSection.tsx +++ /dev/null @@ -1,156 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { Card, CardContent, CardTitle } from '@/components/ui/card'; -import { parseNumberInput } from '@/lib/config-normalization'; -import type { GuildConfig } from '@/lib/config-utils'; -import { ToggleSwitch } from '../toggle-switch'; - -interface ReputationSectionProps { - draftConfig: GuildConfig; - saving: boolean; - onEnabledChange: (enabled: boolean) => void; - /** @deprecated Prefer updateDraftConfig for consistent update patterns. */ - onFieldChange: (field: string, value: unknown) => void; - updateDraftConfig: (updater: (prev: GuildConfig) => GuildConfig) => void; -} - -/** Shared input styling for text inputs. */ -const inputClasses = - 'w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'; - -const DEFAULT_LEVEL_THRESHOLDS = [100, 300, 600, 1000, 1500, 2500, 4000, 6000, 8500, 12000]; - -/** - * Reputation / XP configuration section. - * - * Provides controls for XP settings, cooldowns, and level thresholds. - */ -export function ReputationSection({ - draftConfig, - saving, - onEnabledChange, - onFieldChange: _onFieldChange, - updateDraftConfig, -}: ReputationSectionProps) { - const xpRange = draftConfig.reputation?.xpPerMessage ?? [5, 15]; - const levelThresholds = draftConfig.xp?.levelThresholds ?? DEFAULT_LEVEL_THRESHOLDS; - - // Local state for level thresholds raw input (parsed on blur) - const thresholdsDisplay = levelThresholds.join(', '); - const [thresholdsRaw, setThresholdsRaw] = useState(thresholdsDisplay); - useEffect(() => { - setThresholdsRaw(thresholdsDisplay); - }, [thresholdsDisplay]); - - return ( - - -
- Reputation / XP - -
-
- - - -
- -
-
- ); -} diff --git a/web/src/components/dashboard/config-sections/index.ts b/web/src/components/dashboard/config-sections/index.ts index 65ad60c13..7cf8e2b4e 100644 --- a/web/src/components/dashboard/config-sections/index.ts +++ b/web/src/components/dashboard/config-sections/index.ts @@ -7,6 +7,5 @@ export { EngagementSection } from './EngagementSection'; export { GitHubSection } from './GitHubSection'; export { MemorySection } from './MemorySection'; export { PermissionsSection } from './PermissionsSection'; -export { ReputationSection } from './ReputationSection'; export { StarboardSection } from './StarboardSection'; export { TicketsSection } from './TicketsSection'; diff --git a/web/src/components/ui/embed-builder.tsx b/web/src/components/ui/embed-builder.tsx new file mode 100644 index 000000000..311eaf941 --- /dev/null +++ b/web/src/components/ui/embed-builder.tsx @@ -0,0 +1,947 @@ +'use client'; + +import { + AlignLeft, + ChevronDown, + ChevronUp, + Clock, + Columns, + Eye, + ImageIcon, + Plus, + Settings, + Trash2, + Type, +} from 'lucide-react'; +import * as React from 'react'; +import { generateId } from '@/components/dashboard/config-editor-utils'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { cn } from '@/lib/utils'; + +// ── Types ─────────────────────────────────────────────────────────── + +export interface EmbedField { + id?: string; + name: string; + value: string; + inline: boolean; +} + +export type ThumbnailType = 'none' | 'user_avatar' | 'server_icon' | 'custom'; +export type FormatType = 'text' | 'embed' | 'both'; + +export interface EmbedConfig { + color: string; + title: string; + description: string; + thumbnailType: ThumbnailType; + thumbnailUrl: string; + fields: EmbedField[]; + footerText: string; + footerIconUrl: string; + imageUrl: string; + showTimestamp: boolean; + format: FormatType; +} + +export interface EmbedBuilderProps { + value: EmbedConfig; + onChange: (config: EmbedConfig) => void; + variables?: string[]; + className?: string; +} + +// ── Constants ─────────────────────────────────────────────────────── + +export const CHAR_LIMITS = { + title: 256, + description: 4096, + fieldName: 256, + fieldValue: 1024, + footer: 2048, + total: 6000, +} as const; + +const DISCORD_PRESET_COLORS = [ + '#5865F2', // Blurple + '#57F287', // Green + '#FEE75C', // Yellow + '#EB459E', // Fuchsia + '#ED4245', // Red + '#FFFFFF', // White + '#E67E22', // Orange + '#1ABC9C', // Teal + '#3498DB', // Blue + '#9B59B6', // Purple +]; + +const THUMBNAIL_OPTIONS: { value: ThumbnailType; label: string }[] = [ + { value: 'none', label: 'None' }, + { value: 'user_avatar', label: 'User Avatar' }, + { value: 'server_icon', label: 'Server Icon' }, + { value: 'custom', label: 'Custom URL' }, +]; + +const FORMAT_OPTIONS: { value: FormatType; label: string }[] = [ + { value: 'text', label: 'Text Only' }, + { value: 'embed', label: 'Embed Only' }, + { value: 'both', label: 'Text + Embed' }, +]; + +// ── Helpers ───────────────────────────────────────────────────────── + +function createFieldId(): string { + return generateId(); +} + +function createEmptyField(): EmbedField { + return { + id: createFieldId(), + name: '', + value: '', + inline: false, + }; +} + +function ensureFieldIds(fields: EmbedField[]): EmbedField[] { + let changed = false; + const nextFields = fields.map((field) => { + if (field.id) return field; + changed = true; + return { ...field, id: createFieldId() }; + }); + + return changed ? nextFields : fields; +} + +function appendVariable(text: string, variable: string, maxLength: number): string { + const normalizedText = text.slice(0, maxLength); + const token = `{{${variable}}}`; + if (normalizedText.length + token.length > maxLength) { + return normalizedText; + } + + return `${normalizedText}${token}`; +} + +function sanitizeColorInput(rawValue: string): string { + const normalized = rawValue.trim().replace(/^#*/, ''); + const hex = normalized + .replace(/[^0-9a-f]/gi, '') + .toUpperCase() + .slice(0, 6); + return hex ? `#${hex}` : '#'; +} + +function isValidHexColor(value: string): boolean { + return /^#[0-9A-F]{6}$/i.test(value); +} + +export function defaultEmbedConfig(): EmbedConfig { + return { + color: '#5865F2', + title: '', + description: '', + thumbnailType: 'none', + thumbnailUrl: '', + fields: [], + footerText: '', + footerIconUrl: '', + imageUrl: '', + showTimestamp: false, + format: 'embed', + }; +} + +export function getTotalCharCount(config: EmbedConfig): number { + let total = config.title.length + config.description.length + config.footerText.length; + for (const field of config.fields) { + total += field.name.length + field.value.length; + } + return total; +} + +function tokenizeVariableSegments(text: string): string[] { + const segments: string[] = []; + let cursor = 0; + + while (cursor < text.length) { + const variableStart = text.indexOf('{{', cursor); + if (variableStart === -1) { + segments.push(text.slice(cursor)); + break; + } + + if (variableStart > cursor) { + segments.push(text.slice(cursor, variableStart)); + } + + const variableEnd = text.indexOf('}}', variableStart + 2); + if (variableEnd === -1) { + segments.push(text.slice(variableStart)); + break; + } + + segments.push(text.slice(variableStart, variableEnd + 2)); + cursor = variableEnd + 2; + } + + return segments; +} + +function tokenizeMarkdownSegments(line: string): string[] { + const segments: string[] = []; + let cursor = 0; + + while (cursor < line.length) { + if (line.startsWith('{{', cursor)) { + const variableEnd = line.indexOf('}}', cursor + 2); + if (variableEnd !== -1) { + segments.push(line.slice(cursor, variableEnd + 2)); + cursor = variableEnd + 2; + continue; + } + } + + if (line.startsWith('**', cursor)) { + const boldEnd = line.indexOf('**', cursor + 2); + if (boldEnd !== -1) { + segments.push(line.slice(cursor, boldEnd + 2)); + cursor = boldEnd + 2; + continue; + } + } + + if (line.startsWith('*', cursor)) { + const italicEnd = line.indexOf('*', cursor + 1); + if (italicEnd !== -1) { + segments.push(line.slice(cursor, italicEnd + 1)); + cursor = italicEnd + 1; + continue; + } + } + + if (line.startsWith('`', cursor)) { + const codeEnd = line.indexOf('`', cursor + 1); + if (codeEnd !== -1) { + segments.push(line.slice(cursor, codeEnd + 1)); + cursor = codeEnd + 1; + continue; + } + } + + const nextTokenStarts = [ + line.indexOf('{{', cursor + 1), + line.indexOf('**', cursor + 1), + line.indexOf('*', cursor + 1), + line.indexOf('`', cursor + 1), + ].filter((index) => index !== -1); + const nextTokenStart = nextTokenStarts.length > 0 ? Math.min(...nextTokenStarts) : line.length; + + segments.push(line.slice(cursor, nextTokenStart)); + cursor = nextTokenStart; + } + + return segments; +} + +/** Render template variables as styled badges in a string for preview */ +function renderVariablePreview(text: string): React.ReactNode[] { + if (!text) return []; + + return tokenizeVariableSegments(text).map((part, index) => { + if (part.startsWith('{{') && part.endsWith('}}')) { + const varName = part.slice(2, -2); + return ( + + {varName} + + ); + } + return {part}; + }); +} + +/** Very lightweight Discord markdown → HTML (bold, italic, inline code) */ +function renderMarkdownSegment(segment: string, lineIndex: number, segmentIndex: number) { + if (segment.startsWith('{{') && segment.endsWith('}}')) { + const varName = segment.slice(2, -2); + return ( + + {varName} + + ); + } + + if (segment.startsWith('**') && segment.endsWith('**')) { + return {segment.slice(2, -2)}; + } + + if (segment.startsWith('*') && segment.endsWith('*')) { + return {segment.slice(1, -1)}; + } + + if (segment.startsWith('`') && segment.endsWith('`')) { + return ( + + {segment.slice(1, -1)} + + ); + } + + return {segment}; +} + +function renderDiscordMarkdown(text: string): React.ReactNode[] { + const lines = text.split('\n'); + const result: React.ReactNode[] = []; + + for (let li = 0; li < lines.length; li++) { + if (li > 0) result.push(
); + const line = lines[li]; + const segments = tokenizeMarkdownSegments(line); + for (let si = 0; si < segments.length; si++) { + const seg = segments[si]; + if (!seg) continue; + result.push(renderMarkdownSegment(seg, li, si)); + } + } + return result; +} + +function getCharCountClassName(ratio: number): string { + if (ratio >= 1) { + return 'text-destructive font-semibold'; + } + + if (ratio >= 0.9) { + return 'text-yellow-500'; + } + + return 'text-muted-foreground'; +} + +function formatPreviewTimestamp(date: Date): string { + return new Intl.DateTimeFormat('en-US', { + hour: 'numeric', + minute: '2-digit', + }).format(date); +} + +// ── CharCount indicator ───────────────────────────────────────────── + +function CharCount({ current, max }: { current: number; max: number }) { + const ratio = current / max; + return ( + + {current}/{max} + + ); +} + +// ── Variable Badge Palette ────────────────────────────────────────── + +function VariablePalette({ + variables, + onInsert, +}: { + variables: string[]; + onInsert: (variable: string) => void; +}) { + if (!variables.length) return null; + return ( +
+ {variables.map((v) => ( + + ))} +
+ ); +} + +// ── Embed Preview ─────────────────────────────────────────────────── + +function EmbedPreview({ config }: { config: EmbedConfig }) { + const hasContent = + config.title || + config.description || + config.fields.length > 0 || + config.footerText || + config.imageUrl || + config.showTimestamp; + + const previewTimestamp = React.useMemo(() => formatPreviewTimestamp(new Date()), []); + + if (!hasContent) { + return ( +
+ Start editing to see a preview +
+ ); + } + + return ( +
+
+ {/* Accent color bar */} +
+ +
+
+
+ {/* Title */} + {config.title && ( +
+ {renderVariablePreview(config.title)} +
+ )} + + {/* Description */} + {config.description && ( +
+ {renderDiscordMarkdown(config.description)} +
+ )} +
+ + {/* Thumbnail */} + {config.thumbnailType !== 'none' && ( +
+ {config.thumbnailType === 'custom' && config.thumbnailUrl ? ( + Thumbnail + ) : ( +
+ {config.thumbnailType === 'user_avatar' ? 'Avatar' : 'Icon'} +
+ )} +
+ )} +
+ + {/* Fields */} + {config.fields.length > 0 && ( +
+ {config.fields.map((field, i) => { + const renderedName = renderVariablePreview(field.name); + const renderedValue = renderDiscordMarkdown(field.value); + + return ( +
+
+ {renderedName.length > 0 ? renderedName : '\u200b'} +
+
+ {renderedValue.length > 0 ? renderedValue : '\u200b'} +
+
+ ); + })} +
+ )} + + {/* Image */} + {config.imageUrl && ( +
+ Embed +
+ )} + + {/* Footer */} + {(config.footerText || config.showTimestamp) && ( +
+ {config.footerIconUrl && ( + Footer icon + )} + {config.footerText && {renderVariablePreview(config.footerText)}} + {config.footerText && config.showTimestamp && } + {config.showTimestamp && {previewTimestamp}} +
+ )} +
+
+
+ ); +} + +// ── Main Component ────────────────────────────────────────────────── + +function EmbedBuilder({ value, onChange, variables = [], className }: EmbedBuilderProps) { + const [colorInput, setColorInput] = React.useState(value.color); + const lastHydratedFieldsKeyRef = React.useRef(null); + + React.useEffect(() => { + setColorInput(value.color); + }, [value.color]); + + React.useEffect(() => { + if (!value.fields.some((field) => !field.id)) { + lastHydratedFieldsKeyRef.current = null; + return; + } + + const hydrationKey = JSON.stringify(value.fields.map(({ id: _id, ...field }) => field)); + if (lastHydratedFieldsKeyRef.current === hydrationKey) { + return; + } + + lastHydratedFieldsKeyRef.current = hydrationKey; + + const fieldsWithIds = ensureFieldIds(value.fields); + if (fieldsWithIds !== value.fields) { + onChange({ ...value, fields: fieldsWithIds }); + } + }, [value, onChange]); + + const update = React.useCallback( + (patch: Partial) => { + onChange({ ...value, ...patch }); + }, + [value, onChange], + ); + + const updateField = React.useCallback( + (index: number, patch: Partial) => { + const fields = [...value.fields]; + fields[index] = { ...fields[index], ...patch }; + onChange({ ...value, fields }); + }, + [value, onChange], + ); + + const addField = React.useCallback(() => { + onChange({ + ...value, + fields: [...value.fields, createEmptyField()], + }); + }, [value, onChange]); + + const removeField = React.useCallback( + (index: number) => { + onChange({ + ...value, + fields: value.fields.filter((_, i) => i !== index), + }); + }, + [value, onChange], + ); + + const moveField = React.useCallback( + (index: number, direction: 'up' | 'down') => { + const fields = [...value.fields]; + const target = direction === 'up' ? index - 1 : index + 1; + if (target < 0 || target >= fields.length) return; + [fields[index], fields[target]] = [fields[target], fields[index]]; + onChange({ ...value, fields }); + }, + [value, onChange], + ); + + const totalChars = getTotalCharCount(value); + + return ( +
+ {/* ── Editor Panel ─────────────────── */} +
+
+

+ + Embed Editor +

+
+ Total: + +
+
+ + {/* Format selector */} +
+ Format +
+ {FORMAT_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* Color picker */} +
+ +
+
+ {DISCORD_PRESET_COLORS.map((c) => ( +
+ { + const nextColor = sanitizeColorInput(e.target.value); + setColorInput(nextColor); + if (isValidHexColor(nextColor)) { + update({ color: nextColor }); + } + }} + className="h-8 w-24 font-mono text-xs" + maxLength={7} + placeholder="#5865F2" + /> +
+
+ + {/* Title */} +
+
+ + +
+ update({ title: e.target.value })} + placeholder="Embed title..." + maxLength={CHAR_LIMITS.title} + /> + {variables.length > 0 && ( + update({ title: appendVariable(value.title, v, CHAR_LIMITS.title) })} + /> + )} +
+ + {/* Description */} +
+
+ + +
+