diff --git a/web/package.json b/web/package.json index fe42d6704..11776e144 100644 --- a/web/package.json +++ b/web/package.json @@ -33,9 +33,7 @@ "recharts": "^3.7.0", "server-only": "^0.0.1", "sonner": "^2.0.7", - "tailwind-merge": "^3.5.0", - "next-themes": "^0.4.6", - "react-hook-form": "^7.56.4" + "tailwind-merge": "^3.5.0" }, "devDependencies": { "@tailwindcss/postcss": "^4.2.1", diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx index bed31cc6c..2c58845ca 100644 --- a/web/src/components/dashboard/config-editor.tsx +++ b/web/src/components/dashboard/config-editor.tsx @@ -6,6 +6,7 @@ import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; +import { RoleSelector } from '@/components/ui/role-selector'; import { GUILD_SELECTED_EVENT, SELECTED_GUILD_KEY } from '@/lib/guild-selection'; import type { BotConfig, DeepPartial } from '@/types/config'; import { SYSTEM_PROMPT_MAX_LENGTH } from '@/types/config'; @@ -20,6 +21,24 @@ type GuildConfig = DeepPartial; 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'; +/** + * Generate a UUID with fallback for environments without crypto.randomUUID. + * + * @returns A UUID v4 string. + */ +function generateId(): string { + // Use crypto.randomUUID if available + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + // Fallback: generate a UUID-like string + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + const DEFAULT_ACTIVITY_BADGES = [ { days: 90, label: '👑 Legend' }, { days: 30, label: '🌳 Veteran' }, @@ -27,7 +46,14 @@ const DEFAULT_ACTIVITY_BADGES = [ { days: 0, label: '🌱 Newcomer' }, ] as const; -/** Parse a number input value, enforcing optional min/max constraints. Returns undefined if invalid. */ +/** + * Parse a numeric text input into a number, applying optional minimum/maximum bounds. + * + * @param raw - The input string to parse; an empty string yields `undefined`. + * @param min - Optional lower bound; if the parsed value is less than `min`, `min` is returned. + * @param max - Optional upper bound; if the parsed value is greater than `max`, `max` is returned. + * @returns `undefined` if `raw` is empty or cannot be parsed as a finite number, otherwise the parsed number (clamped to `min`/`max` when provided). + */ function parseNumberInput(raw: string, min?: number, max?: number): number | undefined { if (raw === '') return undefined; const num = Number(raw); @@ -37,32 +63,6 @@ function parseNumberInput(raw: string, min?: number, max?: number): number | und return num; } -function parseRoleMenuOptions(raw: string) { - return raw - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => { - const [label = '', roleId = '', ...descParts] = line.split('|'); - const description = descParts.join('|').trim(); - return { - label: label.trim(), - roleId: roleId.trim(), - ...(description ? { description } : {}), - }; - }) - .filter((opt) => opt.label && opt.roleId) - .slice(0, 25); -} - -function stringifyRoleMenuOptions( - options: Array<{ label?: string; roleId?: string; description?: string }> = [], -) { - return options - .map((opt) => [opt.label ?? '', opt.roleId ?? '', opt.description ?? ''].join('|')) - .join('\n'); -} - /** * Type guard that checks whether a value is a guild configuration object returned by the API. * @@ -110,11 +110,13 @@ function isGuildConfig(data: unknown): data is GuildConfig { } /** - * Renders the configuration editor for a selected guild, allowing viewing and editing of AI, welcome, moderation, and triage settings. + * Edit a guild's bot configuration through a multi-section UI. * - * The component loads the guild's authoritative config from the API, keeps a mutable draft for user edits, computes and applies patch updates per top-level section, warns on unsaved changes, and provides keyboard and UI controls for saving or discarding edits. + * Loads the authoritative config for the selected guild, maintains a mutable draft for user edits, + * computes and applies per-section patches to persist changes, and provides controls to save, + * discard, and validate edits (including an unsaved-changes warning and keyboard shortcut). * - * @returns The editor UI as JSX when a guild is selected and the draft config is available; `null` while no draft is present (or when rendering is handled by loading/error/no-selection states). + * @returns The editor UI as JSX when a guild is selected and a draft config exists; `null` otherwise. */ export function ConfigEditor() { const [guildId, setGuildId] = useState(''); @@ -128,7 +130,6 @@ export function ConfigEditor() { const [draftConfig, setDraftConfig] = useState(null); /** Raw textarea strings — kept separate so partial input isn't stripped on every keystroke. */ - const [roleMenuRaw, setRoleMenuRaw] = useState(''); const [dmStepsRaw, setDmStepsRaw] = useState(''); const abortRef = useRef(null); @@ -197,9 +198,15 @@ export function ConfigEditor() { throw new Error('Invalid config response'); } + // Ensure role menu options have stable IDs + if (data.welcome?.roleMenu?.options) { + data.welcome.roleMenu.options = data.welcome.roleMenu.options.map((opt) => ({ + ...opt, + id: opt.id || generateId(), + })); + } setSavedConfig(data); setDraftConfig(structuredClone(data)); - setRoleMenuRaw(stringifyRoleMenuOptions(data.welcome?.roleMenu?.options ?? [])); setDmStepsRaw((data.welcome?.dmSequence?.steps ?? []).join('\n')); } catch (err) { if ((err as Error).name === 'AbortError') return; @@ -226,6 +233,13 @@ export function ConfigEditor() { // Currently only validates system prompt length; extend with additional checks as needed. const hasValidationErrors = useMemo(() => { if (!draftConfig) return false; + // Role menu validation: all options must have non-empty label and roleId + const roleMenuEnabled = draftConfig.welcome?.roleMenu?.enabled ?? false; + const roleMenuOptions = draftConfig.welcome?.roleMenu?.options ?? []; + const hasRoleMenuErrors = roleMenuOptions.some( + (opt) => !opt.label?.trim() || !opt.roleId?.trim(), + ); + if (roleMenuEnabled && hasRoleMenuErrors) return true; const promptLength = draftConfig.ai?.systemPrompt?.length ?? 0; return promptLength > SYSTEM_PROMPT_MAX_LENGTH; }, [draftConfig]); @@ -371,7 +385,6 @@ export function ConfigEditor() { const discardChanges = useCallback(() => { if (!savedConfig) return; setDraftConfig(structuredClone(savedConfig)); - setRoleMenuRaw(stringifyRoleMenuOptions(savedConfig.welcome?.roleMenu?.options ?? [])); setDmStepsRaw((savedConfig.welcome?.dmSequence?.steps ?? []).join('\n')); toast.success('Changes discarded.'); }, [savedConfig]); @@ -725,9 +738,10 @@ export function ConfigEditor() { -