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