diff --git a/web/next-env.d.ts b/web/next-env.d.ts index c4b7818fb..9edff1c7c 100644 --- a/web/next-env.d.ts +++ b/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx index 0bc26c6f3..1708bca10 100644 --- a/web/src/components/dashboard/config-editor.tsx +++ b/web/src/components/dashboard/config-editor.tsx @@ -5,35 +5,37 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { ChannelSelector } from '@/components/ui/channel-selector'; -import { Input } from '@/components/ui/input'; -import { RoleSelector } from '@/components/ui/role-selector'; +import { updateNestedField, updateSectionEnabled, updateSectionField } from '@/lib/config-updates'; +import { computePatches, deepEqual, type GuildConfig } from '@/lib/config-utils'; 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'; import { ConfigDiff } from './config-diff'; import { ConfigDiffModal } from './config-diff-modal'; +import { + AiAutoModSection, + AiSection, + ChallengesSection, + CommunityFeaturesSection, + EngagementSection, + GitHubSection, + MemorySection, + ModerationSection, + PermissionsSection, + ReputationSection, + StarboardSection, + TicketsSection, + TriageSection, + WelcomeSection, +} from './config-sections'; import { DiscardChangesButton } from './reset-defaults-button'; -import { SystemPromptEditor } from './system-prompt-editor'; - -/** Config sections exposed by the API — all fields optional for partial API responses. */ -type GuildConfig = DeepPartial; - -/** Shared input styling for text inputs and textareas in the config editor. */ -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; @@ -41,36 +43,8 @@ function generateId(): string { }); } -const DEFAULT_ACTIVITY_BADGES = [ - { days: 90, label: '👑 Legend' }, - { days: 30, label: '🌳 Veteran' }, - { days: 7, label: '🌿 Regular' }, - { days: 0, label: '🌱 Newcomer' }, -] as const; - -/** - * 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); - if (!Number.isFinite(num)) return undefined; - if (min !== undefined && num < min) return min; - if (max !== undefined && num > max) return max; - return num; -} - /** * Type guard that checks whether a value is a guild configuration object returned by the API. - * - * @returns `true` if the value is an object containing at least one known top-level section - * (`ai`, `welcome`, `spam`, `moderation`, `triage`, `starboard`, `permissions`, `memory`) and each present section is a plain object - * (not an array or null). Returns `false` otherwise. */ function isGuildConfig(data: unknown): data is GuildConfig { if (typeof data !== 'object' || data === null || Array.isArray(data)) return false; @@ -97,6 +71,7 @@ function isGuildConfig(data: unknown): data is GuildConfig { 'review', 'challenges', 'tickets', + 'aiAutoMod', ] as const; const hasKnownSection = knownSections.some((key) => key in obj); if (!hasKnownSection) return false; @@ -116,9 +91,7 @@ function isGuildConfig(data: unknown): data is GuildConfig { * * 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 a draft config exists; `null` otherwise. + * discard, and validate edits. */ export function ConfigEditor() { const [guildId, setGuildId] = useState(''); @@ -138,6 +111,7 @@ export function ConfigEditor() { /** Raw textarea strings — kept separate so partial input isn't stripped on every keystroke. */ const [dmStepsRaw, setDmStepsRaw] = useState(''); + const [protectRoleIdsRaw, setProtectRoleIdsRaw] = useState(''); const abortRef = useRef(null); @@ -215,6 +189,7 @@ export function ConfigEditor() { setSavedConfig(data); setDraftConfig(structuredClone(data)); setDmStepsRaw((data.welcome?.dmSequence?.steps ?? []).join('\n')); + setProtectRoleIdsRaw((data.moderation?.protectRoles?.roleIds ?? []).join(', ')); } catch (err) { if ((err as Error).name === 'AbortError') return; const msg = (err as Error).message || 'Failed to load config'; @@ -236,8 +211,6 @@ export function ConfigEditor() { return !deepEqual(savedConfig, draftConfig); }, [savedConfig, draftConfig]); - // Check for validation errors before allowing save. - // 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 @@ -257,6 +230,7 @@ export function ConfigEditor() { const patches = computePatches(savedConfig, draftConfig); return [...new Set(patches.map((p) => p.path.split('.')[0]))]; }, [savedConfig, draftConfig]); + // ── Warn on unsaved changes before navigation ────────────────── useEffect(() => { if (!hasChanges) return; @@ -270,7 +244,6 @@ export function ConfigEditor() { return () => window.removeEventListener('beforeunload', onBeforeUnload); }, [hasChanges]); - // ── Save changes (batched: parallel PATCH per section) ───────── // ── Open diff modal before saving ───────────────────────────── const openDiffModal = useCallback(() => { if (!guildId || !savedConfig || !draftConfig) return; @@ -302,6 +275,9 @@ export function ConfigEditor() { if (section === 'welcome') { setDmStepsRaw((savedConfig.welcome?.dmSequence?.steps ?? []).join('\n')); } + if (section === 'moderation') { + setProtectRoleIdsRaw((savedConfig.moderation?.protectRoles?.roleIds ?? []).join(', ')); + } toast.success(`Reverted ${section} changes.`); }, [savedConfig], @@ -339,7 +315,6 @@ export function ConfigEditor() { setSaving(true); - // Shared AbortController for all section saves - aborts all in-flight requests on 401 const saveAbortController = new AbortController(); const { signal } = saveAbortController; @@ -356,7 +331,6 @@ export function ConfigEditor() { }); if (res.status === 401) { - // Abort all other in-flight requests before redirecting saveAbortController.abort(); window.location.href = '/login'; throw new Error('Unauthorized'); @@ -384,8 +358,6 @@ export function ConfigEditor() { const hasFailures = results.some((r) => r.status === 'rejected'); if (hasFailures) { - // Partial failure: merge only succeeded sections into savedConfig so - // the user can retry failed sections without losing their unsaved edits. const succeededSections = Array.from(bySection.keys()).filter( (s) => !failedSections.includes(s), ); @@ -408,9 +380,7 @@ export function ConfigEditor() { } else { toast.success('Config saved successfully!'); setShowDiffModal(false); - // Store previous config for undo (1 level deep; scoped to current guild) setPrevSavedConfig({ guildId, config: structuredClone(savedConfig) as GuildConfig }); - // Full success: reload to get the authoritative version from the server await fetchConfig(guildId); } } catch (err) { @@ -421,21 +391,24 @@ export function ConfigEditor() { } }, [guildId, savedConfig, draftConfig, hasValidationErrors, fetchConfig]); - // Clear undo snapshot when guild changes to prevent cross-guild config corruption + // Clear undo snapshot when guild changes + // biome-ignore lint/correctness/useExhaustiveDependencies: guildId IS necessary - effect must re-run when guild changes useEffect(() => { setPrevSavedConfig(null); - }, []); + }, [guildId]); // ── Undo last save ───────────────────────────────────────────── const undoLastSave = useCallback(() => { if (!prevSavedConfig) return; - // Guard: discard snapshot if guild changed since save if (prevSavedConfig.guildId !== guildId) { setPrevSavedConfig(null); return; } setDraftConfig(structuredClone(prevSavedConfig.config)); setDmStepsRaw((prevSavedConfig.config.welcome?.dmSequence?.steps ?? []).join('\n')); + setProtectRoleIdsRaw( + (prevSavedConfig.config.moderation?.protectRoles?.roleIds ?? []).join(', '), + ); setPrevSavedConfig(null); toast.info('Reverted to previous saved state. Save again to apply.'); }, [prevSavedConfig, guildId]); @@ -459,271 +432,35 @@ export function ConfigEditor() { if (!savedConfig) return; setDraftConfig(structuredClone(savedConfig)); setDmStepsRaw((savedConfig.welcome?.dmSequence?.steps ?? []).join('\n')); + setProtectRoleIdsRaw((savedConfig.moderation?.protectRoles?.roleIds ?? []).join(', ')); toast.success('Changes discarded.'); }, [savedConfig]); - // ── Draft updaters ───────────────────────────────────────────── - const updateSystemPrompt = useCallback( - (value: string) => { - updateDraftConfig((prev) => { - if (!prev) return prev; - return { ...prev, ai: { ...prev.ai, systemPrompt: value } } as GuildConfig; - }); - }, - [updateDraftConfig], - ); - - const updateAiEnabled = useCallback( - (enabled: boolean) => { - updateDraftConfig((prev) => { - if (!prev) return prev; - return { ...prev, ai: { ...prev.ai, enabled } } as GuildConfig; - }); - }, - [updateDraftConfig], - ); - - const updateAiBlockedChannels = useCallback( - (channels: string[]) => { - updateDraftConfig((prev) => { - if (!prev) return prev; - return { ...prev, ai: { ...prev.ai, blockedChannelIds: channels } } as GuildConfig; - }); - }, - [updateDraftConfig], - ); - - const updateWelcomeEnabled = useCallback( - (enabled: boolean) => { - updateDraftConfig((prev) => { - if (!prev) return prev; - return { ...prev, welcome: { ...prev.welcome, enabled } } as GuildConfig; - }); - }, - [updateDraftConfig], - ); - - const updateWelcomeMessage = useCallback( - (message: string) => { - updateDraftConfig((prev) => { - if (!prev) return prev; - return { ...prev, welcome: { ...prev.welcome, message } } as GuildConfig; - }); - }, - [updateDraftConfig], - ); - - const updateWelcomeField = useCallback( - (field: string, value: unknown) => { - updateDraftConfig((prev) => { - if (!prev) return prev; - return { ...prev, welcome: { ...(prev.welcome ?? {}), [field]: value } } as GuildConfig; - }); - }, + // ── Section update handlers ──────────────────────────────────── + const createSectionUpdater = useCallback( + (section: K) => ({ + setEnabled: (enabled: boolean) => { + updateDraftConfig((prev) => updateSectionEnabled(prev, section, enabled)); + }, + setField: (field: string, value: unknown) => { + updateDraftConfig((prev) => updateSectionField(prev, section, field, value)); + }, + setNestedField: (nestedKey: string, field: string, value: unknown) => { + updateDraftConfig((prev) => updateNestedField(prev, section, nestedKey, field, value)); + }, + }), [updateDraftConfig], ); - const updateWelcomeRoleMenu = useCallback( - (field: string, value: unknown) => { - updateDraftConfig((prev) => { - if (!prev) return prev; - return { - ...prev, - welcome: { - ...(prev.welcome ?? {}), - roleMenu: { ...(prev.welcome?.roleMenu ?? {}), [field]: value }, - }, - } as GuildConfig; - }); - }, - [updateDraftConfig], - ); - - const updateWelcomeDmSequence = useCallback( - (field: string, value: unknown) => { - updateDraftConfig((prev) => { - if (!prev) return prev; - return { - ...prev, - welcome: { - ...(prev.welcome ?? {}), - dmSequence: { ...(prev.welcome?.dmSequence ?? {}), [field]: value }, - }, - } as GuildConfig; - }); - }, - [updateDraftConfig], - ); - - const updateModerationEnabled = useCallback( - (enabled: boolean) => { - updateDraftConfig((prev) => { - if (!prev) return prev; - return { ...prev, moderation: { ...prev.moderation, enabled } } as GuildConfig; - }); - }, - [updateDraftConfig], - ); - - const updateModerationField = useCallback( - (field: string, value: unknown) => { - updateDraftConfig((prev) => { - if (!prev) return prev; - return { ...prev, moderation: { ...prev.moderation, [field]: value } } as GuildConfig; - }); - }, - [updateDraftConfig], - ); - - const updateModerationDmNotification = useCallback( - (action: string, value: boolean) => { - updateDraftConfig((prev) => { - if (!prev) return prev; - return { - ...prev, - moderation: { - ...prev.moderation, - dmNotifications: { ...prev.moderation?.dmNotifications, [action]: value }, - }, - } as GuildConfig; - }); - }, - [updateDraftConfig], - ); - - const updateModerationEscalation = useCallback( - (enabled: boolean) => { - updateDraftConfig((prev) => { - if (!prev) return prev; - return { - ...prev, - moderation: { - ...prev.moderation, - escalation: { ...prev.moderation?.escalation, enabled }, - }, - } as GuildConfig; - }); - }, - [updateDraftConfig], - ); - - const updateAiAutoModField = useCallback( - (field: string, value: unknown) => { - updateDraftConfig((prev) => { - if (!prev) return prev; - return { - ...prev, - aiAutoMod: { ...prev.aiAutoMod, [field]: value }, - } as GuildConfig; - }); - }, - [updateDraftConfig], - ); - - const updateTriageEnabled = useCallback( - (enabled: boolean) => { - updateDraftConfig((prev) => { - if (!prev) return prev; - return { ...prev, triage: { ...prev.triage, enabled } } as GuildConfig; - }); - }, - [updateDraftConfig], - ); - - const updateTriageField = useCallback( - (field: string, value: unknown) => { - updateDraftConfig((prev) => { - if (!prev) return prev; - return { ...prev, triage: { ...prev.triage, [field]: value } } as GuildConfig; - }); - }, - [updateDraftConfig], - ); - - const updateStarboardField = useCallback( - (field: string, value: unknown) => { - updateDraftConfig((prev) => { - if (!prev) return prev; - return { ...prev, starboard: { ...prev.starboard, [field]: value } } as GuildConfig; - }); - }, - [updateDraftConfig], - ); - - const updateRateLimitField = useCallback( - (field: string, value: unknown) => { - updateDraftConfig((prev) => { - if (!prev) return prev; - return { - ...prev, - moderation: { - ...prev.moderation, - rateLimit: { ...prev.moderation?.rateLimit, [field]: value }, - }, - } as GuildConfig; - }); - }, - [updateDraftConfig], - ); - - const updateLinkFilterField = useCallback( - (field: string, value: unknown) => { - updateDraftConfig((prev) => { - if (!prev) return prev; - return { - ...prev, - moderation: { - ...prev.moderation, - linkFilter: { ...prev.moderation?.linkFilter, [field]: value }, - }, - } as GuildConfig; - }); - }, - [updateDraftConfig], - ); - - const updateProtectRolesField = useCallback( - (field: string, value: unknown) => { - updateDraftConfig((prev) => { - if (!prev) return prev; - const existingProtectRoles = prev.moderation?.protectRoles ?? { - enabled: true, - includeAdmins: true, - includeModerators: true, - includeServerOwner: true, - roleIds: [], - }; - return { - ...prev, - moderation: { - ...prev.moderation, - protectRoles: { ...existingProtectRoles, [field]: value }, - }, - } as GuildConfig; - }); - }, - [updateDraftConfig], - ); - - const updatePermissionsField = useCallback( - (field: string, value: unknown) => { - updateDraftConfig((prev) => { - if (!prev) return prev; - return { ...prev, permissions: { ...prev.permissions, [field]: value } } as GuildConfig; - }); - }, - [updateDraftConfig], - ); - - const updateMemoryField = useCallback( - (field: string, value: unknown) => { - updateDraftConfig((prev) => { - if (!prev) return prev; - return { ...prev, memory: { ...prev.memory, [field]: value } } as GuildConfig; - }); - }, - [updateDraftConfig], - ); + const aiUpdater = createSectionUpdater('ai'); + const welcomeUpdater = createSectionUpdater('welcome'); + const moderationUpdater = createSectionUpdater('moderation'); + const triageUpdater = createSectionUpdater('triage'); + const starboardUpdater = createSectionUpdater('starboard'); + const permissionsUpdater = createSectionUpdater('permissions'); + const memoryUpdater = createSectionUpdater('memory'); + const reputationUpdater = createSectionUpdater('reputation'); + const challengesUpdater = createSectionUpdater('challenges'); // ── No guild selected ────────────────────────────────────────── if (!guildId) { @@ -780,7 +517,6 @@ export function ConfigEditor() {

- {/* Undo last save — visible only after a successful save with no new changes */} {prevSavedConfig && !hasChanges && (