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 && (
- {/* Save button with unsaved-changes indicator dot */}
)}
- {/* AI section */}
-
-
-
-
- AI Chat
- Configure the AI assistant behavior.
-
-
-
-
-
-
- {/* System Prompt */}
- aiUpdater.setField('systemPrompt', value)}
+ onBlockedChannelsChange={(channels) => aiUpdater.setField('blockedChannelIds', channels)}
/>
- {/* AI Blocked Channels */}
-
-
- Blocked Channels
-
- The AI will not respond in these channels (or their threads).
-
-
-
- {guildId ? (
-
- ) : (
- Select a server first
- )}
-
-
-
- {/* Welcome section */}
-
-
-
-
- Welcome Messages
- Greet new members when they join the server.
-
-
-
-
-
-
- Welcome Message
-
-
- Use {'{user}'} for the member mention and {'{memberCount}'} for the server member count.
-
-
-
-
- Rules Channel
- updateWelcomeField('rulesChannel', selected[0] ?? null)}
- placeholder="Select rules channel"
- disabled={saving}
- maxSelections={1}
- filter="text"
- />
-
-
- Verified Role
- updateWelcomeField('verifiedRole', selected[0] ?? null)}
- placeholder="Select role granted after rules acceptance"
- disabled={saving}
- maxSelections={1}
- />
-
-
- Intro Channel
- updateWelcomeField('introChannel', selected[0] ?? null)}
- placeholder="Select channel to prompt member intros"
- disabled={saving}
- maxSelections={1}
- filter="text"
- />
-
-
+ {/* Welcome Section */}
+ welcomeUpdater.setField('message', value)}
+ onFieldChange={welcomeUpdater.setField}
+ onRoleMenuChange={(field, value) => welcomeUpdater.setNestedField('roleMenu', field, value)}
+ onDmSequenceChange={(field, value) =>
+ welcomeUpdater.setNestedField('dmSequence', field, value)
+ }
+ onDmStepsRawChange={setDmStepsRaw}
+ />
-
- Role Menu
-
-
- Enable self-assignable role menu
-
- updateWelcomeRoleMenu('enabled', v)}
- disabled={saving}
- label="Role Menu"
- />
-
-
- {(draftConfig.welcome?.roleMenu?.options ?? []).map((opt, i) => (
-
- ))}
-
{
- const opts = [
- ...(draftConfig.welcome?.roleMenu?.options ?? []),
- { id: generateId(), label: '', roleId: '' },
- ];
- updateWelcomeRoleMenu('options', opts);
- }}
- disabled={saving || (draftConfig.welcome?.roleMenu?.options ?? []).length >= 25}
- >
- + Add Role Option
-
-
-
-
-
- DM Sequence
-
- Enable onboarding DMs
- updateWelcomeDmSequence('enabled', v)}
- disabled={saving}
- label="DM Sequence"
- />
-
-
-
-
+ {/* Moderation Section */}
+
+ moderationUpdater.setNestedField('dmNotifications', action, value)
+ }
+ onEscalationChange={(enabled) =>
+ moderationUpdater.setNestedField('escalation', 'enabled', enabled)
+ }
+ onRateLimitChange={(field, value) =>
+ moderationUpdater.setNestedField('rateLimit', field, value)
+ }
+ onLinkFilterChange={(field, value) =>
+ moderationUpdater.setNestedField('linkFilter', field, value)
+ }
+ onProtectRolesChange={(field, value) =>
+ moderationUpdater.setNestedField('protectRoles', field, value)
+ }
+ onProtectRoleIdsRawChange={setProtectRoleIdsRaw}
+ />
- {/* Moderation section */}
- {draftConfig.moderation && (
-
-
-
-
- Moderation
-
- Configure moderation, escalation, and logging settings.
-
-
-
-
-
-
-
- Alert Channel
-
- updateModerationField('alertChannelId', selected[0] ?? null)
- }
- placeholder="Select channel for moderation alerts"
- disabled={saving}
- maxSelections={1}
- filter="text"
- />
-
-
- Auto-delete flagged messages
- updateModerationField('autoDelete', v)}
- disabled={saving}
- label="Auto Delete"
- />
-
-
- DM Notifications
- {(['warn', 'timeout', 'kick', 'ban'] as const).map((action) => (
-
- {action}
- updateModerationDmNotification(action, v)}
- disabled={saving}
- label={`DM on ${action}`}
- />
-
- ))}
-
-
- Escalation Enabled
- updateModerationEscalation(v)}
- disabled={saving}
- label="Escalation"
- />
-
-
- {/* Rate Limiting sub-section */}
-
- Rate Limiting
-
- Enabled
- updateRateLimitField('enabled', v)}
- disabled={saving}
- label="Rate Limiting"
- />
-
-
-
- Max Messages
- {
- const num = parseNumberInput(e.target.value, 1);
- if (num !== undefined) updateRateLimitField('maxMessages', num);
- }}
- disabled={saving}
- className={inputClasses}
- />
-
-
- Window (seconds)
- {
- const num = parseNumberInput(e.target.value, 1);
- if (num !== undefined) updateRateLimitField('windowSeconds', num);
- }}
- disabled={saving}
- className={inputClasses}
- />
-
-
-
-
- Mute After Triggers
- {
- const num = parseNumberInput(e.target.value, 1);
- if (num !== undefined) updateRateLimitField('muteAfterTriggers', num);
- }}
- disabled={saving}
- className={inputClasses}
- />
-
-
- Mute Window (s)
- {
- const num = parseNumberInput(e.target.value, 1);
- if (num !== undefined) updateRateLimitField('muteWindowSeconds', num);
- }}
- disabled={saving}
- className={inputClasses}
- />
-
-
- Mute Duration (s)
- {
- const num = parseNumberInput(e.target.value, 1);
- if (num !== undefined) updateRateLimitField('muteDurationSeconds', num);
- }}
- disabled={saving}
- className={inputClasses}
- />
-
-
-
-
- {/* Link Filtering sub-section */}
-
- Link Filtering
-
- Enabled
- updateLinkFilterField('enabled', v)}
- disabled={saving}
- label="Link Filtering"
- />
-
-
- Blocked Domains
-
- updateLinkFilterField(
- 'blockedDomains',
- e.target.value
- .split(',')
- .map((s) => s.trim())
- .filter(Boolean),
- )
- }
- disabled={saving}
- className={inputClasses}
- placeholder="example.com, spam.net"
- />
-
-
-
- {/* Protect Roles sub-section */}
-
- Protect Roles from Moderation
-
- Enabled
- updateProtectRolesField('enabled', v)}
- disabled={saving}
- label="Protect Roles"
- />
-
-
- Include admins
- updateProtectRolesField('includeAdmins', v)}
- disabled={saving}
- label="Include admins"
- />
-
-
- Include moderators
- updateProtectRolesField('includeModerators', v)}
- disabled={saving}
- label="Include moderators"
- />
-
-
- Include server owner
- updateProtectRolesField('includeServerOwner', v)}
- disabled={saving}
- label="Include server owner"
- />
-
-
- Additional protected roles
- updateProtectRolesField('roleIds', selected)}
- placeholder="Select additional protected roles..."
- disabled={saving}
- />
-
-
-
-
- )}
+ {/* AI Auto-Moderation Section */}
+ {
+ updateDraftConfig((prev) => ({
+ ...prev,
+ aiAutoMod: { ...((prev.aiAutoMod as Record) || {}), [field]: value },
+ }));
+ }}
+ />
- {/* AI Auto-Moderation section */}
- {draftConfig.aiAutoMod && (
-
-
-
-
- AI Auto-Moderation
-
- Use Claude AI to analyze messages and take automatic moderation actions.
-
-
-
updateAiAutoModField('enabled', v)}
- disabled={saving}
- label="AI Auto-Moderation"
- />
-
-
-
-
- Flag Review Channel
- updateAiAutoModField('flagChannelId', selected[0] ?? null)}
- placeholder="Select channel for flagged messages"
- disabled={saving}
- maxSelections={1}
- filter="text"
- />
-
-
- Auto-delete flagged messages
- updateAiAutoModField('autoDelete', v)}
- disabled={saving}
- label="Auto-delete"
- />
-
-
- Thresholds (0–100)
-
- Confidence threshold (%) above which the action triggers.
-
- {(['toxicity', 'spam', 'harassment'] as const).map((cat) => (
-
- {cat}
- )?.[cat] ??
- 0.7) * 100,
- )}
- onChange={(e) => {
- const raw = Number(e.target.value);
- const v = Number.isNaN(raw) ? 0 : Math.min(1, Math.max(0, raw / 100));
- updateAiAutoModField('thresholds', {
- ...((draftConfig.aiAutoMod?.thresholds as Record) ?? {}),
- [cat]: v,
- });
- }}
- disabled={saving}
- className={`${inputClasses} w-24`}
- />
- %
-
- ))}
-
-
- Actions
- {(['toxicity', 'spam', 'harassment'] as const).map((cat) => (
-
- {cat}
- )?.[cat] ?? 'flag'
- }
- onChange={(e) => {
- updateAiAutoModField('actions', {
- ...((draftConfig.aiAutoMod?.actions as Record) ?? {}),
- [cat]: e.target.value,
- });
- }}
- disabled={saving}
- className={inputClasses}
- >
- No action
- Delete message
- Flag for review
- Warn user
- Timeout user
- Kick user
- Ban user
-
-
- ))}
-
-
-
- )}
+ {/* Triage Section */}
+
- {/* Triage section */}
- {draftConfig.triage && (
-
-
-
-
- Triage
-
- Configure message triage classifier, responder models, and channels.
-
-
-
-
-
-
-
- Classify Model
- updateTriageField('classifyModel', e.target.value)}
- disabled={saving}
- className={inputClasses}
- placeholder="e.g. claude-haiku-4-5"
- />
-
-
- Respond Model
- updateTriageField('respondModel', e.target.value)}
- disabled={saving}
- className={inputClasses}
- placeholder="e.g. claude-sonnet-4-6"
- />
-
-
-
- Classify Budget
- {
- const num = parseNumberInput(e.target.value, 0);
- if (num !== undefined) updateTriageField('classifyBudget', num);
- }}
- disabled={saving}
- className={inputClasses}
- />
-
-
- Respond Budget
- {
- const num = parseNumberInput(e.target.value, 0);
- if (num !== undefined) updateTriageField('respondBudget', num);
- }}
- disabled={saving}
- className={inputClasses}
- />
-
-
-
-
- Default Interval (ms)
- {
- const num = parseNumberInput(e.target.value, 1);
- if (num !== undefined) updateTriageField('defaultInterval', num);
- }}
- disabled={saving}
- className={inputClasses}
- />
-
-
- Timeout (ms)
- {
- const num = parseNumberInput(e.target.value, 1);
- if (num !== undefined) updateTriageField('timeout', num);
- }}
- disabled={saving}
- className={inputClasses}
- />
-
-
-
-
- Context Messages
- {
- const num = parseNumberInput(e.target.value, 1);
- if (num !== undefined) updateTriageField('contextMessages', num);
- }}
- disabled={saving}
- className={inputClasses}
- />
-
-
- Max Buffer Size
- {
- const num = parseNumberInput(e.target.value, 1);
- if (num !== undefined) updateTriageField('maxBufferSize', num);
- }}
- disabled={saving}
- className={inputClasses}
- />
-
-
-
- Streaming
- updateTriageField('streaming', v)}
- disabled={saving}
- label="Streaming"
- />
-
-
- Moderation Response
- updateTriageField('moderationResponse', v)}
- disabled={saving}
- label="Moderation Response"
- />
-
-
- Debug Footer
- updateTriageField('debugFooter', v)}
- disabled={saving}
- label="Debug Footer"
- />
-
-
- Status Reactions
- updateTriageField('statusReactions', v)}
- disabled={saving}
- label="Status Reactions"
- />
-
-
- Moderation Log Channel
-
- updateTriageField('moderationLogChannel', selected[0] ?? '')
- }
- placeholder="Select channel for moderation logs"
- disabled={saving}
- maxSelections={1}
- filter="text"
- />
-
-
-
- )}
+ {/* Starboard Section */}
+
- {/* Starboard section */}
-
-
-
-
- Starboard
- Pin popular messages to a starboard channel.
-
-
updateStarboardField('enabled', v)}
- disabled={saving}
- label="Starboard"
- />
-
-
-
-
- Channel
- updateStarboardField('channelId', selected[0] ?? '')}
- placeholder="Select starboard channel"
- disabled={saving}
- maxSelections={1}
- filter="text"
- />
-
-
-
- Allow Self-Star
- updateStarboardField('selfStarAllowed', v)}
- disabled={saving}
- label="Self-Star Allowed"
- />
-
-
- Ignored Channels
- updateStarboardField('ignoredChannels', selected)}
- placeholder="Select channels to ignore..."
- disabled={saving}
- filter="text"
- />
-
-
-
+ {/* Permissions Section */}
+
- {/* Permissions section */}
-
-
-
-
- Permissions
-
- Configure role-based access and bot owner overrides.
-
-
-
updatePermissionsField('enabled', v)}
- disabled={saving}
- label="Permissions"
- />
-
-
-
-
- Admin Role ID
- updatePermissionsField('adminRoleId', selected[0] ?? null)}
- placeholder="Select admin role"
- disabled={saving}
- maxSelections={1}
- />
-
-
- Moderator Role ID
-
- updatePermissionsField('moderatorRoleId', selected[0] ?? null)
- }
- placeholder="Select moderator role"
- disabled={saving}
- maxSelections={1}
- />
-
-
- Bot Owners
-
- updatePermissionsField(
- 'botOwners',
- e.target.value
- .split(',')
- .map((s) => s.trim())
- .filter(Boolean),
- )
- }
- disabled={saving}
- className={inputClasses}
- placeholder="Comma-separated user IDs"
- />
-
-
-
+ {/* Memory Section */}
+
- {/* Memory section */}
-
-
-
-
- Memory
- Configure AI context memory and auto-extraction.
-
-
updateMemoryField('enabled', v)}
- disabled={saving}
- label="Memory"
- />
-
-
-
-
- Max Context Memories
- {
- const num = parseNumberInput(e.target.value, 1);
- if (num !== undefined) updateMemoryField('maxContextMemories', num);
- }}
- disabled={saving}
- className={inputClasses}
- />
-
-
- Auto-Extract
- updateMemoryField('autoExtract', v)}
- disabled={saving}
- label="Auto-Extract"
- />
-
-
-
+ {/* Community Features Section */}
+ {
+ updateDraftConfig((prev) => ({
+ ...prev,
+ [key]: {
+ ...((prev[key as keyof GuildConfig] as Record) || {}),
+ enabled,
+ },
+ }));
+ }}
+ />
- {/* ═══ Community Feature Toggles ═══ */}
-
-
-
- Community Features
-
-
- Enable or disable community commands per guild.
-
- {(
- [
- { key: 'help', label: 'Help / FAQ', desc: '/help command for server knowledge base' },
- { key: 'announce', label: 'Announcements', desc: '/announce for scheduled messages' },
- {
- key: 'snippet',
- label: 'Code Snippets',
- desc: '/snippet for saving and sharing code',
- },
- { key: 'poll', label: 'Polls', desc: '/poll for community voting' },
- {
- key: 'showcase',
- label: 'Project Showcase',
- desc: '/showcase to submit, browse, and upvote projects',
- },
- {
- key: 'review',
- label: 'Code Reviews',
- desc: '/review peer code review requests with claim workflow',
- },
- { key: 'tldr', label: 'TL;DR Summaries', desc: '/tldr for AI channel summaries' },
- { key: 'afk', label: 'AFK System', desc: '/afk auto-respond when members are away' },
- {
- key: 'engagement',
- label: 'Engagement Tracking',
- desc: '/profile stats — messages, reactions, days active',
- },
- ] as const
- ).map(({ key, label, desc }) => (
-
-
-
{
- updateDraftConfig((prev) => ({
- ...prev,
- [key]: { ...prev[key], enabled: v },
- }));
- }}
- disabled={saving}
- label={label}
- />
-
- ))}
-
-
+ {/* Engagement / Activity Badges Section */}
+ {
+ updateDraftConfig((prev) => ({
+ ...prev,
+ engagement: { ...(prev.engagement || {}), activityBadges: badges },
+ }));
+ }}
+ />
- {/* ═══ Engagement / Activity Badges ═══ */}
-
-
- Activity Badges
-
- Configure the badge tiers shown on /profile. Each badge requires a minimum number of
- active days.
-
- {(draftConfig.engagement?.activityBadges ?? DEFAULT_ACTIVITY_BADGES).map(
- (badge: { days?: number; label?: string }, i: number) => (
-
- {
- const badges = [
- ...(draftConfig.engagement?.activityBadges ?? DEFAULT_ACTIVITY_BADGES),
- ];
- badges[i] = {
- ...badges[i],
- days: Math.max(0, parseInt(e.target.value, 10) || 0),
- };
- updateDraftConfig((prev) => ({
- ...prev,
- engagement: { ...prev.engagement, activityBadges: badges },
- }));
- }}
- disabled={saving}
- />
- days →
- {
- const badges = [
- ...(draftConfig.engagement?.activityBadges ?? DEFAULT_ACTIVITY_BADGES),
- ];
- badges[i] = { ...badges[i], label: e.target.value };
- updateDraftConfig((prev) => ({
- ...prev,
- engagement: { ...prev.engagement, activityBadges: badges },
- }));
- }}
- disabled={saving}
- />
- {
- const badges = [...(draftConfig.engagement?.activityBadges ?? [])].filter(
- (_, idx) => idx !== i,
- );
- updateDraftConfig((prev) => ({
- ...prev,
- engagement: { ...prev.engagement, activityBadges: badges },
- }));
- }}
- disabled={
- saving ||
- (draftConfig.engagement?.activityBadges ?? DEFAULT_ACTIVITY_BADGES).length <= 1
- }
- >
- ✕
-
-
- ),
- )}
- {
- const badges = [
- ...(draftConfig.engagement?.activityBadges ?? DEFAULT_ACTIVITY_BADGES),
- { days: 0, label: '🌟 New Badge' },
- ];
- updateDraftConfig((prev) => ({
- ...prev,
- engagement: { ...prev.engagement, activityBadges: badges },
- }));
- }}
- disabled={saving}
- >
- + Add Badge
-
-
-
+ {/* Reputation / XP Section */}
+
- {/* ═══ Reputation / XP Settings ═══ */}
-
-
-
- Reputation / XP
-
- updateDraftConfig((prev) => ({
- ...prev,
- reputation: { ...prev.reputation, enabled: v },
- }))
- }
- disabled={saving}
- label="Reputation"
- />
-
-
-
- XP per Message (min)
- {
- const num = parseNumberInput(e.target.value, 1, 100);
- if (num !== undefined) {
- const range = draftConfig.reputation?.xpPerMessage ?? [5, 15];
- const newMax = num > range[1] ? num : range[1];
- updateDraftConfig((prev) => ({
- ...prev,
- reputation: { ...prev.reputation, xpPerMessage: [num, newMax] },
- }));
- }
- }}
- disabled={saving}
- className={inputClasses}
- />
-
-
- XP per Message (max)
- {
- const num = parseNumberInput(e.target.value, 1, 100);
- if (num !== undefined) {
- const range = draftConfig.reputation?.xpPerMessage ?? [5, 15];
- const newMin = num < range[0] ? num : range[0];
- updateDraftConfig((prev) => ({
- ...prev,
- reputation: { ...prev.reputation, xpPerMessage: [newMin, num] },
- }));
- }
- }}
- disabled={saving}
- className={inputClasses}
- />
-
-
- XP Cooldown (seconds)
- {
- const num = parseNumberInput(e.target.value, 0);
- if (num !== undefined)
- updateDraftConfig((prev) => ({
- ...prev,
- reputation: { ...prev.reputation, xpCooldownSeconds: num },
- }));
- }}
- disabled={saving}
- className={inputClasses}
- />
-
-
- Announce Channel
-
- updateDraftConfig((prev) => ({
- ...prev,
- reputation: {
- ...prev.reputation,
- announceChannelId: selected[0] ?? null,
- },
- }))
- }
- placeholder="Select channel for level-up announcements"
- disabled={saving}
- maxSelections={1}
- filter="text"
- />
-
-
-
-
- Level Thresholds (comma-separated XP values)
-
- {
- const nums = e.target.value
- .split(',')
- .map((s) => Number(s.trim()))
- .filter((n) => Number.isFinite(n) && n > 0);
- if (nums.length > 0) {
- const sorted = [...nums].sort((a, b) => a - b);
- updateDraftConfig((prev) => ({
- ...prev,
- reputation: { ...prev.reputation, levelThresholds: sorted },
- }));
- }
- }}
- disabled={saving}
- className={inputClasses}
- placeholder="100, 300, 600, 1000, ..."
- />
-
- XP required for each level (L1, L2, L3, ...). Add more values for more levels.
-
-
-
-
+ {/* Daily Coding Challenges Section */}
+
- {/* ═══ Daily Coding Challenges ═══ */}
-
-
-
- Daily Coding Challenges
-
- updateDraftConfig(
- (prev) =>
- ({ ...prev, challenges: { ...prev.challenges, enabled: v } }) as GuildConfig,
- )
- }
- disabled={saving}
- label="Challenges"
- />
-
-
- Auto-post a daily coding challenge with hint and solve tracking.
-
-
-
- Challenge Channel
-
- updateDraftConfig(
- (prev) =>
- ({
- ...prev,
- challenges: {
- ...prev.challenges,
- channelId: selected[0] ?? null,
- },
- }) as GuildConfig,
- )
- }
- placeholder="Select channel for daily challenges"
- disabled={saving}
- maxSelections={1}
- filter="text"
- />
-
-
- Post Time (HH:MM)
-
- updateDraftConfig(
- (prev) =>
- ({
- ...prev,
- challenges: { ...prev.challenges, postTime: e.target.value },
- }) as GuildConfig,
- )
- }
- disabled={saving}
- className={inputClasses}
- placeholder="09:00"
- />
-
-
- Timezone
-
- updateDraftConfig(
- (prev) =>
- ({
- ...prev,
- challenges: { ...prev.challenges, timezone: e.target.value },
- }) as GuildConfig,
- )
- }
- disabled={saving}
- className={inputClasses}
- placeholder="America/New_York"
- />
-
- IANA timezone (e.g. America/Chicago, Europe/London)
-
-
-
-
-
+ {/* GitHub Feed Section */}
+ {
+ updateDraftConfig((prev) => ({
+ ...prev,
+ github: {
+ ...(prev.github || {}),
+ feed: { ...(prev.github?.feed || {}), [field]: value },
+ },
+ }));
+ }}
+ />
- {/* ═══ GitHub Feed Settings ═══ */}
-
-
-
- GitHub Activity Feed
-
- updateDraftConfig((prev) => ({
- ...prev,
- github: { ...prev.github, feed: { ...prev.github?.feed, enabled: v } },
- }))
- }
- disabled={saving}
- label="GitHub Feed"
- />
-
-
-
- Feed Channel
-
- updateDraftConfig((prev) => ({
- ...prev,
- github: {
- ...prev.github,
- feed: { ...prev.github?.feed, channelId: selected[0] ?? null },
- },
- }))
- }
- placeholder="Select channel for GitHub updates"
- disabled={saving}
- maxSelections={1}
- filter="text"
- />
-
-
- Poll Interval (minutes)
- {
- const num = parseNumberInput(e.target.value, 1);
- if (num !== undefined)
- updateDraftConfig((prev) => ({
- ...prev,
- github: {
- ...prev.github,
- feed: { ...prev.github?.feed, pollIntervalMinutes: num },
- },
- }));
- }}
- disabled={saving}
- className={inputClasses}
- />
-
-
-
-
+ {/* Tickets Section */}
+ {
+ updateDraftConfig((prev) => ({
+ ...prev,
+ tickets: { ...(prev.tickets || {}), enabled },
+ }));
+ }}
+ onFieldChange={(field, value) => {
+ updateDraftConfig((prev) => ({
+ ...prev,
+ tickets: { ...(prev.tickets || {}), [field]: value },
+ }));
+ }}
+ />
- {/* ═══ Tickets ═══ */}
-
-
-
- Tickets
-
- updateDraftConfig((prev) => ({
- ...prev,
- tickets: { ...prev.tickets, enabled: v },
- }))
- }
- disabled={saving}
- label="Tickets"
- />
-
-
-
-
- Ticket Mode
-
- updateDraftConfig((prev) => ({
- ...prev,
- tickets: { ...prev.tickets, mode: e.target.value as 'thread' | 'channel' },
- }))
- }
- disabled={saving}
- className={inputClasses}
- >
- Thread (private thread per ticket)
- Channel (dedicated text channel per ticket)
-
-
- Thread mode creates private threads. Channel mode creates locked text channels with
- permission overrides.
-
-
-
-
- Support Role
-
- updateDraftConfig((prev) => ({
- ...prev,
- tickets: { ...prev.tickets, supportRole: selected[0] ?? null },
- }))
- }
- placeholder="Select support staff role"
- disabled={saving}
- maxSelections={1}
- />
-
-
- Category Channel ID
-
- updateDraftConfig((prev) => ({
- ...prev,
- tickets: { ...prev.tickets, category: e.target.value.trim() || null },
- }))
- }
- disabled={saving}
- className={inputClasses}
- placeholder="Category for tickets"
- />
-
-
- Auto-Close Hours
- {
- const num = parseNumberInput(e.target.value, 1, 720);
- if (num !== undefined)
- updateDraftConfig((prev) => ({
- ...prev,
- tickets: { ...prev.tickets, autoCloseHours: num },
- }));
- }}
- disabled={saving}
- className={inputClasses}
- />
-
- Hours of inactivity before warning (then +24h to close)
-
-
-
- Max Open Per User
- {
- const num = parseNumberInput(e.target.value, 1, 20);
- if (num !== undefined)
- updateDraftConfig((prev) => ({
- ...prev,
- tickets: { ...prev.tickets, maxOpenPerUser: num },
- }));
- }}
- disabled={saving}
- className={inputClasses}
- />
-
-
- Transcript Channel
-
- updateDraftConfig((prev) => ({
- ...prev,
- tickets: { ...prev.tickets, transcriptChannel: selected[0] ?? null },
- }))
- }
- placeholder="Select channel for ticket transcripts"
- disabled={saving}
- maxSelections={1}
- filter="text"
- />
-
-
-
-
- {/* Inline diff view — shows pending changes below the form */}
+ {/* Inline diff view */}
{hasChanges && savedConfig && }
- {/* Diff modal — shown before saving to require explicit confirmation */}
+ {/* Diff modal */}
{savedConfig && (
);
}
-
-// ── Toggle Switch ───────────────────────────────────────────────
-
-interface ToggleSwitchProps {
- checked: boolean;
- onChange: (checked: boolean) => void;
- disabled?: boolean;
- label: string;
-}
-
-/**
- * Renders an accessible toggle switch control.
- *
- * The switch reflects the `checked` state, calls `onChange` with the new boolean value when toggled,
- * and exposes an ARIA label derived from `label`.
- *
- * @param checked - Current on/off state of the switch.
- * @param onChange - Callback invoked with the new checked state when the switch is toggled.
- * @param disabled - When true, disables user interaction and applies disabled styling.
- * @param label - Human-readable name used for the switch's ARIA label.
- * @returns The button element acting as the toggle switch.
- */
-function ToggleSwitch({ checked, onChange, disabled, label }: ToggleSwitchProps) {
- return (
- onChange(!checked)}
- className="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 aria-checked:bg-primary aria-[checked=false]:bg-muted"
- >
-
-
- );
-}
-
-// ── Helpers ────────────────────────────────────────────────────────
-
-/**
- * Determine whether two JSON-serializable values are deeply equal by recursively comparing primitives, arrays, and plain objects.
- *
- * @param a - First value to compare
- * @param b - Second value to compare
- * @returns `true` if `a` and `b` are structurally equal, `false` otherwise
- */
-function deepEqual(a: unknown, b: unknown): boolean {
- if (a === b) return true;
- if (a === null || b === null) return false;
- if (typeof a !== typeof b) return false;
-
- if (Array.isArray(a)) {
- if (!Array.isArray(b) || a.length !== b.length) return false;
- return a.every((item, i) => deepEqual(item, b[i]));
- }
-
- if (typeof a === 'object') {
- const aObj = a as Record;
- const bObj = b as Record;
- const aKeys = Object.keys(aObj);
- const bKeys = Object.keys(bObj);
- if (aKeys.length !== bKeys.length) return false;
- return aKeys.every((key) => Object.hasOwn(bObj, key) && deepEqual(aObj[key], bObj[key]));
- }
-
- return false;
-}
-
-/**
- * Compute a flat list of dot-path patches that describe differences between two guild configs.
- *
- * Skips the root-level `guildId`, recurses into plain objects to emit leaf-level changes,
- * and produces a patch for any differing non-object value or array.
- *
- * @param original - The original (server-authoritative) guild configuration to compare against
- * @param modified - The modified guild configuration containing desired updates
- * @returns An array of patches where each item has a dot-separated `path` to the changed field and `value` set to the new value
- */
-function computePatches(
- original: GuildConfig,
- modified: GuildConfig,
-): Array<{ path: string; value: unknown }> {
- const patches: Array<{ path: string; value: unknown }> = [];
-
- /**
- * Traverse two plain-object trees and record leaf-level differences as path/value patches.
- *
- * Walks the structures rooted at `origObj` and `modObj`, compares values recursively, and appends
- * a patch { path, value } to the outer-scope `patches` array for each leaf or differing non-object
- * value in `modObj`. The root-level field named "guildId" is ignored.
- *
- * @param origObj - The original (source) object to compare against
- * @param modObj - The modified (target) object to derive patches from
- * @param prefix - Current dot-separated path prefix for nested keys (use empty string for root)
- */
- function walk(origObj: Record, modObj: Record, prefix: string) {
- const allKeys = new Set([...Object.keys(origObj), ...Object.keys(modObj)]);
-
- for (const key of allKeys) {
- // Skip the guildId metadata field
- if (prefix === '' && key === 'guildId') continue;
-
- const fullPath = prefix ? `${prefix}.${key}` : key;
- const origVal = origObj[key];
- const modVal = modObj[key];
-
- if (deepEqual(origVal, modVal)) continue;
-
- // If both are plain objects, recurse to find the leaf changes
- if (
- typeof origVal === 'object' &&
- origVal !== null &&
- !Array.isArray(origVal) &&
- typeof modVal === 'object' &&
- modVal !== null &&
- !Array.isArray(modVal)
- ) {
- walk(origVal as Record, modVal as Record, fullPath);
- } else {
- const patchValue = !Object.hasOwn(modObj, key) || modVal === undefined ? null : modVal;
- patches.push({ path: fullPath, value: patchValue });
- }
- }
- }
-
- walk(
- original as unknown as Record,
- modified as unknown as Record,
- '',
- );
-
- return patches;
-}
diff --git a/web/src/components/dashboard/config-sections/AiAutoModSection.tsx b/web/src/components/dashboard/config-sections/AiAutoModSection.tsx
index 7b9a17ef1..4566d4d42 100644
--- a/web/src/components/dashboard/config-sections/AiAutoModSection.tsx
+++ b/web/src/components/dashboard/config-sections/AiAutoModSection.tsx
@@ -1,34 +1,9 @@
'use client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
-import { ChannelSelector } from '@/components/ui/channel-selector';
-import { Label } from '@/components/ui/label';
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select';
-import { Slider } from '@/components/ui/slider';
-import { Switch } from '@/components/ui/switch';
-import { useGuildSelection } from '@/hooks/use-guild-selection';
+import { decimalToPercent, percentToDecimal } from '@/lib/config-normalization';
import type { GuildConfig } from '@/lib/config-utils';
-
-const ACTION_OPTIONS = [
- { value: 'none', label: 'No action' },
- { value: 'delete', label: 'Delete message' },
- { value: 'flag', label: 'Flag for review' },
- { value: 'warn', label: 'Warn user' },
- { value: 'timeout', label: 'Timeout user' },
- { value: 'kick', label: 'Kick user' },
- { value: 'ban', label: 'Ban user' },
-] as const;
-
-const MODEL_OPTIONS = [
- { value: 'claude-haiku-4-5', label: 'Claude Haiku (fast, low cost)' },
- { value: 'claude-sonnet-4-5', label: 'Claude Sonnet (balanced)' },
-] as const;
+import { ToggleSwitch } from '../toggle-switch';
interface AiAutoModSectionProps {
draftConfig: GuildConfig;
@@ -36,62 +11,28 @@ interface AiAutoModSectionProps {
onFieldChange: (field: string, value: unknown) => void;
}
+/** Shared input styling for text inputs and selects. */
+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';
+
/**
- * Render the AI Auto-Moderation settings section.
- * Controls enabling/disabling AI analysis, per-category thresholds and actions,
- * flag channel selection, and model selection.
+ * AI Auto-Moderation configuration section.
+ *
+ * Provides controls for AI-powered moderation including thresholds,
+ * actions per category (toxicity, spam, harassment), and auto-delete settings.
*/
export function AiAutoModSection({ draftConfig, saving, onFieldChange }: AiAutoModSectionProps) {
- const guildId = useGuildSelection();
- const cfg = draftConfig.aiAutoMod as Record | undefined;
-
- if (!cfg) return null;
-
- const enabled = Boolean(cfg.enabled);
- const thresholds = (cfg.thresholds as Record) ?? {
- toxicity: 0.7,
- spam: 0.8,
- harassment: 0.7,
- };
- const actions = (cfg.actions as Record) ?? {
- toxicity: 'flag',
- spam: 'delete',
- harassment: 'warn',
+ // Provide defaults so section renders even when aiAutoMod is missing
+ const cfg = draftConfig.aiAutoMod ?? {
+ enabled: false,
+ thresholds: { toxicity: 0.7, spam: 0.7, harassment: 0.7 },
+ actions: { toxicity: 'flag', spam: 'flag', harassment: 'flag' },
+ flagChannelId: null,
+ autoDelete: true,
};
- const flagChannelId = (cfg.flagChannelId as string) ?? '';
- const selectedFlagChannels = flagChannelId ? [flagChannelId] : [];
- const model = (cfg.model as string) ?? 'claude-haiku-4-5';
- const autoDelete = Boolean(cfg.autoDelete ?? true);
- const handleThresholdChange = (category: string, value: number[]) => {
- onFieldChange('thresholds', { ...thresholds, [category]: value[0] });
- };
-
- const handleActionChange = (category: string, value: string) => {
- onFieldChange('actions', { ...actions, [category]: value });
- };
-
- const handleFlagChannelChange = (channels: string[]) => {
- onFieldChange('flagChannelId', channels[0] ?? null);
- };
-
- const categories: Array<{ key: string; label: string; description: string }> = [
- {
- key: 'toxicity',
- label: 'Toxicity',
- description: 'Hateful language, slurs, extreme negativity',
- },
- {
- key: 'spam',
- label: 'Spam',
- description: 'Repetitive content, scam links, advertisements',
- },
- {
- key: 'harassment',
- label: 'Harassment',
- description: 'Targeted attacks, threats, bullying',
- },
- ];
+ const thresholds = (cfg.thresholds as Record) ?? {};
+ const actions = (cfg.actions as Record) ?? {};
return (
@@ -103,122 +44,94 @@ export function AiAutoModSection({ draftConfig, saving, onFieldChange }: AiAutoM
Use Claude AI to analyze messages and take automatic moderation actions.
-
onFieldChange('enabled', v)}
+ onFieldChange('enabled', v)}
disabled={saving}
- aria-label="Toggle AI Auto-Moderation"
+ label="AI Auto-Moderation"
/>
-
- {/* Model */}
-
- AI Model
- onFieldChange('model', v)}
- disabled={saving || !enabled}
- >
-
-
-
-
- {MODEL_OPTIONS.map((opt) => (
-
- {opt.label}
-
- ))}
-
-
-
-
- {/* Flag channel */}
-
-
Flag Review Channel
-
- Flagged messages are posted here for manual review.
-
- {guildId ? (
-
- ) : (
-
Select a server first
- )}
-
-
- {/* Auto-delete */}
+
+
+ Flag Review Channel ID
+ onFieldChange('flagChannelId', e.target.value || null)}
+ disabled={saving}
+ className={inputClasses}
+ placeholder="Channel ID where flagged messages are posted"
+ />
+
-
-
Auto-delete flagged messages
-
- Delete the offending message before taking action.
-
-
-
onFieldChange('autoDelete', v)}
- disabled={saving || !enabled}
- aria-label="Toggle auto-delete"
+ Auto-delete flagged messages
+ onFieldChange('autoDelete', v)}
+ disabled={saving}
+ label="Auto-delete"
/>
-
- {/* Per-category thresholds and actions */}
-
-
Category Thresholds & Actions
+
+ Thresholds (0–100)
- Set the confidence threshold (0–100%) and action for each category.
+ Confidence threshold (%) above which the action triggers.
- {categories.map(({ key, label, description }) => (
-
-
-
{label}
-
{description}
-
-
-
-
- Threshold
- {Math.round((thresholds[key] ?? 0.7) * 100)}%
-
-
handleThresholdChange(key, v)}
- disabled={saving || !enabled}
- />
-
-
- handleActionChange(key, v)}
- disabled={saving || !enabled}
- >
-
-
-
-
- {ACTION_OPTIONS.map((opt) => (
-
- {opt.label}
-
- ))}
-
-
-
-
-
+ {(['toxicity', 'spam', 'harassment'] as const).map((cat) => (
+
+ {cat}
+ {
+ const raw = Number(e.target.value);
+ const v = percentToDecimal(raw);
+ onFieldChange('thresholds', {
+ ...thresholds,
+ [cat]: v,
+ });
+ }}
+ disabled={saving}
+ className={`${inputClasses} w-24`}
+ />
+ %
+
))}
-
+
+
+ Actions
+ {(['toxicity', 'spam', 'harassment'] as const).map((cat) => (
+
+ {cat}
+ {
+ onFieldChange('actions', {
+ ...actions,
+ [cat]: e.target.value,
+ });
+ }}
+ disabled={saving}
+ className={inputClasses}
+ >
+ No action
+ Delete message
+ Flag for review
+ Warn user
+ Timeout user
+ Kick user
+ Ban user
+
+
+ ))}
+
);
diff --git a/web/src/components/dashboard/config-sections/AiSection.tsx b/web/src/components/dashboard/config-sections/AiSection.tsx
index c0b271572..1e177f28d 100644
--- a/web/src/components/dashboard/config-sections/AiSection.tsx
+++ b/web/src/components/dashboard/config-sections/AiSection.tsx
@@ -1,27 +1,38 @@
'use client';
import { SystemPromptEditor } from '@/components/dashboard/system-prompt-editor';
-import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
-import { Label } from '@/components/ui/label';
-import { Switch } from '@/components/ui/switch';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { ChannelSelector } from '@/components/ui/channel-selector';
import type { GuildConfig } from '@/lib/config-utils';
import { SYSTEM_PROMPT_MAX_LENGTH } from '@/types/config';
+import { ToggleSwitch } from '../toggle-switch';
interface AiSectionProps {
draftConfig: GuildConfig;
+ guildId: string;
saving: boolean;
onEnabledChange: (enabled: boolean) => void;
onSystemPromptChange: (value: string) => void;
+ onBlockedChannelsChange: (channels: string[]) => void;
}
+/**
+ * AI Chat configuration section.
+ *
+ * Provides controls for enabling/disabling AI chat, editing the system prompt,
+ * and selecting blocked channels where the AI will not respond.
+ */
export function AiSection({
draftConfig,
+ guildId,
saving,
onEnabledChange,
onSystemPromptChange,
+ onBlockedChannelsChange,
}: AiSectionProps) {
return (
<>
+ {/* AI section */}
@@ -29,28 +40,48 @@ export function AiSection({
AI Chat
Configure the AI assistant behavior.
-
-
-
- AI Chat
-
-
+
+ {/* System Prompt */}
+
+ {/* AI Blocked Channels */}
+
+
+ Blocked Channels
+
+ The AI will not respond in these channels (or their threads).
+
+
+
+ {guildId ? (
+
+ ) : (
+ Select a server first
+ )}
+
+
>
);
}
diff --git a/web/src/components/dashboard/config-sections/ChallengesSection.tsx b/web/src/components/dashboard/config-sections/ChallengesSection.tsx
new file mode 100644
index 000000000..bf6812900
--- /dev/null
+++ b/web/src/components/dashboard/config-sections/ChallengesSection.tsx
@@ -0,0 +1,87 @@
+'use client';
+
+import { Card, CardContent, CardTitle } from '@/components/ui/card';
+import type { GuildConfig } from '@/lib/config-utils';
+import { ToggleSwitch } from '../toggle-switch';
+
+interface ChallengesSectionProps {
+ draftConfig: GuildConfig;
+ saving: boolean;
+ onEnabledChange: (enabled: boolean) => void;
+ onFieldChange: (field: string, value: unknown) => 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';
+
+/**
+ * Daily Coding Challenges configuration section.
+ *
+ * Provides controls for auto-posting daily coding challenges with hint and solve tracking.
+ */
+export function ChallengesSection({
+ draftConfig,
+ saving,
+ onEnabledChange,
+ onFieldChange,
+}: ChallengesSectionProps) {
+ return (
+
+
+
+ Daily Coding Challenges
+
+
+
+ Auto-post a daily coding challenge with hint and solve tracking.
+
+
+
+
+ );
+}
diff --git a/web/src/components/dashboard/config-sections/CommunityFeaturesSection.tsx b/web/src/components/dashboard/config-sections/CommunityFeaturesSection.tsx
new file mode 100644
index 000000000..e66fad80f
--- /dev/null
+++ b/web/src/components/dashboard/config-sections/CommunityFeaturesSection.tsx
@@ -0,0 +1,76 @@
+'use client';
+
+import { Card, CardContent, CardTitle } from '@/components/ui/card';
+import type { GuildConfig } from '@/lib/config-utils';
+import { ToggleSwitch } from '../toggle-switch';
+
+interface CommunityFeaturesSectionProps {
+ draftConfig: GuildConfig;
+ saving: boolean;
+ onToggleChange: (key: string, enabled: boolean) => void;
+}
+
+const COMMUNITY_FEATURES = [
+ { key: 'help', label: 'Help / FAQ', desc: '/help command for server knowledge base' },
+ { key: 'announce', label: 'Announcements', desc: '/announce for scheduled messages' },
+ { key: 'snippet', label: 'Code Snippets', desc: '/snippet for saving and sharing code' },
+ { key: 'poll', label: 'Polls', desc: '/poll for community voting' },
+ {
+ key: 'showcase',
+ label: 'Project Showcase',
+ desc: '/showcase to submit, browse, and upvote projects',
+ },
+ {
+ key: 'review',
+ label: 'Code Reviews',
+ desc: '/review peer code review requests with claim workflow',
+ },
+ { key: 'tldr', label: 'TL;DR Summaries', desc: '/tldr for AI channel summaries' },
+ { key: 'afk', label: 'AFK System', desc: '/afk auto-respond when members are away' },
+ {
+ key: 'engagement',
+ label: 'Engagement Tracking',
+ desc: '/profile stats — messages, reactions, days active',
+ },
+] as const;
+
+/**
+ * Community Features configuration section.
+ *
+ * Provides toggles for enabling/disabling various community commands per guild.
+ */
+export function CommunityFeaturesSection({
+ draftConfig,
+ saving,
+ onToggleChange,
+}: CommunityFeaturesSectionProps) {
+ return (
+
+
+
+ Community Features
+
+
+ Enable or disable community commands per guild.
+
+ {COMMUNITY_FEATURES.map(({ key, label, desc }) => (
+
+
+
onToggleChange(key, v)}
+ disabled={saving}
+ label={label}
+ />
+
+ ))}
+
+
+ );
+}
diff --git a/web/src/components/dashboard/config-sections/EngagementSection.tsx b/web/src/components/dashboard/config-sections/EngagementSection.tsx
new file mode 100644
index 000000000..a3f2fe71a
--- /dev/null
+++ b/web/src/components/dashboard/config-sections/EngagementSection.tsx
@@ -0,0 +1,96 @@
+'use client';
+
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import type { GuildConfig } from '@/lib/config-utils';
+
+interface EngagementSectionProps {
+ draftConfig: GuildConfig;
+ saving: boolean;
+ onActivityBadgesChange: (badges: Array<{ days?: number; label?: string }>) => void;
+}
+
+const DEFAULT_ACTIVITY_BADGES = [
+ { days: 90, label: '👑 Legend' },
+ { days: 30, label: '🌳 Veteran' },
+ { days: 7, label: '🌿 Regular' },
+ { days: 0, label: '🌱 Newcomer' },
+] as const;
+
+/**
+ * Engagement / Activity Badges configuration section.
+ *
+ * Provides controls for configuring badge tiers shown on /profile.
+ */
+export function EngagementSection({
+ draftConfig,
+ saving,
+ onActivityBadgesChange,
+}: EngagementSectionProps) {
+ const badges = draftConfig.engagement?.activityBadges ?? [...DEFAULT_ACTIVITY_BADGES];
+
+ return (
+
+
+ Activity Badges
+
+ Configure the badge tiers shown on /profile. Each badge requires a minimum number of
+ active days.
+
+ {badges.map((badge, i) => (
+
+ {
+ const newBadges = [...badges];
+ newBadges[i] = {
+ ...newBadges[i],
+ days: Math.max(0, parseInt(e.target.value, 10) || 0),
+ };
+ onActivityBadgesChange(newBadges);
+ }}
+ disabled={saving}
+ />
+ days →
+ {
+ const newBadges = [...badges];
+ newBadges[i] = { ...newBadges[i], label: e.target.value };
+ onActivityBadgesChange(newBadges);
+ }}
+ disabled={saving}
+ />
+ {
+ const newBadges = badges.filter((_, idx) => idx !== i);
+ onActivityBadgesChange(newBadges);
+ }}
+ disabled={saving || badges.length <= 1}
+ >
+ ✕
+
+
+ ))}
+ {
+ const newBadges = [...badges, { days: 0, label: '🌟 New Badge' }];
+ onActivityBadgesChange(newBadges);
+ }}
+ disabled={saving}
+ >
+ + Add Badge
+
+
+
+ );
+}
diff --git a/web/src/components/dashboard/config-sections/GitHubSection.tsx b/web/src/components/dashboard/config-sections/GitHubSection.tsx
new file mode 100644
index 000000000..bc1e64318
--- /dev/null
+++ b/web/src/components/dashboard/config-sections/GitHubSection.tsx
@@ -0,0 +1,70 @@
+'use client';
+
+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 GitHubSectionProps {
+ draftConfig: GuildConfig;
+ saving: boolean;
+ onFieldChange: (field: string, value: unknown) => 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';
+
+/**
+ * GitHub Activity Feed configuration section.
+ *
+ * Provides controls for GitHub feed channel and polling interval.
+ */
+export function GitHubSection({ draftConfig, saving, onFieldChange }: GitHubSectionProps) {
+ const feed = draftConfig.github?.feed ?? {};
+
+ return (
+
+
+
+ GitHub Activity Feed
+ onFieldChange('enabled', v)}
+ disabled={saving}
+ label="GitHub Feed"
+ />
+
+
+
+ Feed Channel ID
+ onFieldChange('channelId', e.target.value.trim() || null)}
+ disabled={saving}
+ className={inputClasses}
+ placeholder="Channel ID for GitHub updates"
+ />
+
+
+ Poll Interval (minutes)
+ {
+ const num = parseNumberInput(e.target.value, 1);
+ if (num !== undefined) onFieldChange('pollIntervalMinutes', num);
+ }}
+ disabled={saving}
+ className={inputClasses}
+ />
+
+
+
+
+ );
+}
diff --git a/web/src/components/dashboard/config-sections/MemorySection.tsx b/web/src/components/dashboard/config-sections/MemorySection.tsx
new file mode 100644
index 000000000..6a359778f
--- /dev/null
+++ b/web/src/components/dashboard/config-sections/MemorySection.tsx
@@ -0,0 +1,74 @@
+'use client';
+
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { parseNumberInput } from '@/lib/config-normalization';
+import type { GuildConfig } from '@/lib/config-utils';
+import { ToggleSwitch } from '../toggle-switch';
+
+interface MemorySectionProps {
+ draftConfig: GuildConfig;
+ saving: boolean;
+ onEnabledChange: (enabled: boolean) => void;
+ onFieldChange: (field: string, value: unknown) => 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';
+
+/**
+ * Memory configuration section.
+ *
+ * Provides controls for AI context memory and auto-extraction settings.
+ */
+export function MemorySection({
+ draftConfig,
+ saving,
+ onEnabledChange,
+ onFieldChange,
+}: MemorySectionProps) {
+ return (
+
+
+
+
+ Memory
+ Configure AI context memory and auto-extraction.
+
+
+
+
+
+
+ Max Context Memories
+ {
+ const num = parseNumberInput(e.target.value, 1);
+ if (num !== undefined) onFieldChange('maxContextMemories', num);
+ }}
+ disabled={saving}
+ className={inputClasses}
+ />
+
+
+ Auto-Extract
+ onFieldChange('autoExtract', v)}
+ disabled={saving}
+ label="Auto-Extract"
+ />
+
+
+
+ );
+}
diff --git a/web/src/components/dashboard/config-sections/ModerationSection.tsx b/web/src/components/dashboard/config-sections/ModerationSection.tsx
index a3cfd9947..78e898148 100644
--- a/web/src/components/dashboard/config-sections/ModerationSection.tsx
+++ b/web/src/components/dashboard/config-sections/ModerationSection.tsx
@@ -1,45 +1,63 @@
'use client';
+import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ChannelSelector } from '@/components/ui/channel-selector';
-import { Label } from '@/components/ui/label';
-import { RoleSelector } from '@/components/ui/role-selector';
-import { Switch } from '@/components/ui/switch';
-import { useGuildSelection } from '@/hooks/use-guild-selection';
+import { Input } from '@/components/ui/input';
+import { parseNumberInput } from '@/lib/config-normalization';
import type { GuildConfig } from '@/lib/config-utils';
+import { ToggleSwitch } from '../toggle-switch';
interface ModerationSectionProps {
draftConfig: GuildConfig;
+ guildId: string;
saving: boolean;
+ protectRoleIdsRaw: string;
onEnabledChange: (enabled: boolean) => void;
onFieldChange: (field: string, value: unknown) => void;
onDmNotificationChange: (action: string, value: boolean) => void;
onEscalationChange: (enabled: boolean) => void;
+ onRateLimitChange: (field: string, value: unknown) => void;
+ onLinkFilterChange: (field: string, value: unknown) => void;
onProtectRolesChange: (field: string, value: unknown) => void;
+ onProtectRoleIdsRawChange: (value: string) => 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';
+
/**
- * Render the Moderation settings section, including alert channel selection, auto-delete,
- * DM notification toggles, and escalation controls.
+ * Moderation configuration section.
*
- * @param draftConfig - The current draft guild configuration containing moderation settings.
- * @param saving - Whether a save operation is in progress; when true, interactive controls are disabled.
- * @param onEnabledChange - Callback invoked with the new enabled state when moderation is toggled.
- * @param onFieldChange - Generic field update callback, called with field name and new value (e.g., 'alertChannelId', 'autoDelete').
- * @param onDmNotificationChange - Callback invoked with an action ('warn' | 'timeout' | 'kick' | 'ban') and boolean to toggle DM notifications for that action.
- * @param onEscalationChange - Callback invoked with the new escalation enabled state.
- * @returns The rendered moderation Card element, or `null` if `draftConfig.moderation` is not present.
+ * Provides controls for moderation settings including alert channels,
+ * auto-delete, DM notifications, escalation, rate limiting, link filtering,
+ * and protected roles.
*/
export function ModerationSection({
draftConfig,
+ guildId,
saving,
+ protectRoleIdsRaw,
onEnabledChange,
onFieldChange,
onDmNotificationChange,
onEscalationChange,
+ onRateLimitChange,
+ onLinkFilterChange,
onProtectRolesChange,
+ onProtectRoleIdsRawChange,
}: ModerationSectionProps) {
- const guildId = useGuildSelection();
+ // Local state for blocked domains raw input (parsed on blur)
+ // Must be before early return to satisfy React hooks rules
+ const blockedDomainsDisplay = (draftConfig.moderation?.linkFilter?.blockedDomains ?? []).join(
+ ', ',
+ );
+ const [blockedDomainsRaw, setBlockedDomainsRaw] = useState(blockedDomainsDisplay);
+ useEffect(() => {
+ setBlockedDomainsRaw(blockedDomainsDisplay);
+ }, [blockedDomainsDisplay]);
+
if (!draftConfig.moderation) return null;
const alertChannelId = draftConfig.moderation?.alertChannelId ?? '';
@@ -59,17 +77,17 @@ export function ModerationSection({
Configure moderation, escalation, and logging settings.
-
-
Alert Channel
+
Alert Channel
{guildId ? (
-
- Auto-delete flagged messages
-
- Auto-delete flagged messages
+ onFieldChange('autoDelete', v)}
+ onChange={(v) => onFieldChange('autoDelete', v)}
disabled={saving}
- aria-label="Toggle auto-delete"
+ label="Auto Delete"
/>
DM Notifications
{(['warn', 'timeout', 'kick', 'ban'] as const).map((action) => (
-
- {action}
-
- {action}
+ onDmNotificationChange(action, v)}
+ onChange={(v) => onDmNotificationChange(action, v)}
disabled={saving}
- aria-label={`DM on ${action}`}
+ label={`DM on ${action}`}
/>
))}
-
- Escalation Enabled
-
- Escalation Enabled
+ onEscalationChange(v)}
+ onChange={(v) => onEscalationChange(v)}
disabled={saving}
- aria-label="Toggle escalation"
+ label="Escalation"
/>
+ {/* Rate Limiting sub-section */}
+
+ Rate Limiting
+
+ Enabled
+ onRateLimitChange('enabled', v)}
+ disabled={saving}
+ label="Rate Limiting"
+ />
+
+
+
+ Max Messages
+ {
+ const num = parseNumberInput(e.target.value, 1);
+ if (num !== undefined) onRateLimitChange('maxMessages', num);
+ }}
+ disabled={saving}
+ className={inputClasses}
+ />
+
+
+ Window (seconds)
+ {
+ const num = parseNumberInput(e.target.value, 1);
+ if (num !== undefined) onRateLimitChange('windowSeconds', num);
+ }}
+ disabled={saving}
+ className={inputClasses}
+ />
+
+
+
+
+ Mute After Triggers
+ {
+ const num = parseNumberInput(e.target.value, 1);
+ if (num !== undefined) onRateLimitChange('muteAfterTriggers', num);
+ }}
+ disabled={saving}
+ className={inputClasses}
+ />
+
+
+ Mute Window (s)
+ {
+ const num = parseNumberInput(e.target.value, 1);
+ if (num !== undefined) onRateLimitChange('muteWindowSeconds', num);
+ }}
+ disabled={saving}
+ className={inputClasses}
+ />
+
+
+ Mute Duration (s)
+ {
+ const num = parseNumberInput(e.target.value, 1);
+ if (num !== undefined) onRateLimitChange('muteDurationSeconds', num);
+ }}
+ disabled={saving}
+ className={inputClasses}
+ />
+
+
+
+
+ {/* Link Filtering sub-section */}
+
+ Link Filtering
+
+ Enabled
+ onLinkFilterChange('enabled', v)}
+ disabled={saving}
+ label="Link Filtering"
+ />
+
+
+ Blocked Domains
+ setBlockedDomainsRaw(e.target.value)}
+ onBlur={() =>
+ onLinkFilterChange(
+ 'blockedDomains',
+ blockedDomainsRaw
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean),
+ )
+ }
+ disabled={saving}
+ className={inputClasses}
+ placeholder="example.com, spam.net"
+ />
+
+
+
{/* Protect Roles sub-section */}
Protect Roles from Moderation
-
- Enabled
-
- Enabled
+ onProtectRolesChange('enabled', v)}
+ onChange={(v) => onProtectRolesChange('enabled', v)}
disabled={saving}
- aria-label="Toggle protect roles"
+ label="Protect Roles"
/>
-
- Include admins
-
- Include admins
+ onProtectRolesChange('includeAdmins', v)}
+ onChange={(v) => onProtectRolesChange('includeAdmins', v)}
disabled={saving}
- aria-label="Include admins"
+ label="Include admins"
/>
-
- Include moderators
-
- Include moderators
+ onProtectRolesChange('includeModerators', v)}
+ onChange={(v) => onProtectRolesChange('includeModerators', v)}
disabled={saving}
- aria-label="Include moderators"
+ label="Include moderators"
/>
-
- Include server owner
-
- Include server owner
+ onProtectRolesChange('includeServerOwner', v)}
+ onChange={(v) => onProtectRolesChange('includeServerOwner', v)}
disabled={saving}
- aria-label="Include server owner"
+ label="Include server owner"
/>
-
- Additional protected roles
-
- {guildId ? (
-
onProtectRolesChange('roleIds', selected)}
- disabled={saving}
- placeholder="Select additional protected roles..."
- />
- ) : (
- Select a server first
- )}
+
+ Additional protected role IDs (comma-separated)
+
+ {
+ const raw = e.target.value;
+ onProtectRoleIdsRawChange(raw);
+ onProtectRolesChange(
+ 'roleIds',
+ raw
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean),
+ );
+ }}
+ onBlur={(e) =>
+ onProtectRoleIdsRawChange(
+ e.target.value
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean)
+ .join(', '),
+ )
+ }
+ disabled={saving}
+ placeholder="Role ID 1, Role ID 2"
+ />
diff --git a/web/src/components/dashboard/config-sections/PermissionsSection.tsx b/web/src/components/dashboard/config-sections/PermissionsSection.tsx
new file mode 100644
index 000000000..617b876a8
--- /dev/null
+++ b/web/src/components/dashboard/config-sections/PermissionsSection.tsx
@@ -0,0 +1,109 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { RoleSelector } from '@/components/ui/role-selector';
+import type { GuildConfig } from '@/lib/config-utils';
+import { ToggleSwitch } from '../toggle-switch';
+
+interface PermissionsSectionProps {
+ draftConfig: GuildConfig;
+ guildId: string;
+ saving: boolean;
+ onFieldChange: (field: string, value: unknown) => 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';
+
+/**
+ * Permissions configuration section.
+ *
+ * Provides controls for role-based access and bot owner overrides.
+ */
+export function PermissionsSection({
+ draftConfig,
+ guildId,
+ saving,
+ onFieldChange,
+}: PermissionsSectionProps) {
+ // Local state for bot owners raw input (parsed on blur)
+ const botOwnersDisplay = (draftConfig.permissions?.botOwners ?? []).join(', ');
+ const [botOwnersRaw, setBotOwnersRaw] = useState(botOwnersDisplay);
+ useEffect(() => {
+ setBotOwnersRaw(botOwnersDisplay);
+ }, [botOwnersDisplay]);
+
+ return (
+
+
+
+
+ Permissions
+ Configure role-based access and bot owner overrides.
+
+
onFieldChange('enabled', v)}
+ disabled={saving}
+ label="Permissions"
+ />
+
+
+
+
+ Admin Role
+ onFieldChange('adminRoleId', selected[0] ?? null)}
+ placeholder="Select admin role"
+ disabled={saving}
+ maxSelections={1}
+ />
+
+
+ Moderator Role
+ onFieldChange('moderatorRoleId', selected[0] ?? null)}
+ placeholder="Select moderator role"
+ disabled={saving}
+ maxSelections={1}
+ />
+
+
+ Bot Owners
+ setBotOwnersRaw(e.target.value)}
+ onBlur={() =>
+ onFieldChange(
+ 'botOwners',
+ botOwnersRaw
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean),
+ )
+ }
+ disabled={saving}
+ className={inputClasses}
+ placeholder="Comma-separated user IDs"
+ />
+
+
+
+ );
+}
diff --git a/web/src/components/dashboard/config-sections/ReputationSection.tsx b/web/src/components/dashboard/config-sections/ReputationSection.tsx
new file mode 100644
index 000000000..ca7bc44af
--- /dev/null
+++ b/web/src/components/dashboard/config-sections/ReputationSection.tsx
@@ -0,0 +1,150 @@
+'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;
+ onFieldChange: (field: string, value: unknown) => 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, level thresholds, and announcements.
+ */
+export function ReputationSection({
+ draftConfig,
+ saving,
+ onEnabledChange,
+ onFieldChange,
+}: ReputationSectionProps) {
+ const xpRange = draftConfig.reputation?.xpPerMessage ?? [5, 15];
+ const levelThresholds = draftConfig.reputation?.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
+
+
+
+
+ XP per Message (min)
+ {
+ const num = parseNumberInput(e.target.value, 1, 100);
+ if (num !== undefined) {
+ const newMax = num > (xpRange[1] ?? 15) ? num : (xpRange[1] ?? 15);
+ onFieldChange('xpPerMessage', [num, newMax]);
+ }
+ }}
+ disabled={saving}
+ className={inputClasses}
+ />
+
+
+ XP per Message (max)
+ {
+ const num = parseNumberInput(e.target.value, 1, 100);
+ if (num !== undefined) {
+ const newMin = num < (xpRange[0] ?? 5) ? num : (xpRange[0] ?? 5);
+ onFieldChange('xpPerMessage', [newMin, num]);
+ }
+ }}
+ disabled={saving}
+ className={inputClasses}
+ />
+
+
+ XP Cooldown (seconds)
+ {
+ const num = parseNumberInput(e.target.value, 0);
+ if (num !== undefined) onFieldChange('xpCooldownSeconds', num);
+ }}
+ disabled={saving}
+ className={inputClasses}
+ />
+
+
+ Announce Channel ID
+ onFieldChange('announceChannelId', e.target.value.trim() || null)}
+ disabled={saving}
+ className={inputClasses}
+ placeholder="Channel ID for level-up announcements"
+ />
+
+
+
+ Level Thresholds (comma-separated XP values)
+ setThresholdsRaw(e.target.value)}
+ onBlur={() => {
+ const nums = thresholdsRaw
+ .split(',')
+ .map((s) => Number(s.trim()))
+ .filter((n) => Number.isFinite(n) && n > 0);
+ if (nums.length > 0) {
+ const sorted = [...nums].sort((a, b) => a - b);
+ onFieldChange('levelThresholds', sorted);
+ }
+ }}
+ disabled={saving}
+ className={inputClasses}
+ placeholder="100, 300, 600, 1000, ..."
+ />
+
+ XP required for each level (L1, L2, L3, ...). Add more values for more levels.
+
+
+
+
+ );
+}
diff --git a/web/src/components/dashboard/config-sections/StarboardSection.tsx b/web/src/components/dashboard/config-sections/StarboardSection.tsx
new file mode 100644
index 000000000..a20298bb8
--- /dev/null
+++ b/web/src/components/dashboard/config-sections/StarboardSection.tsx
@@ -0,0 +1,133 @@
+'use client';
+
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { parseNumberInput } from '@/lib/config-normalization';
+import type { GuildConfig } from '@/lib/config-utils';
+import { ToggleSwitch } from '../toggle-switch';
+
+interface StarboardSectionProps {
+ draftConfig: GuildConfig;
+ saving: boolean;
+ onFieldChange: (field: string, value: unknown) => 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';
+
+/**
+ * Starboard configuration section.
+ *
+ * Provides controls for pinning popular messages to a starboard channel,
+ * including threshold, emoji settings, and ignored channels.
+ */
+export function StarboardSection({ draftConfig, saving, onFieldChange }: StarboardSectionProps) {
+ return (
+
+
+
+
+ Starboard
+ Pin popular messages to a starboard channel.
+
+
onFieldChange('enabled', v)}
+ disabled={saving}
+ label="Starboard"
+ />
+
+
+
+
+ Channel ID
+ onFieldChange('channelId', e.target.value)}
+ disabled={saving}
+ className={inputClasses}
+ placeholder="Starboard channel ID"
+ />
+
+
+
+ Allow Self-Star
+ onFieldChange('selfStarAllowed', v)}
+ disabled={saving}
+ label="Self-Star Allowed"
+ />
+
+
+ Ignored Channels
+
+ onFieldChange(
+ 'ignoredChannels',
+ e.target.value
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean),
+ )
+ }
+ disabled={saving}
+ className={inputClasses}
+ placeholder="Comma-separated channel IDs"
+ />
+
+
+
+ );
+}
diff --git a/web/src/components/dashboard/config-sections/TicketsSection.tsx b/web/src/components/dashboard/config-sections/TicketsSection.tsx
new file mode 100644
index 000000000..202c02a7e
--- /dev/null
+++ b/web/src/components/dashboard/config-sections/TicketsSection.tsx
@@ -0,0 +1,137 @@
+'use client';
+
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { parseNumberInput } from '@/lib/config-normalization';
+import type { GuildConfig } from '@/lib/config-utils';
+import { ToggleSwitch } from '../toggle-switch';
+
+interface TicketsSectionProps {
+ draftConfig: GuildConfig;
+ saving: boolean;
+ onEnabledChange: (enabled: boolean) => void;
+ onFieldChange: (field: string, value: unknown) => void;
+}
+
+/** Shared input styling for text inputs and selects. */
+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';
+
+/**
+ * Tickets configuration section.
+ *
+ * Provides controls for ticket system mode, support roles, auto-close settings,
+ * and transcript channel configuration.
+ */
+export function TicketsSection({
+ draftConfig,
+ saving,
+ onEnabledChange,
+ onFieldChange,
+}: TicketsSectionProps) {
+ return (
+
+
+
+ Tickets
+
+
+
+
+
+ Ticket Mode
+ onFieldChange('mode', e.target.value)}
+ disabled={saving}
+ className={inputClasses}
+ >
+ Thread (private thread per ticket)
+ Channel (dedicated text channel per ticket)
+
+
+ Thread mode creates private threads. Channel mode creates locked text channels with
+ permission overrides.
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/dashboard/config-sections/TriageSection.tsx b/web/src/components/dashboard/config-sections/TriageSection.tsx
index d0b13ddf0..026aefd6a 100644
--- a/web/src/components/dashboard/config-sections/TriageSection.tsx
+++ b/web/src/components/dashboard/config-sections/TriageSection.tsx
@@ -1,13 +1,9 @@
'use client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
-import { ChannelSelector } from '@/components/ui/channel-selector';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import { Switch } from '@/components/ui/switch';
-import { useGuildSelection } from '@/hooks/use-guild-selection';
+import { parseNumberInput } from '@/lib/config-normalization';
import type { GuildConfig } from '@/lib/config-utils';
-import { NumberField } from './NumberField';
+import { ToggleSwitch } from '../toggle-switch';
interface TriageSectionProps {
draftConfig: GuildConfig;
@@ -16,16 +12,15 @@ interface TriageSectionProps {
onFieldChange: (field: string, value: unknown) => 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';
+
/**
- * Renders the Triage configuration UI for editing classifier, responder, budget, timing, toggles, and moderation log channel.
- *
- * Renders nothing if `draftConfig.triage` is not present.
+ * Triage configuration section.
*
- * @param draftConfig - Guild configuration draft containing the `triage` settings to display and edit.
- * @param saving - When true, input controls are disabled to prevent changes during a save operation.
- * @param onEnabledChange - Invoked with the new enabled state when the Triage master switch is toggled.
- * @param onFieldChange - Invoked with `(field, value)` for individual field updates; used for all editable triage fields including `moderationLogChannel`.
- * @returns The Triage configuration card element, or `null` when triage configuration is absent.
+ * Provides controls for message triage classifier, responder models,
+ * budgets, intervals, and various feature toggles.
*/
export function TriageSection({
draftConfig,
@@ -33,17 +28,8 @@ export function TriageSection({
onEnabledChange,
onFieldChange,
}: TriageSectionProps) {
- const guildId = useGuildSelection();
-
if (!draftConfig.triage) return null;
- const moderationLogChannel = draftConfig.triage?.moderationLogChannel ?? '';
- const selectedChannels = moderationLogChannel ? [moderationLogChannel] : [];
-
- const handleChannelChange = (channels: string[]) => {
- onFieldChange('moderationLogChannel', channels[0] ?? '');
- };
-
return (
@@ -54,140 +40,185 @@ export function TriageSection({
Configure message triage classifier, responder models, and channels.
-
-
- Classify Model
-
+ Classify Model
+ onFieldChange('classifyModel', e.target.value)}
disabled={saving}
+ className={inputClasses}
placeholder="e.g. claude-haiku-4-5"
/>
-
-
- Respond Model
-
+
+ Respond Model
+ onFieldChange('respondModel', e.target.value)}
disabled={saving}
+ className={inputClasses}
placeholder="e.g. claude-sonnet-4-6"
/>
-
+
- onFieldChange('classifyBudget', v)}
- disabled={saving}
- step={0.01}
- min={0}
- />
- onFieldChange('respondBudget', v)}
- disabled={saving}
- step={0.01}
- min={0}
- />
+
+ Classify Budget
+ {
+ const num = parseNumberInput(e.target.value, 0);
+ if (num !== undefined) onFieldChange('classifyBudget', num);
+ }}
+ disabled={saving}
+ className={inputClasses}
+ />
+
+
+ Respond Budget
+ {
+ const num = parseNumberInput(e.target.value, 0);
+ if (num !== undefined) onFieldChange('respondBudget', num);
+ }}
+ disabled={saving}
+ className={inputClasses}
+ />
+
- onFieldChange('defaultInterval', v)}
- disabled={saving}
- min={1}
- />
- onFieldChange('timeout', v)}
- disabled={saving}
- min={1}
- />
+
+ Default Interval (ms)
+ {
+ const num = parseNumberInput(e.target.value, 1);
+ if (num !== undefined) onFieldChange('defaultInterval', num);
+ }}
+ disabled={saving}
+ className={inputClasses}
+ />
+
+
+ Timeout (ms)
+ {
+ const num = parseNumberInput(e.target.value, 1);
+ if (num !== undefined) onFieldChange('timeout', num);
+ }}
+ disabled={saving}
+ className={inputClasses}
+ />
+
- onFieldChange('contextMessages', v)}
- disabled={saving}
- min={1}
- />
- onFieldChange('maxBufferSize', v)}
- disabled={saving}
- min={1}
- />
+
+ Context Messages
+ {
+ const num = parseNumberInput(e.target.value, 1);
+ if (num !== undefined) onFieldChange('contextMessages', num);
+ }}
+ disabled={saving}
+ className={inputClasses}
+ />
+
+
+ Max Buffer Size
+ {
+ const num = parseNumberInput(e.target.value, 1);
+ if (num !== undefined) onFieldChange('maxBufferSize', num);
+ }}
+ disabled={saving}
+ className={inputClasses}
+ />
+
-
- Streaming
-
- Streaming
+ onFieldChange('streaming', v)}
+ onChange={(v) => onFieldChange('streaming', v)}
disabled={saving}
- aria-label="Toggle streaming"
+ label="Streaming"
/>
-
- Moderation Response
-
- Moderation Response
+ onFieldChange('moderationResponse', v)}
+ onChange={(v) => onFieldChange('moderationResponse', v)}
disabled={saving}
- aria-label="Toggle moderation response"
+ label="Moderation Response"
/>
-
- Debug Footer
-
-
-
-
Moderation Log Channel
- {guildId ? (
-
- ) : (
-
Select a server first
- )}
+
+ Status Reactions
+ onFieldChange('statusReactions', v)}
+ disabled={saving}
+ label="Status Reactions"
+ />
+
+ Moderation Log Channel
+ onFieldChange('moderationLogChannel', e.target.value.trim() || null)}
+ disabled={saving}
+ className={inputClasses}
+ placeholder="Channel ID for moderation logs"
+ />
+
);
diff --git a/web/src/components/dashboard/config-sections/WelcomeSection.tsx b/web/src/components/dashboard/config-sections/WelcomeSection.tsx
index 349ae39a8..b73df4a51 100644
--- a/web/src/components/dashboard/config-sections/WelcomeSection.tsx
+++ b/web/src/components/dashboard/config-sections/WelcomeSection.tsx
@@ -1,24 +1,61 @@
'use client';
+import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
-import { Label } from '@/components/ui/label';
-import { Switch } from '@/components/ui/switch';
-import { Textarea } from '@/components/ui/textarea';
+import { RoleSelector } from '@/components/ui/role-selector';
import type { GuildConfig } from '@/lib/config-utils';
+import { ToggleSwitch } from '../toggle-switch';
interface WelcomeSectionProps {
draftConfig: GuildConfig;
+ guildId: string;
saving: boolean;
+ dmStepsRaw: string;
onEnabledChange: (enabled: boolean) => void;
onMessageChange: (message: string) => void;
+ onFieldChange: (field: string, value: unknown) => void;
+ onRoleMenuChange: (field: string, value: unknown) => void;
+ onDmSequenceChange: (field: string, value: unknown) => void;
+ onDmStepsRawChange: (value: string) => void;
}
+/** Shared input styling for text inputs and textareas. */
+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.
+ */
+function generateId(): string {
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
+ return crypto.randomUUID();
+ }
+ 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);
+ });
+}
+
+/**
+ * Welcome Messages configuration section.
+ *
+ * Provides controls for welcome messages, role menu, and DM sequence settings.
+ */
export function WelcomeSection({
draftConfig,
+ guildId,
saving,
+ dmStepsRaw,
onEnabledChange,
onMessageChange,
+ onFieldChange,
+ onRoleMenuChange,
+ onDmSequenceChange,
+ onDmStepsRawChange,
}: WelcomeSectionProps) {
+ const roleMenuOptions = draftConfig.welcome?.roleMenu?.options ?? [];
+
return (
@@ -27,30 +64,179 @@ export function WelcomeSection({
Welcome Messages
Greet new members when they join the server.
-
-
-
- Welcome Message
-
+
Use {'{user}'} for the member mention and {'{memberCount}'} for the server member count.
+
+
+
+ Rules Channel ID
+ onFieldChange('rulesChannel', e.target.value.trim() || null)}
+ disabled={saving}
+ className={inputClasses}
+ placeholder="Channel where Accept Rules button lives"
+ />
+
+
+ Verified Role ID
+ onFieldChange('verifiedRole', e.target.value.trim() || null)}
+ disabled={saving}
+ className={inputClasses}
+ placeholder="Role granted after rules acceptance"
+ />
+
+
+ Intro Channel ID
+ onFieldChange('introChannel', e.target.value.trim() || null)}
+ disabled={saving}
+ className={inputClasses}
+ placeholder="Channel to prompt member intros"
+ />
+
+
+
+
+ Role Menu
+
+ Enable self-assignable role menu
+ onRoleMenuChange('enabled', v)}
+ disabled={saving}
+ label="Role Menu"
+ />
+
+
+ {roleMenuOptions.map((opt, i) => (
+
+ ))}
+
{
+ const opts = [...roleMenuOptions, { id: generateId(), label: '', roleId: '' }];
+ onRoleMenuChange('options', opts);
+ }}
+ disabled={saving || roleMenuOptions.length >= 25}
+ >
+ + Add Role Option
+
+
+
+
+
+ DM Sequence
+
+ Enable onboarding DMs
+ onDmSequenceChange('enabled', v)}
+ disabled={saving}
+ label="DM Sequence"
+ />
+
+ onDmStepsRawChange(e.target.value)}
+ onBlur={(e) => {
+ const parsed = e.currentTarget.value
+ .split('\n')
+ .map((line) => line.trim())
+ .filter(Boolean);
+ onDmSequenceChange('steps', parsed);
+ onDmStepsRawChange(parsed.join('\n'));
+ }}
+ rows={4}
+ disabled={saving}
+ className={inputClasses}
+ placeholder="One DM step per line"
+ />
+
);
diff --git a/web/src/components/dashboard/config-sections/index.ts b/web/src/components/dashboard/config-sections/index.ts
new file mode 100644
index 000000000..0cb9138e0
--- /dev/null
+++ b/web/src/components/dashboard/config-sections/index.ts
@@ -0,0 +1,14 @@
+export { AiAutoModSection } from './AiAutoModSection';
+export { AiSection } from './AiSection';
+export { ChallengesSection } from './ChallengesSection';
+export { CommunityFeaturesSection } from './CommunityFeaturesSection';
+export { EngagementSection } from './EngagementSection';
+export { GitHubSection } from './GitHubSection';
+export { MemorySection } from './MemorySection';
+export { ModerationSection } from './ModerationSection';
+export { PermissionsSection } from './PermissionsSection';
+export { ReputationSection } from './ReputationSection';
+export { StarboardSection } from './StarboardSection';
+export { TicketsSection } from './TicketsSection';
+export { TriageSection } from './TriageSection';
+export { WelcomeSection } from './WelcomeSection';
diff --git a/web/src/lib/config-normalization.ts b/web/src/lib/config-normalization.ts
new file mode 100644
index 000000000..edeefd297
--- /dev/null
+++ b/web/src/lib/config-normalization.ts
@@ -0,0 +1,93 @@
+/**
+ * Normalization utilities for config editor values.
+ *
+ * Provides consistent transformation of user input (strings, arrays)
+ * into API-compatible formats.
+ */
+
+/**
+ * Parse a comma-separated string into an array of trimmed, non-empty strings.
+ *
+ * @param raw - The comma-separated input string
+ * @returns Array of trimmed, non-empty values
+ */
+export function parseCommaSeparatedList(raw: string): string[] {
+ return raw
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean);
+}
+
+/**
+ * Normalize a comma-separated list of role IDs for display.
+ * Joins array values with ', ' for consistent formatting.
+ *
+ * @param roleIds - Array of role ID strings
+ * @returns Formatted string for display in inputs
+ */
+export function formatRoleIdsForDisplay(roleIds: string[]): string {
+ return roleIds.join(', ');
+}
+
+/**
+ * Parse newline-separated text into an array of trimmed, non-empty lines.
+ *
+ * @param raw - The multiline input string
+ * @returns Array of trimmed, non-empty lines
+ */
+export function parseNewlineSeparatedList(raw: string): string[] {
+ return raw
+ .split('\n')
+ .map((line) => line.trim())
+ .filter(Boolean);
+}
+
+/**
+ * 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)
+ */
+export 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;
+}
+
+/**
+ * Normalize a threshold percentage (0-100) to a decimal (0-1).
+ *
+ * @param percent - The percentage value (0-100)
+ * @returns Clamped decimal value between 0 and 1
+ */
+export function percentToDecimal(percent: number): number {
+ if (Number.isNaN(percent)) return 0;
+ return Math.min(1, Math.max(0, percent / 100));
+}
+
+/**
+ * Convert a decimal threshold (0-1) to a percentage (0-100) for display.
+ *
+ * @param decimal - The decimal value (0-1)
+ * @returns Percentage value rounded to nearest integer
+ */
+export function decimalToPercent(decimal: number): number {
+ return Math.round(decimal * 100);
+}
+
+/**
+ * Normalize an optional string value for API storage.
+ * Converts empty strings to null, otherwise trims whitespace.
+ *
+ * @param value - The input string value
+ * @returns Trimmed string or null if empty
+ */
+export function normalizeOptionalString(value: string): string | null {
+ const trimmed = value.trim();
+ return trimmed || null;
+}
diff --git a/web/src/lib/config-updates.ts b/web/src/lib/config-updates.ts
new file mode 100644
index 000000000..6bf132a1c
--- /dev/null
+++ b/web/src/lib/config-updates.ts
@@ -0,0 +1,234 @@
+import type { GuildConfig } from '@/lib/config-utils';
+
+/**
+ * Immutable update utilities for guild configuration.
+ *
+ * These helpers produce new config objects with updated values,
+ * preserving immutability and type safety.
+ */
+
+/**
+ * Update a top-level section's enabled flag.
+ *
+ * @param config - The current guild config
+ * @param section - The section name to update
+ * @param enabled - The new enabled value
+ * @returns Updated config with the section's enabled flag set
+ */
+export function updateSectionEnabled(
+ config: GuildConfig,
+ section: K,
+ enabled: boolean,
+): GuildConfig {
+ return {
+ ...config,
+ [section]: {
+ ...((config[section] as Record) || {}),
+ enabled,
+ },
+ } as GuildConfig;
+}
+
+/**
+ * Update a field within a specific section.
+ *
+ * @param config - The current guild config
+ * @param section - The section name to update
+ * @param field - The field name within the section
+ * @param value - The new value
+ * @returns Updated config with the field set
+ */
+export function updateSectionField(
+ config: GuildConfig,
+ section: K,
+ field: string,
+ value: unknown,
+): GuildConfig {
+ return {
+ ...config,
+ [section]: {
+ ...((config[section] as Record) || {}),
+ [field]: value,
+ },
+ } as GuildConfig;
+}
+
+/**
+ * Update a nested object field within a section.
+ *
+ * @param config - The current guild config
+ * @param section - The section name to update
+ * @param nestedKey - The nested object key (e.g., 'rateLimit', 'protectRoles')
+ * @param field - The field name within the nested object
+ * @param value - The new value
+ * @returns Updated config with the nested field set
+ */
+export function updateNestedField(
+ config: GuildConfig,
+ section: K,
+ nestedKey: string,
+ field: string,
+ value: unknown,
+): GuildConfig {
+ const sectionData = (config[section] as Record) || {};
+ const nestedData = (sectionData[nestedKey] as Record) || {};
+
+ return {
+ ...config,
+ [section]: {
+ ...sectionData,
+ [nestedKey]: {
+ ...nestedData,
+ [field]: value,
+ },
+ },
+ } as GuildConfig;
+}
+
+/**
+ * Update an array item at a specific index within a nested path.
+ *
+ * @param config - The current guild config
+ * @param section - The section name
+ * @param path - Array of keys to traverse (e.g., ['roleMenu', 'options'])
+ * @param index - The index to update
+ * @param item - The new item value
+ * @returns Updated config with the array item replaced
+ */
+export function updateArrayItem(
+ config: GuildConfig,
+ section: keyof GuildConfig,
+ path: string[],
+ index: number,
+ item: T,
+): GuildConfig {
+ const sectionData = (config[section] as Record) || {};
+
+ // Handle edge case: empty path
+ if (path.length === 0) return config;
+
+ // Track each level's data during traversal for correct rebuilding
+ const levels: Record[] = [sectionData];
+ let cursor: Record = sectionData;
+ for (let i = 0; i < path.length - 1; i++) {
+ const next = (cursor[path[i]] as Record) || {};
+ levels.push(next);
+ cursor = next;
+ }
+
+ const lastKey = path[path.length - 1];
+ const arr = [...((cursor[lastKey] as T[]) || [])];
+
+ // Validate index bounds
+ if (!Number.isInteger(index) || index < 0 || index >= arr.length) {
+ return config;
+ }
+
+ arr[index] = item;
+
+ // Rebuild from bottom up using tracked levels
+ let rebuilt: Record = { ...cursor, [lastKey]: arr };
+ for (let i = path.length - 2; i >= 0; i--) {
+ rebuilt = { ...levels[i], [path[i]]: rebuilt };
+ }
+
+ return {
+ ...config,
+ [section]: rebuilt,
+ } as GuildConfig;
+}
+
+/**
+ * Remove an array item at a specific index within a nested path.
+ *
+ * @param config - The current guild config
+ * @param section - The section name
+ * @param path - Array of keys to traverse (e.g., ['roleMenu', 'options'])
+ * @param index - The index to remove
+ * @returns Updated config with the array item removed
+ */
+export function removeArrayItem(
+ config: GuildConfig,
+ section: keyof GuildConfig,
+ path: string[],
+ index: number,
+): GuildConfig {
+ const sectionData = (config[section] as Record) || {};
+
+ // Handle edge case: empty path
+ if (path.length === 0) return config;
+
+ // Track each level's data during traversal for correct rebuilding
+ const levels: Record[] = [sectionData];
+ let cursor: Record = sectionData;
+ for (let i = 0; i < path.length - 1; i++) {
+ const next = (cursor[path[i]] as Record) || {};
+ levels.push(next);
+ cursor = next;
+ }
+
+ const lastKey = path[path.length - 1];
+ const arr = [...((cursor[lastKey] as unknown[]) || [])];
+
+ // Validate index bounds
+ if (!Number.isInteger(index) || index < 0 || index >= arr.length) {
+ return config;
+ }
+
+ arr.splice(index, 1);
+
+ // Rebuild from bottom up using tracked levels
+ let rebuilt: Record = { ...cursor, [lastKey]: arr };
+ for (let i = path.length - 2; i >= 0; i--) {
+ rebuilt = { ...levels[i], [path[i]]: rebuilt };
+ }
+
+ return {
+ ...config,
+ [section]: rebuilt,
+ } as GuildConfig;
+}
+
+/**
+ * Append an item to an array within a nested path.
+ *
+ * @param config - The current guild config
+ * @param section - The section name
+ * @param path - Array of keys to traverse (e.g., ['roleMenu', 'options'])
+ * @param item - The item to append
+ * @returns Updated config with the item appended
+ */
+export function appendArrayItem(
+ config: GuildConfig,
+ section: keyof GuildConfig,
+ path: string[],
+ item: T,
+): GuildConfig {
+ const sectionData = (config[section] as Record) || {};
+
+ // Handle edge case: empty path
+ if (path.length === 0) return config;
+
+ // Track each level's data during traversal for correct rebuilding
+ const levels: Record[] = [sectionData];
+ let cursor: Record = sectionData;
+ for (let i = 0; i < path.length - 1; i++) {
+ const next = (cursor[path[i]] as Record) || {};
+ levels.push(next);
+ cursor = next;
+ }
+
+ const lastKey = path[path.length - 1];
+ const arr = [...((cursor[lastKey] as T[]) || []), item];
+
+ // Rebuild from bottom up using tracked levels
+ let rebuilt: Record = { ...cursor, [lastKey]: arr };
+ for (let i = path.length - 2; i >= 0; i--) {
+ rebuilt = { ...levels[i], [path[i]]: rebuilt };
+ }
+
+ return {
+ ...config,
+ [section]: rebuilt,
+ } as GuildConfig;
+}
diff --git a/web/tests/components/dashboard/config-editor-autosave.test.tsx b/web/tests/components/dashboard/config-editor-autosave.test.tsx
index 92959e08b..2beb83f40 100644
--- a/web/tests/components/dashboard/config-editor-autosave.test.tsx
+++ b/web/tests/components/dashboard/config-editor-autosave.test.tsx
@@ -1,14 +1,14 @@
/**
- * Tests for the auto-save feature in ConfigEditor.
+ * Tests for config-editor section components and save/revert behavior.
*
* Covers:
- * - AutoSaveStatus component renders the correct UI for idle, saving, saved, and error states
- * - ConfigEditor loads config without triggering auto-save (no PATCH on mount)
- * - Validation error banner appears when system prompt exceeds max length
- * - Retry button is present in the error state
+ * - ConfigEditor loads config without triggering save (no PATCH on mount)
+ * - Validation error detection for system prompt length
+ * - Section-level revert functionality
+ * - Normalization utilities
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
-import { render, screen, waitFor, act, fireEvent } from '@testing-library/react';
+import { render, screen, waitFor, act } from '@testing-library/react';
// ── Mocks ─────────────────────────────────────────────────────────
@@ -25,7 +25,7 @@ vi.mock('@/components/dashboard/reset-defaults-button', () => ({
onReset: () => void;
disabled: boolean;
}) => (
-
+
Discard
),
@@ -59,6 +59,10 @@ vi.mock('@/components/dashboard/config-diff', () => ({
ConfigDiff: () =>
,
}));
+vi.mock('@/components/dashboard/config-diff-modal', () => ({
+ ConfigDiffModal: () =>
,
+}));
+
// ── Fixtures ──────────────────────────────────────────────────────
const minimalConfig = {
@@ -86,7 +90,7 @@ const minimalConfig = {
// ── Tests ─────────────────────────────────────────────────────────
-describe('ConfigEditor auto-save integration', () => {
+describe('ConfigEditor integration', () => {
beforeEach(() => {
localStorage.clear();
localStorage.setItem('volvox-bot-selected-guild', 'guild-123');
@@ -123,7 +127,7 @@ describe('ConfigEditor auto-save integration', () => {
expect(patchCalls).toHaveLength(0);
});
- it('shows validation error banner when system prompt exceeds max length', async () => {
+ it('renders all section components after loading', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
@@ -136,50 +140,18 @@ describe('ConfigEditor auto-save integration', () => {
await waitFor(
() => {
- expect(screen.getByTestId('system-prompt')).toBeInTheDocument();
- },
- { timeout: 3000 },
- );
-
- // Type more than SYSTEM_PROMPT_MAX_LENGTH chars (20000)
- const tooLong = 'x'.repeat(20001);
- await act(async () => {
- fireEvent.change(screen.getByTestId('system-prompt'), { target: { value: tooLong } });
- });
-
- await waitFor(
- () => {
- expect(
- screen.getByText(/Fix validation errors before changes can be saved/),
- ).toBeInTheDocument();
+ expect(screen.getByText('Bot Configuration')).toBeInTheDocument();
},
{ timeout: 3000 },
);
- // No PATCH should have been issued
- const patchCalls = fetchMock.mock.calls.filter(
- (call: unknown[]) => (call[1] as { method?: string } | undefined)?.method === 'PATCH',
- );
- expect(patchCalls).toHaveLength(0);
- });
-});
-
-// ── Unit tests for AutoSaveStatus (via snapshot-style checks) ─────
-
-describe('auto-save status UI', () => {
- beforeEach(() => {
- vi.useFakeTimers();
- localStorage.clear();
- localStorage.setItem('volvox-bot-selected-guild', 'guild-123');
- });
-
- afterEach(() => {
- vi.useRealTimers();
- vi.restoreAllMocks();
- vi.unstubAllGlobals();
+ // Check that main sections are rendered
+ expect(screen.getByText('AI Chat')).toBeInTheDocument();
+ expect(screen.getByText('Welcome Messages')).toBeInTheDocument();
+ expect(screen.getByText('Save Changes')).toBeInTheDocument();
});
- it('shows no status indicator when idle', async () => {
+ it('renders with initial disabled discard button', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
@@ -190,128 +162,91 @@ describe('auto-save status UI', () => {
const { ConfigEditor } = await import('@/components/dashboard/config-editor');
render( );
- await act(async () => {
- await vi.advanceTimersByTimeAsync(10);
- });
+ await waitFor(
+ () => {
+ expect(screen.getByTestId('system-prompt')).toBeInTheDocument();
+ },
+ { timeout: 3000 },
+ );
- expect(screen.getByTestId('system-prompt')).toBeInTheDocument();
- expect(screen.queryByText('Saving...')).not.toBeInTheDocument();
- expect(screen.queryByText('Saved')).not.toBeInTheDocument();
- expect(screen.queryByText('Save failed')).not.toBeInTheDocument();
+ // Initially discard button should be disabled (no changes yet)
+ const discardButton = screen.getByTestId('discard-button');
+ expect(discardButton).toBeDisabled();
});
+});
- it('shows "Saving..." with a spinner while saving', async () => {
- const fetchMock = vi.fn().mockImplementation((_url: string, opts: { method?: string }) => {
- if (opts?.method === 'PATCH') {
- // Never resolves — keeps saving state visible
- return new Promise(() => {});
- }
- return Promise.resolve({
- ok: true,
- status: 200,
- json: () => Promise.resolve(minimalConfig),
- });
- });
- vi.stubGlobal('fetch', fetchMock);
-
- const { ConfigEditor } = await import('@/components/dashboard/config-editor');
- render( );
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(10);
- });
-
- expect(screen.getByTestId('system-prompt')).toBeInTheDocument();
-
- // Trigger a change so auto-save will fire
- await act(async () => {
- fireEvent.change(screen.getByTestId('system-prompt'), { target: { value: 'Hello' } });
- });
-
- // Advance past the 500ms debounce
- await act(async () => {
- await vi.advanceTimersByTimeAsync(600);
- });
+// ── Unit tests for normalization utilities ────────────────────────
- expect(screen.getByText('Saving...')).toBeInTheDocument();
+describe('config-normalization', () => {
+ it('parseNumberInput handles valid numbers', async () => {
+ const { parseNumberInput } = await import('@/lib/config-normalization');
+ expect(parseNumberInput('42')).toBe(42);
+ expect(parseNumberInput('3.14')).toBe(3.14);
+ expect(parseNumberInput('0')).toBe(0);
});
- it('shows "Saved" after a successful save', async () => {
- const fetchMock = vi.fn().mockResolvedValue({
- ok: true,
- status: 200,
- json: () => Promise.resolve(minimalConfig),
- });
- vi.stubGlobal('fetch', fetchMock);
-
- const { ConfigEditor } = await import('@/components/dashboard/config-editor');
- render( );
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(10);
- });
-
- expect(screen.getByTestId('system-prompt')).toBeInTheDocument();
-
- // Change to trigger auto-save
- await act(async () => {
- fireEvent.change(screen.getByTestId('system-prompt'), { target: { value: 'Updated prompt' } });
- });
-
- // Advance past debounce and let save + reload complete
- await act(async () => {
- await vi.advanceTimersByTimeAsync(600);
- });
- await act(async () => {
- await vi.advanceTimersByTimeAsync(10);
- });
+ it('parseNumberInput returns undefined for empty string', async () => {
+ const { parseNumberInput } = await import('@/lib/config-normalization');
+ expect(parseNumberInput('')).toBeUndefined();
+ });
- expect(screen.getByText('Saved')).toBeInTheDocument();
+ it('parseNumberInput clamps to min/max bounds', async () => {
+ const { parseNumberInput } = await import('@/lib/config-normalization');
+ expect(parseNumberInput('5', 10)).toBe(10);
+ expect(parseNumberInput('100', 0, 50)).toBe(50);
+ expect(parseNumberInput('25', 10, 50)).toBe(25);
});
- it('shows "Save failed" with a Retry button when PATCH returns an error', async () => {
- let callCount = 0;
- const fetchMock = vi.fn().mockImplementation(() => {
- callCount++;
- if (callCount === 1) {
- return Promise.resolve({
- ok: true,
- status: 200,
- json: () => Promise.resolve(minimalConfig),
- });
- }
- return Promise.resolve({
- ok: false,
- status: 500,
- json: () => Promise.resolve({ error: 'Server error' }),
- });
- });
- vi.stubGlobal('fetch', fetchMock);
+ it('percentToDecimal converts correctly', async () => {
+ const { percentToDecimal } = await import('@/lib/config-normalization');
+ expect(percentToDecimal(100)).toBe(1);
+ expect(percentToDecimal(50)).toBe(0.5);
+ expect(percentToDecimal(0)).toBe(0);
+ expect(percentToDecimal(150)).toBe(1); // clamped
+ expect(percentToDecimal(-50)).toBe(0); // clamped
+ });
- const { ConfigEditor } = await import('@/components/dashboard/config-editor');
- render( );
+ it('decimalToPercent converts correctly', async () => {
+ const { decimalToPercent } = await import('@/lib/config-normalization');
+ expect(decimalToPercent(1)).toBe(100);
+ expect(decimalToPercent(0.5)).toBe(50);
+ expect(decimalToPercent(0)).toBe(0);
+ expect(decimalToPercent(0.333)).toBe(33);
+ });
+});
- await act(async () => {
- await vi.advanceTimersByTimeAsync(10);
- });
+// ── Unit tests for config update utilities ────────────────────────
- expect(screen.getByTestId('system-prompt')).toBeInTheDocument();
+describe('config-updates', () => {
+ const baseConfig = {
+ ai: { enabled: false, systemPrompt: '' },
+ welcome: { enabled: false, message: '' },
+ };
- await act(async () => {
- fireEvent.change(screen.getByTestId('system-prompt'), {
- target: { value: 'Trigger save failure' },
- });
- });
+ it('updateSectionEnabled toggles section enabled state', async () => {
+ const { updateSectionEnabled } = await import('@/lib/config-updates');
+ const result = updateSectionEnabled(baseConfig, 'ai', true);
+ expect(result.ai?.enabled).toBe(true);
+ expect(result.welcome?.enabled).toBe(false);
+ });
- // Advance past debounce + let save attempt complete
- await act(async () => {
- await vi.advanceTimersByTimeAsync(600);
- });
- await act(async () => {
- await vi.advanceTimersByTimeAsync(10);
- });
+ it('updateSectionField updates specific field', async () => {
+ const { updateSectionField } = await import('@/lib/config-updates');
+ const result = updateSectionField(baseConfig, 'ai', 'systemPrompt', 'Hello');
+ expect(result.ai?.systemPrompt).toBe('Hello');
+ expect(result.ai?.enabled).toBe(false);
+ });
- expect(screen.getByText('Save failed')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Retry save' })).toBeInTheDocument();
+ it('updateNestedField updates nested object fields', async () => {
+ const { updateNestedField } = await import('@/lib/config-updates');
+ const configWithNested = {
+ ...baseConfig,
+ moderation: {
+ enabled: false,
+ rateLimit: { enabled: false, maxMessages: 10 },
+ },
+ };
+ const result = updateNestedField(configWithNested, 'moderation', 'rateLimit', 'maxMessages', 20);
+ expect((result.moderation as { rateLimit?: { maxMessages?: number } })?.rateLimit?.maxMessages).toBe(20);
});
});
diff --git a/web/tests/lib/config-normalization.test.ts b/web/tests/lib/config-normalization.test.ts
new file mode 100644
index 000000000..6d1724d34
--- /dev/null
+++ b/web/tests/lib/config-normalization.test.ts
@@ -0,0 +1,188 @@
+import { describe, expect, it } from 'vitest';
+import {
+ parseCommaSeparatedList,
+ formatRoleIdsForDisplay,
+ parseNewlineSeparatedList,
+ parseNumberInput,
+ percentToDecimal,
+ decimalToPercent,
+ normalizeOptionalString,
+} from '@/lib/config-normalization';
+
+describe('config-normalization', () => {
+ describe('parseCommaSeparatedList', () => {
+ it('parses comma-separated values into trimmed array', () => {
+ const result = parseCommaSeparatedList('a, b, c');
+ expect(result).toEqual(['a', 'b', 'c']);
+ });
+
+ it('handles extra whitespace', () => {
+ const result = parseCommaSeparatedList(' a , b , c ');
+ expect(result).toEqual(['a', 'b', 'c']);
+ });
+
+ it('filters empty values', () => {
+ const result = parseCommaSeparatedList('a,,b,,c,');
+ expect(result).toEqual(['a', 'b', 'c']);
+ });
+
+ it('returns empty array for empty string', () => {
+ const result = parseCommaSeparatedList('');
+ expect(result).toEqual([]);
+ });
+
+ it('handles single value', () => {
+ const result = parseCommaSeparatedList('123456789');
+ expect(result).toEqual(['123456789']);
+ });
+ });
+
+ describe('formatRoleIdsForDisplay', () => {
+ it('joins role IDs with comma and space', () => {
+ const result = formatRoleIdsForDisplay(['123', '456', '789']);
+ expect(result).toBe('123, 456, 789');
+ });
+
+ it('returns empty string for empty array', () => {
+ const result = formatRoleIdsForDisplay([]);
+ expect(result).toBe('');
+ });
+
+ it('handles single role ID', () => {
+ const result = formatRoleIdsForDisplay(['123']);
+ expect(result).toBe('123');
+ });
+ });
+
+ describe('parseNewlineSeparatedList', () => {
+ it('parses newline-separated values', () => {
+ const result = parseNewlineSeparatedList('line1\nline2\nline3');
+ expect(result).toEqual(['line1', 'line2', 'line3']);
+ });
+
+ it('trims whitespace from each line', () => {
+ const result = parseNewlineSeparatedList(' line1 \n line2 ');
+ expect(result).toEqual(['line1', 'line2']);
+ });
+
+ it('filters empty lines', () => {
+ const result = parseNewlineSeparatedList('line1\n\nline2\n\n');
+ expect(result).toEqual(['line1', 'line2']);
+ });
+
+ it('returns empty array for empty string', () => {
+ const result = parseNewlineSeparatedList('');
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('parseNumberInput', () => {
+ it('parses valid number string', () => {
+ const result = parseNumberInput('42');
+ expect(result).toBe(42);
+ });
+
+ it('returns undefined for empty string', () => {
+ const result = parseNumberInput('');
+ expect(result).toBeUndefined();
+ });
+
+ it('returns undefined for non-numeric string', () => {
+ const result = parseNumberInput('abc');
+ expect(result).toBeUndefined();
+ });
+
+ it('returns undefined for NaN', () => {
+ const result = parseNumberInput('NaN');
+ expect(result).toBeUndefined();
+ });
+
+ it('clamps to minimum value', () => {
+ const result = parseNumberInput('5', 10);
+ expect(result).toBe(10);
+ });
+
+ it('clamps to maximum value', () => {
+ const result = parseNumberInput('100', 0, 50);
+ expect(result).toBe(50);
+ });
+
+ it('handles decimal numbers', () => {
+ const result = parseNumberInput('3.14');
+ expect(result).toBe(3.14);
+ });
+
+ it('handles negative numbers', () => {
+ const result = parseNumberInput('-10');
+ expect(result).toBe(-10);
+ });
+
+ it('clamps negative to min', () => {
+ const result = parseNumberInput('-10', 0);
+ expect(result).toBe(0);
+ });
+ });
+
+ describe('percentToDecimal', () => {
+ it('converts 100% to 1.0', () => {
+ expect(percentToDecimal(100)).toBe(1);
+ });
+
+ it('converts 50% to 0.5', () => {
+ expect(percentToDecimal(50)).toBe(0.5);
+ });
+
+ it('converts 0% to 0.0', () => {
+ expect(percentToDecimal(0)).toBe(0);
+ });
+
+ it('clamps values above 100', () => {
+ expect(percentToDecimal(150)).toBe(1);
+ });
+
+ it('clamps negative values', () => {
+ expect(percentToDecimal(-50)).toBe(0);
+ });
+
+ it('handles NaN as 0', () => {
+ expect(percentToDecimal(NaN)).toBe(0);
+ });
+ });
+
+ describe('decimalToPercent', () => {
+ it('converts 1.0 to 100%', () => {
+ expect(decimalToPercent(1)).toBe(100);
+ });
+
+ it('converts 0.5 to 50%', () => {
+ expect(decimalToPercent(0.5)).toBe(50);
+ });
+
+ it('converts 0.0 to 0%', () => {
+ expect(decimalToPercent(0)).toBe(0);
+ });
+
+ it('rounds to nearest integer', () => {
+ expect(decimalToPercent(0.333)).toBe(33);
+ expect(decimalToPercent(0.666)).toBe(67);
+ });
+ });
+
+ describe('normalizeOptionalString', () => {
+ it('trims whitespace', () => {
+ expect(normalizeOptionalString(' hello ')).toBe('hello');
+ });
+
+ it('returns null for empty string', () => {
+ expect(normalizeOptionalString('')).toBeNull();
+ });
+
+ it('returns null for whitespace-only string', () => {
+ expect(normalizeOptionalString(' ')).toBeNull();
+ });
+
+ it('preserves non-empty strings', () => {
+ expect(normalizeOptionalString('channel-id-123')).toBe('channel-id-123');
+ });
+ });
+});
diff --git a/web/tests/lib/config-updates.test.ts b/web/tests/lib/config-updates.test.ts
new file mode 100644
index 000000000..90638e2d2
--- /dev/null
+++ b/web/tests/lib/config-updates.test.ts
@@ -0,0 +1,178 @@
+import { describe, expect, it } from 'vitest';
+import type { GuildConfig } from '@/lib/config-utils';
+import {
+ updateSectionEnabled,
+ updateSectionField,
+ updateNestedField,
+ updateArrayItem,
+ removeArrayItem,
+ appendArrayItem,
+} from '@/lib/config-updates';
+
+describe('config-updates', () => {
+ const baseConfig: GuildConfig = {
+ ai: { enabled: false, systemPrompt: '' },
+ welcome: {
+ enabled: true,
+ message: 'Hello!',
+ roleMenu: {
+ enabled: false,
+ options: [
+ { id: '1', label: 'Option 1', roleId: 'role-1' },
+ { id: '2', label: 'Option 2', roleId: 'role-2' },
+ ],
+ },
+ },
+ moderation: {
+ enabled: false,
+ rateLimit: {
+ enabled: true,
+ maxMessages: 10,
+ },
+ },
+ };
+
+ describe('updateSectionEnabled', () => {
+ it('updates section enabled flag', () => {
+ const result = updateSectionEnabled(baseConfig, 'ai', true);
+ expect(result.ai?.enabled).toBe(true);
+ });
+
+ it('preserves other section fields', () => {
+ const result = updateSectionEnabled(baseConfig, 'ai', true);
+ expect(result.ai?.systemPrompt).toBe('');
+ });
+
+ it('creates section if it does not exist', () => {
+ const config: GuildConfig = {};
+ const result = updateSectionEnabled(config, 'starboard', true);
+ expect(result.starboard?.enabled).toBe(true);
+ });
+
+ it('does not mutate original config', () => {
+ const original = { ...baseConfig };
+ updateSectionEnabled(baseConfig, 'ai', true);
+ expect(baseConfig.ai?.enabled).toBe(original.ai?.enabled);
+ });
+ });
+
+ describe('updateSectionField', () => {
+ it('updates a field within a section', () => {
+ const result = updateSectionField(baseConfig, 'welcome', 'message', 'New message');
+ expect(result.welcome?.message).toBe('New message');
+ });
+
+ it('preserves other fields in the section', () => {
+ const result = updateSectionField(baseConfig, 'welcome', 'message', 'New message');
+ expect(result.welcome?.enabled).toBe(true);
+ });
+
+ it('creates section if it does not exist', () => {
+ const config: GuildConfig = {};
+ const result = updateSectionField(config, 'permissions', 'adminRoleId', '123');
+ expect(result.permissions?.adminRoleId).toBe('123');
+ });
+ });
+
+ describe('updateNestedField', () => {
+ it('updates a nested field', () => {
+ const result = updateNestedField(baseConfig, 'moderation', 'rateLimit', 'maxMessages', 20);
+ expect(result.moderation?.rateLimit?.maxMessages).toBe(20);
+ });
+
+ it('preserves sibling nested fields', () => {
+ const result = updateNestedField(baseConfig, 'moderation', 'rateLimit', 'maxMessages', 20);
+ expect(result.moderation?.rateLimit?.enabled).toBe(true);
+ });
+
+ it('creates nested object if it does not exist', () => {
+ const config: GuildConfig = { moderation: { enabled: true } };
+ const result = updateNestedField(config, 'moderation', 'linkFilter', 'enabled', true);
+ expect(result.moderation?.linkFilter?.enabled).toBe(true);
+ });
+ });
+
+ describe('updateArrayItem', () => {
+ it('updates an item at specified index', () => {
+ const newOption = { id: '1', label: 'Updated', roleId: 'role-1' };
+ const result = updateArrayItem(
+ baseConfig,
+ 'welcome',
+ ['roleMenu', 'options'],
+ 0,
+ newOption,
+ );
+ expect(result.welcome?.roleMenu?.options?.[0]).toEqual(newOption);
+ });
+
+ it('preserves other array items', () => {
+ const newOption = { id: '1', label: 'Updated', roleId: 'role-1' };
+ const result = updateArrayItem(
+ baseConfig,
+ 'welcome',
+ ['roleMenu', 'options'],
+ 0,
+ newOption,
+ );
+ expect(result.welcome?.roleMenu?.options?.[1]).toEqual(baseConfig.welcome?.roleMenu?.options?.[1]);
+ });
+
+ it('creates array if it does not exist', () => {
+ const config: GuildConfig = { welcome: { enabled: true } };
+ const newOption = { id: '1', label: 'New', roleId: 'role-1' };
+ const result = updateArrayItem(config, 'welcome', ['roleMenu', 'options'], 0, newOption);
+ expect(result.welcome?.roleMenu?.options).toHaveLength(1);
+ expect(result.welcome?.roleMenu?.options?.[0]).toEqual(newOption);
+ });
+ });
+
+ describe('removeArrayItem', () => {
+ it('removes item at specified index', () => {
+ const result = removeArrayItem(baseConfig, 'welcome', ['roleMenu', 'options'], 0);
+ expect(result.welcome?.roleMenu?.options).toHaveLength(1);
+ expect(result.welcome?.roleMenu?.options?.[0].id).toBe('2');
+ });
+
+ it('handles removing last item', () => {
+ const config: GuildConfig = {
+ welcome: {
+ roleMenu: {
+ options: [{ id: '1', label: 'Only', roleId: 'role-1' }],
+ },
+ },
+ };
+ const result = removeArrayItem(config, 'welcome', ['roleMenu', 'options'], 0);
+ expect(result.welcome?.roleMenu?.options).toHaveLength(0);
+ });
+
+ it('handles empty array gracefully', () => {
+ const config: GuildConfig = { welcome: { roleMenu: { options: [] } } };
+ const result = removeArrayItem(config, 'welcome', ['roleMenu', 'options'], 0);
+ expect(result.welcome?.roleMenu?.options).toHaveLength(0);
+ });
+ });
+
+ describe('appendArrayItem', () => {
+ it('appends item to array', () => {
+ const newOption = { id: '3', label: 'Option 3', roleId: 'role-3' };
+ const result = appendArrayItem(baseConfig, 'welcome', ['roleMenu', 'options'], newOption);
+ expect(result.welcome?.roleMenu?.options).toHaveLength(3);
+ expect(result.welcome?.roleMenu?.options?.[2]).toEqual(newOption);
+ });
+
+ it('creates array if it does not exist', () => {
+ const config: GuildConfig = { welcome: { enabled: true } };
+ const newOption = { id: '1', label: 'First', roleId: 'role-1' };
+ const result = appendArrayItem(config, 'welcome', ['roleMenu', 'options'], newOption);
+ expect(result.welcome?.roleMenu?.options).toHaveLength(1);
+ expect(result.welcome?.roleMenu?.options?.[0]).toEqual(newOption);
+ });
+
+ it('preserves existing items', () => {
+ const newOption = { id: '3', label: 'Option 3', roleId: 'role-3' };
+ const result = appendArrayItem(baseConfig, 'welcome', ['roleMenu', 'options'], newOption);
+ expect(result.welcome?.roleMenu?.options?.[0]).toEqual(baseConfig.welcome?.roleMenu?.options?.[0]);
+ expect(result.welcome?.roleMenu?.options?.[1]).toEqual(baseConfig.welcome?.roleMenu?.options?.[1]);
+ });
+ });
+});