diff --git a/web/public/locales/en/config/detect.json b/web/public/locales/en/config/detect.json index 71f2c25d5..889ea1456 100644 --- a/web/public/locales/en/config/detect.json +++ b/web/public/locales/en/config/detect.json @@ -1,5 +1,5 @@ { - "label": "Object tracking", + "label": "Object Detection", "description": "Settings for the detection/detect role used to run object detection and initialize trackers.", "groups": { "resolution": "Resolution", diff --git a/web/src/components/config-form/sections/FfmpegSection.tsx b/web/src/components/config-form/sections/FfmpegSection.tsx index 0b78a0645..9cb3b1d10 100644 --- a/web/src/components/config-form/sections/FfmpegSection.tsx +++ b/web/src/components/config-form/sections/FfmpegSection.tsx @@ -38,6 +38,100 @@ export const FfmpegSection = createConfigSection({ "apple_compatibility", "gpu", ], + uiSchema: { + global_args: { + "ui:widget": "ArrayAsTextWidget", + "ui:options": { + suppressMultiSchema: true, + }, + }, + hwaccel_args: { + "ui:widget": "ArrayAsTextWidget", + "ui:options": { + suppressMultiSchema: true, + }, + }, + input_args: { + "ui:widget": "ArrayAsTextWidget", + "ui:options": { + suppressMultiSchema: true, + }, + }, + output_args: { + "ui:widget": "ArrayAsTextWidget", + "ui:options": { + suppressMultiSchema: true, + }, + detect: { + "ui:widget": "ArrayAsTextWidget", + "ui:options": { + suppressMultiSchema: true, + }, + }, + record: { + "ui:widget": "ArrayAsTextWidget", + "ui:options": { + suppressMultiSchema: true, + }, + }, + items: { + detect: { + "ui:widget": "ArrayAsTextWidget", + "ui:options": { + suppressMultiSchema: true, + }, + }, + record: { + "ui:widget": "ArrayAsTextWidget", + "ui:options": { + suppressMultiSchema: true, + }, + }, + }, + }, + inputs: { + items: { + global_args: { + "ui:widget": "ArrayAsTextWidget", + "ui:options": { + suppressMultiSchema: true, + }, + }, + hwaccel_args: { + "ui:widget": "ArrayAsTextWidget", + "ui:options": { + suppressMultiSchema: true, + }, + }, + input_args: { + "ui:widget": "ArrayAsTextWidget", + "ui:options": { + suppressMultiSchema: true, + }, + }, + output_args: { + "ui:widget": "ArrayAsTextWidget", + "ui:options": { + suppressMultiSchema: true, + }, + items: { + detect: { + "ui:widget": "ArrayAsTextWidget", + "ui:options": { + suppressMultiSchema: true, + }, + }, + record: { + "ui:widget": "ArrayAsTextWidget", + "ui:options": { + suppressMultiSchema: true, + }, + }, + }, + }, + }, + }, + }, }, }); diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index a9691039c..61bbea061 100644 --- a/web/src/components/config-form/theme/frigateTheme.ts +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -8,7 +8,6 @@ import type { RegistryFieldsType, TemplatesType, } from "@rjsf/utils"; -import { getDefaultRegistry } from "@rjsf/core"; import { SwitchWidget } from "./widgets/SwitchWidget"; import { SelectWidget } from "./widgets/SelectWidget"; @@ -27,6 +26,7 @@ import { ArrayAsTextWidget } from "./widgets/ArrayAsTextWidget"; import { FieldTemplate } from "./templates/FieldTemplate"; import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate"; import { ArrayFieldTemplate } from "./templates/ArrayFieldTemplate"; +import { ArrayFieldItemTemplate } from "./templates/ArrayFieldItemTemplate"; import { BaseInputTemplate } from "./templates/BaseInputTemplate"; import { DescriptionFieldTemplate } from "./templates/DescriptionFieldTemplate"; import { TitleFieldTemplate } from "./templates/TitleFieldTemplate"; @@ -39,11 +39,8 @@ export interface FrigateTheme { fields: RegistryFieldsType; } -const defaultRegistry = getDefaultRegistry(); - export const frigateTheme: FrigateTheme = { widgets: { - ...defaultRegistry.widgets, // Override default widgets with shadcn/ui styled versions TextWidget: TextWidget, PasswordWidget: PasswordWidget, @@ -64,20 +61,15 @@ export const frigateTheme: FrigateTheme = { zoneNames: ZoneSwitchesWidget, }, templates: { - ...defaultRegistry.templates, FieldTemplate: FieldTemplate as React.ComponentType, ObjectFieldTemplate: ObjectFieldTemplate, ArrayFieldTemplate: ArrayFieldTemplate, + ArrayFieldItemTemplate: ArrayFieldItemTemplate, BaseInputTemplate: BaseInputTemplate as React.ComponentType, DescriptionFieldTemplate: DescriptionFieldTemplate, TitleFieldTemplate: TitleFieldTemplate, ErrorListTemplate: ErrorListTemplate, MultiSchemaFieldTemplate: MultiSchemaFieldTemplate, - ButtonTemplates: { - ...defaultRegistry.templates.ButtonTemplates, - }, - }, - fields: { - ...defaultRegistry.fields, }, + fields: {}, }; diff --git a/web/src/components/config-form/theme/templates/ArrayFieldItemTemplate.tsx b/web/src/components/config-form/theme/templates/ArrayFieldItemTemplate.tsx new file mode 100644 index 000000000..ab2f3b272 --- /dev/null +++ b/web/src/components/config-form/theme/templates/ArrayFieldItemTemplate.tsx @@ -0,0 +1,58 @@ +import type { + ArrayFieldItemTemplateProps, + FormContextType, + RJSFSchema, + StrictRJSFSchema, +} from "@rjsf/utils"; +import { getTemplate, getUiOptions } from "@rjsf/utils"; + +/** + * Custom ArrayFieldItemTemplate to ensure array item content uses full width + * while keeping action buttons aligned to the right. + */ +export function ArrayFieldItemTemplate< + T = unknown, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = FormContextType, +>(props: ArrayFieldItemTemplateProps) { + const { + children, + buttonsProps, + displayLabel, + hasDescription, + hasToolbar, + uiSchema, + registry, + } = props; + + const uiOptions = getUiOptions(uiSchema); + const ArrayFieldItemButtonsTemplate = getTemplate< + "ArrayFieldItemButtonsTemplate", + T, + S, + F + >("ArrayFieldItemButtonsTemplate", registry, uiOptions); + + const margin = hasDescription ? -6 : 22; + + return ( +
+
+
{children}
+ {hasToolbar && ( +
+ +
+ )} +
+
+ ); +} + +export default ArrayFieldItemTemplate; diff --git a/web/src/components/config-form/theme/templates/ArrayFieldTemplate.tsx b/web/src/components/config-form/theme/templates/ArrayFieldTemplate.tsx index 8b40f9349..4c5c4abb4 100644 --- a/web/src/components/config-form/theme/templates/ArrayFieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/ArrayFieldTemplate.tsx @@ -33,7 +33,7 @@ export function ArrayFieldTemplate(props: ArrayFieldTemplateProps) {
diff --git a/web/src/components/config-form/theme/templates/FieldTemplate.tsx b/web/src/components/config-form/theme/templates/FieldTemplate.tsx index 0239e5100..4270374a6 100644 --- a/web/src/components/config-form/theme/templates/FieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/FieldTemplate.tsx @@ -43,11 +43,11 @@ export function FieldTemplate(props: FieldTemplateProps) { // Get UI options const uiOptions = uiSchema?.["ui:options"] || {}; + + // Determine field characteristics const isAdvanced = uiOptions.advanced === true; - - // Boolean fields (switches) render label inline const isBoolean = schema.type === "boolean"; - + const isObjectField = schema.type === "object"; const isNullableUnion = isNullableUnionSchema(schema as StrictRJSFSchema); const suppressMultiSchema = (uiSchema?.["ui:options"] as Record | undefined) @@ -144,7 +144,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
) : ( <> - {finalDescription && !isMultiSchemaWrapper && ( + {finalDescription && !isMultiSchemaWrapper && !isObjectField && (

{finalDescription}

)} {children} diff --git a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx index 717f8e809..4d504d4ef 100644 --- a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx @@ -1,4 +1,4 @@ -// Object Field Template - renders nested object fields +// Object Field Template - renders nested object fields with i18n support import type { ObjectFieldTemplateProps } from "@rjsf/utils"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { @@ -14,21 +14,15 @@ import { cn } from "@/lib/utils"; export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { const { title, description, properties, uiSchema, registry, schema } = props; - const { idSchema } = props as ObjectFieldTemplateProps & { - idSchema?: { $id?: string }; - }; - const formContext = (props as Record).formContext as - | Record - | undefined; + type FormContext = { i18nNamespace?: string }; + const formContext = registry?.formContext as FormContext | undefined; // Check if this is a root-level object - const isRoot = idSchema?.$id === "root" || registry?.rootSchema === schema; + const isRoot = registry?.rootSchema === schema; const [isOpen, setIsOpen] = useState(true); - const { t } = useTranslation([ - (formContext?.i18nNamespace as string | undefined) || "common", - ]); + const { t } = useTranslation([formContext?.i18nNamespace || "common"]); const groupDefinitions = (uiSchema?.["ui:groups"] as Record | undefined) || {}; @@ -51,6 +45,52 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { const toTitle = (value: string) => value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()); + // Get the property name from the field path (e.g., "alerts" from path) + const fieldPathId = ( + props as { fieldPathId?: { path?: (string | number)[] } } + ).fieldPathId; + let propertyName: string | undefined; + const path = fieldPathId?.path; + if (path) { + for (let i = path.length - 1; i >= 0; i -= 1) { + const segment = path[i]; + if (typeof segment === "string") { + propertyName = segment; + break; + } + } + } + + // Try i18n translation, fall back to schema or original values + const i18nNs = formContext?.i18nNamespace; + + let inferredLabel: string | undefined; + if (i18nNs && propertyName) { + const translated = t(`${propertyName}.label`, { + ns: i18nNs, + defaultValue: "", + }); + inferredLabel = translated || undefined; + } + const schemaTitle = schema?.title; + const fallbackLabel = + title || schemaTitle || (propertyName ? toTitle(propertyName) : undefined); + inferredLabel = inferredLabel ?? fallbackLabel; + + let inferredDescription: string | undefined; + if (i18nNs && propertyName) { + const translated = t(`${propertyName}.description`, { + ns: i18nNs, + defaultValue: "", + }); + inferredDescription = translated || undefined; + } + const schemaDescription = schema?.description; + const fallbackDescription = + (typeof description === "string" ? description : undefined) || + schemaDescription; + inferredDescription = inferredDescription ?? fallbackDescription; + const renderGroupedFields = (items: (typeof properties)[number][]) => { if (!items.length) { return null; @@ -142,16 +182,16 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { // Nested objects render as collapsible cards return ( - +
- {title} - {description && ( -

- {description} + {inferredLabel} + {inferredDescription && ( +

+ {inferredDescription}

)}
diff --git a/web/src/components/config-form/theme/widgets/ArrayAsTextWidget.tsx b/web/src/components/config-form/theme/widgets/ArrayAsTextWidget.tsx index 618047f48..7e8ab9c57 100644 --- a/web/src/components/config-form/theme/widgets/ArrayAsTextWidget.tsx +++ b/web/src/components/config-form/theme/widgets/ArrayAsTextWidget.tsx @@ -6,9 +6,13 @@ import { useCallback } from "react"; export function ArrayAsTextWidget(props: WidgetProps) { const { value, onChange, disabled, readonly, placeholder } = props; - // Convert array to space-separated string - const textValue = - Array.isArray(value) && value.length > 0 ? value.join(" ") : ""; + // Convert array or string to text + let textValue = ""; + if (typeof value === "string" && value.length > 0) { + textValue = value; + } else if (Array.isArray(value) && value.length > 0) { + textValue = value.join(" "); + } const handleChange = useCallback( (event: React.ChangeEvent) => { diff --git a/web/src/views/settings/CameraConfigView.tsx b/web/src/views/settings/CameraConfigView.tsx index 9591269e7..b770d02a6 100644 --- a/web/src/views/settings/CameraConfigView.tsx +++ b/web/src/views/settings/CameraConfigView.tsx @@ -325,7 +325,7 @@ const CameraConfigContent = memo(function CameraConfigContent({ return (
{/* Section Navigation */} -