diff --git a/web/src/components/config/ConfigRawEditor.tsx b/web/src/components/config/ConfigRawEditor.tsx index cb550dad7..8eb8dc133 100644 --- a/web/src/components/config/ConfigRawEditor.tsx +++ b/web/src/components/config/ConfigRawEditor.tsx @@ -20,6 +20,7 @@ export default function ConfigRawEditor({ rawToml, onChange, disabled }: Props) onChange={(e) => onChange(e.target.value)} disabled={disabled} spellCheck={false} + aria-label="Raw TOML configuration editor" className="w-full min-h-[500px] bg-gray-950 text-gray-200 font-mono text-sm p-4 resize-y focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset disabled:opacity-50" style={{ tabSize: 4 }} /> diff --git a/web/src/components/config/ConfigSection.tsx b/web/src/components/config/ConfigSection.tsx index b85dd53d0..ad06da01f 100644 --- a/web/src/components/config/ConfigSection.tsx +++ b/web/src/components/config/ConfigSection.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { ChevronRight, ChevronDown } from 'lucide-react'; import type { SectionDef, FieldDef } from './types'; import TextField from './fields/TextField'; @@ -47,14 +47,25 @@ export default function ConfigSection({ visibleFields, }: Props) { const [collapsed, setCollapsed] = useState(section.defaultCollapsed ?? false); + const sectionPanelId = useMemo( + () => + `config-section-${(section.path || 'root').replace(/[^a-zA-Z0-9_-]/g, '-')}`, + [section.path], + ); const Icon = section.icon; const fields = visibleFields ?? section.fields; + useEffect(() => { + setCollapsed(section.defaultCollapsed ?? false); + }, [section.path, section.defaultCollapsed]); + return (
{!collapsed && ( -
+
{fields.map((field) => { const value = getFieldValue(section.path, field.key); const masked = isFieldMasked(section.path, field.key); diff --git a/web/src/components/config/fields/NumberField.tsx b/web/src/components/config/fields/NumberField.tsx index 92106fc54..bf5e0eec1 100644 --- a/web/src/components/config/fields/NumberField.tsx +++ b/web/src/components/config/fields/NumberField.tsx @@ -15,7 +15,20 @@ export default function NumberField({ field, value, onChange }: FieldProps) { } const n = Number(raw); if (!isNaN(n)) { - onChange(field.step && field.step < 1 ? n : Math.floor(n)); + onChange(n); + } + }} + onBlur={(e) => { + if (field.step !== undefined && field.step < 1) { + return; + } + const raw = e.target.value; + if (raw === '') { + return; + } + const n = Number(raw); + if (!isNaN(n)) { + onChange(Math.floor(n)); } }} min={field.min} diff --git a/web/src/components/config/fields/TagListField.tsx b/web/src/components/config/fields/TagListField.tsx index 990168f25..63b965772 100644 --- a/web/src/components/config/fields/TagListField.tsx +++ b/web/src/components/config/fields/TagListField.tsx @@ -32,7 +32,7 @@ export default function TagListField({ field, value, onChange }: FieldProps) {
{tags.map((tag, i) => ( {tag} diff --git a/web/src/components/config/useConfigForm.ts b/web/src/components/config/useConfigForm.ts index bd362030f..9b0b9d00e 100644 --- a/web/src/components/config/useConfigForm.ts +++ b/web/src/components/config/useConfigForm.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; import { parse, stringify } from 'smol-toml'; import { getConfig, putConfig } from '@/lib/api'; @@ -18,7 +18,9 @@ function scanMasked(obj: unknown, prefix: string, out: Set) { return; } if (Array.isArray(obj)) { - obj.forEach((item, i) => scanMasked(item, `${prefix}.${i}`, out)); + obj.forEach((item, i) => { + scanMasked(item, `${prefix}.${i}`, out); + }); return; } if (typeof obj === 'object') { @@ -90,6 +92,7 @@ export function useConfigForm(): ConfigFormState { const [parsed, setParsed] = useState({}); const maskedPathsRef = useRef>(new Set()); const dirtyPathsRef = useRef>(new Set()); + const successTimeoutRef = useRef | null>(null); const [, forceRender] = useState(0); const loadConfig = useCallback(async () => { @@ -121,12 +124,22 @@ export function useConfigForm(): ConfigFormState { } }, []); - // Load on first render + // Load once on mount. const hasLoaded = useRef(false); - if (!hasLoaded.current) { - hasLoaded.current = true; - loadConfig(); - } + useEffect(() => { + if (!hasLoaded.current) { + hasLoaded.current = true; + void loadConfig(); + } + }, [loadConfig]); + + useEffect(() => { + return () => { + if (successTimeoutRef.current) { + clearTimeout(successTimeoutRef.current); + } + }; + }, []); const fieldPath = (sectionPath: string, fieldKey: string) => sectionPath ? `${sectionPath}.${fieldKey}` : fieldKey; @@ -235,6 +248,9 @@ export function useConfigForm(): ConfigFormState { setSaving(true); setError(null); setSuccess(null); + if (successTimeoutRef.current) { + clearTimeout(successTimeoutRef.current); + } try { let toml: string; @@ -247,7 +263,7 @@ export function useConfigForm(): ConfigFormState { setSuccess('Configuration saved successfully.'); // Auto-dismiss success after 4 seconds - setTimeout(() => setSuccess(null), 4000); + successTimeoutRef.current = setTimeout(() => setSuccess(null), 4000); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save configuration'); } finally { @@ -262,6 +278,10 @@ export function useConfigForm(): ConfigFormState { const clearMessages = useCallback(() => { setError(null); setSuccess(null); + if (successTimeoutRef.current) { + clearTimeout(successTimeoutRef.current); + successTimeoutRef.current = null; + } }, []); return {