diff --git a/web/public/locales/en/config/audio.json b/web/public/locales/en/config/audio.json index 7e783b1e8..c69f64273 100644 --- a/web/public/locales/en/config/audio.json +++ b/web/public/locales/en/config/audio.json @@ -1,6 +1,10 @@ { "label": "Global Audio events configuration", "description": "Global settings for audio-based event detection; camera-level settings can override these.", + "groups": { + "detection": "Detection", + "sensitivity": "Sensitivity" + }, "enabled": { "label": "Enable audio events", "description": "Enable or disable audio event detection globally. Can be overridden per camera." diff --git a/web/public/locales/en/config/detect.json b/web/public/locales/en/config/detect.json index 55e2f7b81..71f2c25d5 100644 --- a/web/public/locales/en/config/detect.json +++ b/web/public/locales/en/config/detect.json @@ -1,6 +1,10 @@ { "label": "Object tracking", "description": "Settings for the detection/detect role used to run object detection and initialize trackers.", + "groups": { + "resolution": "Resolution", + "tracking": "Tracking" + }, "enabled": { "label": "Detection Enabled", "description": "Enable or disable object detection for this camera. Detection must be enabled for object tracking to run." diff --git a/web/public/locales/en/config/motion.json b/web/public/locales/en/config/motion.json index cbc32e3b0..7543dcff5 100644 --- a/web/public/locales/en/config/motion.json +++ b/web/public/locales/en/config/motion.json @@ -1,6 +1,10 @@ { "label": "Global motion detection configuration", "description": "Default motion detection settings applied to cameras unless overridden per-camera.", + "groups": { + "sensitivity": "Sensitivity", + "algorithm": "Algorithm" + }, "enabled": { "label": "Enable motion detection", "description": "Enable or disable motion detection globally; per-camera settings can override this." diff --git a/web/public/locales/en/config/objects.json b/web/public/locales/en/config/objects.json index 29ec3ca2b..59fe418c3 100644 --- a/web/public/locales/en/config/objects.json +++ b/web/public/locales/en/config/objects.json @@ -1,6 +1,10 @@ { "label": "Global object configuration", "description": "Global object tracking defaults including which labels to track and per-object filters.", + "groups": { + "tracking": "Tracking", + "filtering": "Filtering" + }, "track": { "label": "Objects to track", "description": "List of object labels to track globally; camera configs can override this." diff --git a/web/public/locales/en/config/record.json b/web/public/locales/en/config/record.json index 916d2a9a2..55b736b67 100644 --- a/web/public/locales/en/config/record.json +++ b/web/public/locales/en/config/record.json @@ -1,6 +1,10 @@ { "label": "Global record configuration", "description": "Global recording and retention settings applied to cameras unless overridden per-camera.", + "groups": { + "retention": "Retention", + "events": "Events" + }, "enabled": { "label": "Enable record on all cameras", "description": "Enable or disable recording globally; individual cameras can override this." diff --git a/web/public/locales/en/config/snapshots.json b/web/public/locales/en/config/snapshots.json index 36c571dda..cf57d7b18 100644 --- a/web/public/locales/en/config/snapshots.json +++ b/web/public/locales/en/config/snapshots.json @@ -1,6 +1,9 @@ { "label": "Global snapshots configuration", "description": "Global settings for saved JPEG snapshots of tracked objects; can be overridden per-camera.", + "groups": { + "display": "Display" + }, "enabled": { "label": "Snapshots enabled", "description": "Enable or disable saving snapshots globally." diff --git a/web/public/locales/en/config/timestamp_style.json b/web/public/locales/en/config/timestamp_style.json index 33179ca8a..577fd91c7 100644 --- a/web/public/locales/en/config/timestamp_style.json +++ b/web/public/locales/en/config/timestamp_style.json @@ -1,6 +1,9 @@ { "label": "Global timestamp style configuration", "description": "Global styling options for in-feed timestamps applied to recordings and snapshots.", + "groups": { + "appearance": "Appearance" + }, "position": { "label": "Timestamp position", "description": "Position of the timestamp on the image (tl/tr/bl/br)." diff --git a/web/src/components/config-form/ConfigForm.tsx b/web/src/components/config-form/ConfigForm.tsx index 2945468dc..2380d6df7 100644 --- a/web/src/components/config-form/ConfigForm.tsx +++ b/web/src/components/config-form/ConfigForm.tsx @@ -27,6 +27,8 @@ export interface ConfigFormProps { uiSchema?: UiSchema; /** Field ordering */ fieldOrder?: string[]; + /** Field groups for layout */ + fieldGroups?: Record; /** Fields to hide */ hiddenFields?: string[]; /** Fields marked as advanced (collapsed by default) */ @@ -55,6 +57,7 @@ export function ConfigForm({ onError, uiSchema: customUiSchema, fieldOrder, + fieldGroups, hiddenFields, advancedFields, disabled = false, @@ -100,12 +103,13 @@ export function ConfigForm({ const finalUiSchema = useMemo( () => ({ ...generatedUiSchema, + "ui:groups": fieldGroups, ...customUiSchema, "ui:submitButtonOptions": showSubmit ? { norender: false } : { norender: true }, }), - [generatedUiSchema, customUiSchema, showSubmit], + [generatedUiSchema, customUiSchema, showSubmit, fieldGroups], ); // Create error transformer for user-friendly error messages diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 5d024b775..271ee3dfb 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -45,6 +45,8 @@ export interface SectionConfig { advancedFields?: string[]; /** Fields to compare for override detection */ overrideFields?: string[]; + /** Whether to enable live validation */ + liveValidate?: boolean; /** Additional uiSchema overrides */ uiSchema?: UiSchema; } @@ -466,8 +468,10 @@ export function createConfigSection({ formData={pendingData || formData} onChange={handleChange} fieldOrder={sectionConfig.fieldOrder} + fieldGroups={sectionConfig.fieldGroups} hiddenFields={sectionConfig.hiddenFields} advancedFields={sectionConfig.advancedFields} + liveValidate={sectionConfig.liveValidate} uiSchema={sectionConfig.uiSchema} disabled={disabled || isSaving} readonly={readonly} diff --git a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx index a8c8b6dc4..4c4f90611 100644 --- a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx @@ -9,31 +9,111 @@ import { import { Button } from "@/components/ui/button"; import { useState } from "react"; import { LuChevronDown, LuChevronRight } from "react-icons/lu"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { - const { title, description, properties } = props; + const { title, description, properties, uiSchema } = props; + const formContext = (props as Record).formContext as + | Record + | undefined; // Check if this is a root-level object const isRoot = !title; const [isOpen, setIsOpen] = useState(true); + const { t } = useTranslation([ + (formContext?.i18nNamespace as string | undefined) || "common", + ]); + + const groupDefinitions = + (uiSchema?.["ui:groups"] as Record | undefined) || {}; + + const isHiddenProp = (prop: (typeof properties)[number]) => + prop.content.props.uiSchema?.["ui:widget"] === "hidden"; + + const visibleProps = properties.filter((prop) => !isHiddenProp(prop)); + // Check for advanced section grouping - const advancedProps = properties.filter( + const advancedProps = visibleProps.filter( (p) => p.content.props.uiSchema?.["ui:options"]?.advanced === true, ); - const regularProps = properties.filter( + const regularProps = visibleProps.filter( (p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true, ); const [showAdvanced, setShowAdvanced] = useState(false); + const toTitle = (value: string) => + value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()); + + const renderGroupedFields = (items: (typeof properties)[number][]) => { + if (!items.length) { + return null; + } + + const grouped = new Set(); + const groups = Object.entries(groupDefinitions) + .map(([groupKey, fields]) => { + const ordered = fields + .map((field) => items.find((item) => item.name === field)) + .filter(Boolean) as (typeof properties)[number][]; + + if (ordered.length === 0) { + return null; + } + + ordered.forEach((item) => grouped.add(item.name)); + + const label = t(`groups.${groupKey}`, { + defaultValue: toTitle(groupKey), + }); + + return { + key: groupKey, + label, + items: ordered, + }; + }) + .filter(Boolean) as Array<{ + key: string; + label: string; + items: (typeof properties)[number][]; + }>; + + const ungrouped = items.filter((item) => !grouped.has(item.name)); + + return ( +
+ {groups.map((group) => ( +
+
+ {group.label} +
+
+ {group.items.map((element) => ( +
{element.content}
+ ))} +
+
+ ))} + + {ungrouped.length > 0 && ( +
0 && "pt-2")}> + {ungrouped.map((element) => ( +
{element.content}
+ ))} +
+ )} +
+ ); + }; + // Root level renders children directly if (isRoot) { return (
- {regularProps.map((element) => ( -
{element.content}
- ))} + {renderGroupedFields(regularProps)} {advancedProps.length > 0 && ( @@ -48,9 +128,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { - {advancedProps.map((element) => ( -
{element.content}
- ))} + {renderGroupedFields(advancedProps)}
)} @@ -83,9 +161,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { - {regularProps.map((element) => ( -
{element.content}
- ))} + {renderGroupedFields(regularProps)} {advancedProps.length > 0 && ( @@ -104,9 +180,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { - {advancedProps.map((element) => ( -
{element.content}
- ))} + {renderGroupedFields(advancedProps)}
)} diff --git a/web/src/views/settings/GlobalConfigView.tsx b/web/src/views/settings/GlobalConfigView.tsx index 41e59c086..bbea60e91 100644 --- a/web/src/views/settings/GlobalConfigView.tsx +++ b/web/src/views/settings/GlobalConfigView.tsx @@ -66,6 +66,7 @@ const globalSectionConfigs: Record< fieldOrder?: string[]; hiddenFields?: string[]; advancedFields?: string[]; + liveValidate?: boolean; i18nNamespace: string; } > = { @@ -114,8 +115,8 @@ const globalSectionConfigs: Record< "trusted_proxies", "hash_iterations", "roles", - "admin_first_time_login", ], + hiddenFields: ["admin_first_time_login"], advancedFields: [ "cookie_name", "cookie_secure", @@ -148,6 +149,7 @@ const globalSectionConfigs: Record< "separator", ], advancedFields: ["header_map", "auth_secret", "separator"], + liveValidate: true, }, ui: { i18nNamespace: "config/ui", @@ -502,6 +504,7 @@ const GlobalConfigSection = memo(function GlobalConfigSection({ fieldOrder={sectionConfig.fieldOrder} hiddenFields={sectionConfig.hiddenFields} advancedFields={sectionConfig.advancedFields} + liveValidate={sectionConfig.liveValidate} showSubmit={false} i18nNamespace={sectionConfig.i18nNamespace} disabled={isSaving}