add section description from schema and clarify global vs camera level descriptions

This commit is contained in:
Josh Hawkins 2026-01-31 08:50:24 -06:00
parent 8b7156438e
commit 8f681d5689
16 changed files with 218 additions and 76 deletions

View File

@ -76,7 +76,7 @@ class CameraConfig(FrigateBaseModel):
audio: AudioConfig = Field( audio: AudioConfig = Field(
default_factory=AudioConfig, default_factory=AudioConfig,
title="Audio events", title="Audio events",
description="Settings for audio-based event detection; can be overridden per-camera.", description="Settings for audio-based event detection for this camera.",
) )
audio_transcription: CameraAudioTranscriptionConfig = Field( audio_transcription: CameraAudioTranscriptionConfig = Field(
default_factory=CameraAudioTranscriptionConfig, default_factory=CameraAudioTranscriptionConfig,
@ -96,7 +96,7 @@ class CameraConfig(FrigateBaseModel):
face_recognition: CameraFaceRecognitionConfig = Field( face_recognition: CameraFaceRecognitionConfig = Field(
default_factory=CameraFaceRecognitionConfig, default_factory=CameraFaceRecognitionConfig,
title="Face recognition", title="Face recognition",
description="Settings for face detection and recognition; can be overridden per-camera.", description="Settings for face detection and recognition for this camera.",
) )
ffmpeg: CameraFfmpegConfig = Field( ffmpeg: CameraFfmpegConfig = Field(
title="FFmpeg", title="FFmpeg",
@ -115,7 +115,7 @@ class CameraConfig(FrigateBaseModel):
motion: MotionConfig = Field( motion: MotionConfig = Field(
None, None,
title="Motion detection", title="Motion detection",
description="Default motion detection settings; can be overridden per-camera.", description="Default motion detection settings for this camera.",
) )
objects: ObjectConfig = Field( objects: ObjectConfig = Field(
default_factory=ObjectConfig, default_factory=ObjectConfig,
@ -125,12 +125,12 @@ class CameraConfig(FrigateBaseModel):
record: RecordConfig = Field( record: RecordConfig = Field(
default_factory=RecordConfig, default_factory=RecordConfig,
title="Recording", title="Recording",
description="Recording and retention settings; can be overridden per-camera.", description="Recording and retention settings for this camera.",
) )
review: ReviewConfig = Field( review: ReviewConfig = Field(
default_factory=ReviewConfig, default_factory=ReviewConfig,
title="Review", title="Review",
description="Settings that control alerts, detections, and GenAI review summaries used by the UI and storage; can be overridden per-camera.", description="Settings that control alerts, detections, and GenAI review summaries used by the UI and storage for this camera.",
) )
semantic_search: CameraSemanticSearchConfig = Field( semantic_search: CameraSemanticSearchConfig = Field(
default_factory=CameraSemanticSearchConfig, default_factory=CameraSemanticSearchConfig,
@ -140,7 +140,7 @@ class CameraConfig(FrigateBaseModel):
snapshots: SnapshotsConfig = Field( snapshots: SnapshotsConfig = Field(
default_factory=SnapshotsConfig, default_factory=SnapshotsConfig,
title="Snapshots", title="Snapshots",
description="Settings for saved JPEG snapshots of tracked objects; can be overridden per-camera.", description="Settings for saved JPEG snapshots of tracked objects for this camera.",
) )
timestamp_style: TimestampStyleConfig = Field( timestamp_style: TimestampStyleConfig = Field(
default_factory=TimestampStyleConfig, default_factory=TimestampStyleConfig,
@ -162,7 +162,7 @@ class CameraConfig(FrigateBaseModel):
notifications: NotificationConfig = Field( notifications: NotificationConfig = Field(
default_factory=NotificationConfig, default_factory=NotificationConfig,
title="Notifications", title="Notifications",
description="Settings to enable and control notifications; can be overridden per-camera.", description="Settings to enable and control notifications for this camera.",
) )
onvif: OnvifConfig = Field( onvif: OnvifConfig = Field(
default_factory=OnvifConfig, default_factory=OnvifConfig,

View File

@ -49,7 +49,7 @@ class FfmpegConfig(FrigateBaseModel):
path: str = Field( path: str = Field(
default="default", default="default",
title="FFmpeg path", title="FFmpeg path",
description='Path to the FFmpeg binary to use globally or a version alias ("5.0" or "7.0").', description='Path to the FFmpeg binary to use or a version alias ("5.0" or "7.0").',
) )
global_args: Union[str, list[str]] = Field( global_args: Union[str, list[str]] = Field(
default=FFMPEG_GLOBAL_ARGS_DEFAULT, default=FFMPEG_GLOBAL_ARGS_DEFAULT,

View File

@ -11,7 +11,7 @@ class MotionConfig(FrigateBaseModel):
enabled: bool = Field( enabled: bool = Field(
default=True, default=True,
title="Enable motion detection", title="Enable motion detection",
description="Enable or disable motion detection globally; per-camera settings can override this.", description="Enable or disable motion detection; can be overridden per-camera.",
) )
threshold: int = Field( threshold: int = Field(
default=30, default=30,

View File

@ -11,7 +11,7 @@ class NotificationConfig(FrigateBaseModel):
enabled: bool = Field( enabled: bool = Field(
default=False, default=False,
title="Enable notifications", title="Enable notifications",
description="Enable or disable notifications globally.", description="Enable or disable notifications; can be overridden per-camera.",
) )
email: Optional[str] = Field( email: Optional[str] = Field(
default=None, default=None,

View File

@ -132,7 +132,7 @@ class ObjectConfig(FrigateBaseModel):
track: list[str] = Field( track: list[str] = Field(
default=DEFAULT_TRACKED_OBJECTS, default=DEFAULT_TRACKED_OBJECTS,
title="Objects to track", title="Objects to track",
description="List of object labels to track globally; camera configs can override this.", description="List of object labels to track; can be overridden per-camera.",
) )
filters: dict[str, FilterConfig] = Field( filters: dict[str, FilterConfig] = Field(
default_factory=dict, default_factory=dict,

View File

@ -98,7 +98,7 @@ class RecordConfig(FrigateBaseModel):
enabled: bool = Field( enabled: bool = Field(
default=False, default=False,
title="Enable recording", title="Enable recording",
description="Enable or disable recording globally; individual cameras can override this.", description="Enable or disable recording; can be overridden per-camera.",
) )
expire_interval: int = Field( expire_interval: int = Field(
default=60, default=60,

View File

@ -30,7 +30,7 @@ class SnapshotsConfig(FrigateBaseModel):
enabled: bool = Field( enabled: bool = Field(
default=False, default=False,
title="Snapshots enabled", title="Snapshots enabled",
description="Enable or disable saving snapshots globally.", description="Enable or disable saving snapshots; can be overridden per-camera.",
) )
clean_copy: bool = Field( clean_copy: bool = Field(
default=True, default=True,

View File

@ -46,7 +46,7 @@ class AudioTranscriptionConfig(FrigateBaseModel):
enabled: bool = Field( enabled: bool = Field(
default=False, default=False,
title="Enable audio transcription", title="Enable audio transcription",
description="Enable or disable automatic audio transcription globally.", description="Enable or disable automatic audio transcription; can be overridden per-camera.",
) )
language: str = Field( language: str = Field(
default="en", default="en",
@ -240,7 +240,7 @@ class FaceRecognitionConfig(FrigateBaseModel):
enabled: bool = Field( enabled: bool = Field(
default=False, default=False,
title="Enable face recognition", title="Enable face recognition",
description="Enable or disable face recognition globally.", description="Enable or disable face recognition; can be overridden per-camera.",
) )
model_size: str = Field( model_size: str = Field(
default="small", default="small",
@ -322,12 +322,12 @@ class LicensePlateRecognitionConfig(FrigateBaseModel):
enabled: bool = Field( enabled: bool = Field(
default=False, default=False,
title="Enable LPR", title="Enable LPR",
description="Enable or disable LPR globally; camera-level settings can override.", description="Enable or disable license plate recognition; can be overridden per-camera.",
) )
model_size: str = Field( model_size: str = Field(
default="small", default="small",
title="Model size", title="Model size",
description="Model size used for text detection/recognition; small runs on CPU, large on GPU.", description="Model size used for text detection/recognition. Most users should use 'small'.",
) )
detection_threshold: float = Field( detection_threshold: float = Field(
default=0.7, default=0.7,

View File

@ -15,10 +15,10 @@
}, },
"audio": { "audio": {
"label": "Audio events", "label": "Audio events",
"description": "Settings for audio-based event detection; can be overridden per-camera.", "description": "Settings for audio-based event detection for this camera.",
"enabled": { "enabled": {
"label": "Enable audio detection", "label": "Enable audio detection",
"description": "Enable or disable audio event detection; can be overridden per-camera." "description": "Enable or disable audio event detection for this camera."
}, },
"max_not_heard": { "max_not_heard": {
"label": "End timeout", "label": "End timeout",
@ -138,7 +138,7 @@
}, },
"face_recognition": { "face_recognition": {
"label": "Face recognition", "label": "Face recognition",
"description": "Settings for face detection and recognition; can be overridden per-camera.", "description": "Settings for face detection and recognition for this camera.",
"enabled": { "enabled": {
"label": "Enable face recognition", "label": "Enable face recognition",
"description": "Enable or disable face recognition." "description": "Enable or disable face recognition."
@ -153,7 +153,7 @@
"description": "FFmpeg settings including binary path, args, hwaccel options, and per-role output args.", "description": "FFmpeg settings including binary path, args, hwaccel options, and per-role output args.",
"path": { "path": {
"label": "FFmpeg path", "label": "FFmpeg path",
"description": "Path to the FFmpeg binary to use globally or a version alias (\"5.0\" or \"7.0\")." "description": "Path to the FFmpeg binary to use for this camera or a version alias (\"5.0\" or \"7.0\")."
}, },
"global_args": { "global_args": {
"label": "FFmpeg global args", "label": "FFmpeg global args",
@ -254,10 +254,10 @@
}, },
"motion": { "motion": {
"label": "Motion detection", "label": "Motion detection",
"description": "Default motion detection settings; can be overridden per-camera.", "description": "Default motion detection settings for this camera.",
"enabled": { "enabled": {
"label": "Enable motion detection", "label": "Enable motion detection",
"description": "Enable or disable motion detection globally; per-camera settings can override this." "description": "Enable or disable motion detection for this camera."
}, },
"threshold": { "threshold": {
"label": "Motion threshold", "label": "Motion threshold",
@ -308,7 +308,7 @@
"description": "Object tracking defaults including which labels to track and per-object filters.", "description": "Object tracking defaults including which labels to track and per-object filters.",
"track": { "track": {
"label": "Objects to track", "label": "Objects to track",
"description": "List of object labels to track globally; camera configs can override this." "description": "List of object labels to track for this camera."
}, },
"filters": { "filters": {
"label": "Object filters", "label": "Object filters",
@ -400,10 +400,10 @@
}, },
"record": { "record": {
"label": "Recording", "label": "Recording",
"description": "Recording and retention settings; can be overridden per-camera.", "description": "Recording and retention settings for this camera.",
"enabled": { "enabled": {
"label": "Enable recording", "label": "Enable recording",
"description": "Enable or disable recording globally; individual cameras can override this." "description": "Enable or disable recording for this camera."
}, },
"expire_interval": { "expire_interval": {
"label": "Record cleanup interval", "label": "Record cleanup interval",
@ -496,7 +496,7 @@
}, },
"review": { "review": {
"label": "Review", "label": "Review",
"description": "Settings that control alerts, detections, and GenAI review summaries used by the UI and storage; can be overridden per-camera.", "description": "Settings that control alerts, detections, and GenAI review summaries used by the UI and storage for this camera.",
"alerts": { "alerts": {
"label": "Alerts config", "label": "Alerts config",
"description": "Settings for which tracked objects generate alerts and how alerts are retained.", "description": "Settings for which tracked objects generate alerts and how alerts are retained.",
@ -620,10 +620,10 @@
}, },
"snapshots": { "snapshots": {
"label": "Snapshots", "label": "Snapshots",
"description": "Settings for saved JPEG snapshots of tracked objects; can be overridden per-camera.", "description": "Settings for saved JPEG snapshots of tracked objects for this camera.",
"enabled": { "enabled": {
"label": "Snapshots enabled", "label": "Snapshots enabled",
"description": "Enable or disable saving snapshots globally." "description": "Enable or disable saving snapshots for this camera."
}, },
"clean_copy": { "clean_copy": {
"label": "Save clean copy", "label": "Save clean copy",
@ -744,10 +744,10 @@
}, },
"notifications": { "notifications": {
"label": "Notifications", "label": "Notifications",
"description": "Settings to enable and control notifications; can be overridden per-camera.", "description": "Settings to enable and control notifications for this camera.",
"enabled": { "enabled": {
"label": "Enable notifications", "label": "Enable notifications",
"description": "Enable or disable notifications globally." "description": "Enable or disable notifications for this camera."
}, },
"email": { "email": {
"label": "Notification email", "label": "Notification email",

View File

@ -130,7 +130,12 @@ export function createConfigSection({
defaultCollapsed = false, defaultCollapsed = false,
showTitle, showTitle,
}: BaseSectionProps) { }: BaseSectionProps) {
const { t } = useTranslation([i18nNamespace, "views/settings", "common"]); const { t, i18n } = useTranslation([
i18nNamespace,
"config/cameras",
"views/settings",
"common",
]);
const [isOpen, setIsOpen] = useState(!defaultCollapsed); const [isOpen, setIsOpen] = useState(!defaultCollapsed);
const [pendingData, setPendingData] = useState<ConfigSectionData | null>( const [pendingData, setPendingData] = useState<ConfigSectionData | null>(
null, null,
@ -455,13 +460,31 @@ export function createConfigSection({
return null; return null;
} }
// Get section title from config namespace // Get section title from config namespace. For camera-level sections we
const title = t("label", { // prefer the `config/cameras` namespace where keys are nested under the
ns: i18nNamespace, // section name (e.g., `audio.label`). Fall back to provided i18nNamespace.
defaultValue: const defaultTitle =
sectionPath.charAt(0).toUpperCase() + sectionPath.charAt(0).toUpperCase() +
sectionPath.slice(1).replace(/_/g, " "), sectionPath.slice(1).replace(/_/g, " ");
}); const title =
level === "camera"
? t(`${sectionPath}.label`, {
ns: "config/cameras",
defaultValue: defaultTitle,
})
: t("label", {
ns: i18nNamespace,
defaultValue: defaultTitle,
});
const sectionDescription =
level === "camera"
? i18n.exists(`${sectionPath}.description`, { ns: "config/cameras" })
? t(`${sectionPath}.description`, { ns: "config/cameras" })
: undefined
: i18n.exists("description", { ns: i18nNamespace })
? t("description", { ns: i18nNamespace })
: undefined;
const sectionContent = ( const sectionContent = (
<div className="space-y-6"> <div className="space-y-6">
@ -491,6 +514,9 @@ export function createConfigSection({
? config?.cameras?.[cameraName] ? config?.cameras?.[cameraName]
: undefined, : undefined,
fullConfig: config, fullConfig: config,
// When rendering camera-level sections, provide the section path so
// field templates can look up keys under the `config/cameras` namespace
sectionI18nPrefix: level === "camera" ? sectionPath : undefined,
t, t,
}} }}
/> />
@ -597,21 +623,30 @@ export function createConfigSection({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{shouldShowTitle && ( {shouldShowTitle && (
<div className="flex items-center justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-3"> <div className="flex flex-col gap-1">
<Heading as="h4">{title}</Heading> <div className="flex items-center gap-3">
{showOverrideIndicator && level === "camera" && isOverridden && ( <Heading as="h4">{title}</Heading>
<Badge variant="secondary" className="text-xs"> {showOverrideIndicator &&
{t("overridden", { level === "camera" &&
ns: "common", isOverridden && (
defaultValue: "Overridden", <Badge variant="secondary" className="text-xs">
})} {t("overridden", {
</Badge> ns: "common",
)} defaultValue: "Overridden",
{hasChanges && ( })}
<Badge variant="outline" className="text-xs"> </Badge>
{t("modified", { ns: "common", defaultValue: "Modified" })} )}
</Badge> {hasChanges && (
<Badge variant="outline" className="text-xs">
{t("modified", { ns: "common", defaultValue: "Modified" })}
</Badge>
)}
</div>
{sectionDescription && (
<p className="text-sm text-muted-foreground">
{sectionDescription}
</p>
)} )}
</div> </div>
{level === "camera" && isOverridden && ( {level === "camera" && isOverridden && (

View File

@ -1,16 +1,40 @@
// Description Field Template // Description Field Template
import type { DescriptionFieldProps } from "@rjsf/utils"; import type { DescriptionFieldProps } from "@rjsf/utils";
import { useTranslation } from "react-i18next";
import { ConfigFormContext } from "@/types/configForm";
export function DescriptionFieldTemplate(props: DescriptionFieldProps) { export function DescriptionFieldTemplate(props: DescriptionFieldProps) {
const { description, id } = props; const { description, id } = props;
const formContext = (
props as { registry?: { formContext?: ConfigFormContext } }
).registry?.formContext;
if (!description) { const isCameraLevel = formContext?.level === "camera";
const sectionI18nPrefix = formContext?.sectionI18nPrefix;
const i18nNamespace = formContext?.i18nNamespace;
const effectiveNamespace = isCameraLevel ? "config/cameras" : i18nNamespace;
const { t, i18n } = useTranslation([
effectiveNamespace || i18nNamespace || "common",
i18nNamespace || "common",
]);
let resolvedDescription = description;
if (isCameraLevel && sectionI18nPrefix && effectiveNamespace) {
const descriptionKey = `${sectionI18nPrefix}.description`;
if (i18n.exists(descriptionKey, { ns: effectiveNamespace })) {
resolvedDescription = t(descriptionKey, { ns: effectiveNamespace });
}
}
if (!resolvedDescription) {
return null; return null;
} }
return ( return (
<p id={id} className="text-sm text-muted-foreground"> <p id={id} className="text-sm text-muted-foreground">
{description} {resolvedDescription}
</p> </p>
); );
} }

View File

@ -81,7 +81,13 @@ export function FieldTemplate(props: FieldTemplateProps) {
// Get i18n namespace from form context (passed through registry) // Get i18n namespace from form context (passed through registry)
const formContext = registry?.formContext as ConfigFormContext | undefined; const formContext = registry?.formContext as ConfigFormContext | undefined;
const i18nNamespace = formContext?.i18nNamespace as string | undefined; const i18nNamespace = formContext?.i18nNamespace as string | undefined;
const sectionI18nPrefix = formContext?.sectionI18nPrefix as
| string
| undefined;
const isCameraLevel = formContext?.level === "camera";
const effectiveNamespace = isCameraLevel ? "config/cameras" : i18nNamespace;
const { t, i18n } = useTranslation([ const { t, i18n } = useTranslation([
effectiveNamespace || i18nNamespace || "common",
i18nNamespace || "common", i18nNamespace || "common",
"views/settings", "views/settings",
]); ]);
@ -126,10 +132,21 @@ export function FieldTemplate(props: FieldTemplateProps) {
// Try to get translated label, falling back to schema title, then RJSF label // Try to get translated label, falling back to schema title, then RJSF label
let finalLabel = label; let finalLabel = label;
if (i18nNamespace && translationPath) { if (effectiveNamespace && translationPath) {
// Prefer camera-scoped translations when a section prefix is provided
const prefixedTranslationKey =
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${translationPath}.label`
: undefined;
const translationKey = `${translationPath}.label`; const translationKey = `${translationPath}.label`;
if (i18n.exists(translationKey, { ns: i18nNamespace })) {
finalLabel = t(translationKey, { ns: i18nNamespace }); if (
prefixedTranslationKey &&
i18n.exists(prefixedTranslationKey, { ns: effectiveNamespace })
) {
finalLabel = t(prefixedTranslationKey, { ns: effectiveNamespace });
} else if (i18n.exists(translationKey, { ns: effectiveNamespace })) {
finalLabel = t(translationKey, { ns: effectiveNamespace });
} else if (schemaTitle) { } else if (schemaTitle) {
finalLabel = schemaTitle; finalLabel = schemaTitle;
} else if (translatedFilterObjectLabel) { } else if (translatedFilterObjectLabel) {
@ -145,11 +162,25 @@ export function FieldTemplate(props: FieldTemplateProps) {
let fieldLabel = schemaTitle; let fieldLabel = schemaTitle;
if (!fieldLabel) { if (!fieldLabel) {
const fieldTranslationKey = `${fieldName}.label`; const fieldTranslationKey = `${fieldName}.label`;
const prefixedFieldTranslationKey =
sectionI18nPrefix &&
!fieldTranslationKey.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${fieldTranslationKey}`
: undefined;
if ( if (
i18nNamespace && prefixedFieldTranslationKey &&
i18n.exists(fieldTranslationKey, { ns: i18nNamespace }) effectiveNamespace &&
i18n.exists(prefixedFieldTranslationKey, { ns: effectiveNamespace })
) { ) {
fieldLabel = t(fieldTranslationKey, { ns: i18nNamespace }); fieldLabel = t(prefixedFieldTranslationKey, {
ns: effectiveNamespace,
});
} else if (
effectiveNamespace &&
i18n.exists(fieldTranslationKey, { ns: effectiveNamespace })
) {
fieldLabel = t(fieldTranslationKey, { ns: effectiveNamespace });
} else { } else {
fieldLabel = humanizeKey(fieldName); fieldLabel = humanizeKey(fieldName);
} }
@ -177,11 +208,25 @@ export function FieldTemplate(props: FieldTemplateProps) {
let fieldLabel = schemaTitle; let fieldLabel = schemaTitle;
if (!fieldLabel) { if (!fieldLabel) {
const fieldTranslationKey = `${fieldName}.label`; const fieldTranslationKey = `${fieldName}.label`;
const prefixedFieldTranslationKey =
sectionI18nPrefix &&
!fieldTranslationKey.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${fieldTranslationKey}`
: undefined;
if ( if (
i18nNamespace && prefixedFieldTranslationKey &&
i18n.exists(fieldTranslationKey, { ns: i18nNamespace }) effectiveNamespace &&
i18n.exists(prefixedFieldTranslationKey, { ns: effectiveNamespace })
) { ) {
fieldLabel = t(fieldTranslationKey, { ns: i18nNamespace }); fieldLabel = t(prefixedFieldTranslationKey, {
ns: effectiveNamespace,
});
} else if (
effectiveNamespace &&
i18n.exists(fieldTranslationKey, { ns: effectiveNamespace })
) {
fieldLabel = t(fieldTranslationKey, { ns: effectiveNamespace });
} else { } else {
fieldLabel = humanizeKey(fieldName); fieldLabel = humanizeKey(fieldName);
} }
@ -198,10 +243,19 @@ export function FieldTemplate(props: FieldTemplateProps) {
// Try to get translated description, falling back to schema description // Try to get translated description, falling back to schema description
let finalDescription = description || ""; let finalDescription = description || "";
if (i18nNamespace && translationPath) { if (effectiveNamespace && translationPath) {
const prefixedDescriptionKey =
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${translationPath}.description`
: undefined;
const descriptionKey = `${translationPath}.description`; const descriptionKey = `${translationPath}.description`;
if (i18n.exists(descriptionKey, { ns: i18nNamespace })) { if (
finalDescription = t(descriptionKey, { ns: i18nNamespace }); prefixedDescriptionKey &&
i18n.exists(prefixedDescriptionKey, { ns: effectiveNamespace })
) {
finalDescription = t(prefixedDescriptionKey, { ns: effectiveNamespace });
} else if (i18n.exists(descriptionKey, { ns: effectiveNamespace })) {
finalDescription = t(descriptionKey, { ns: effectiveNamespace });
} else if (schemaDescription) { } else if (schemaDescription) {
finalDescription = schemaDescription; finalDescription = schemaDescription;
} }

View File

@ -68,7 +68,14 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const [isOpen, setIsOpen] = useState(true); const [isOpen, setIsOpen] = useState(true);
const isCameraLevel = formContext?.level === "camera";
const effectiveNamespace = isCameraLevel
? "config/cameras"
: formContext?.i18nNamespace;
const sectionI18nPrefix = formContext?.sectionI18nPrefix;
const { t, i18n } = useTranslation([ const { t, i18n } = useTranslation([
effectiveNamespace || formContext?.i18nNamespace || "common",
formContext?.i18nNamespace || "common", formContext?.i18nNamespace || "common",
"config/groups", "config/groups",
"common", "common",
@ -127,12 +134,18 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
} }
// Try i18n translation, fall back to schema or original values // Try i18n translation, fall back to schema or original values
const i18nNs = formContext?.i18nNamespace; const i18nNs = effectiveNamespace;
let inferredLabel: string | undefined; let inferredLabel: string | undefined;
if (i18nNs && translationPath) { if (i18nNs && translationPath) {
const prefixedLabelKey =
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${translationPath}.label`
: undefined;
const labelKey = `${translationPath}.label`; const labelKey = `${translationPath}.label`;
if (i18n.exists(labelKey, { ns: i18nNs })) { if (prefixedLabelKey && i18n.exists(prefixedLabelKey, { ns: i18nNs })) {
inferredLabel = t(prefixedLabelKey, { ns: i18nNs });
} else if (i18n.exists(labelKey, { ns: i18nNs })) {
inferredLabel = t(labelKey, { ns: i18nNs }); inferredLabel = t(labelKey, { ns: i18nNs });
} }
} }
@ -146,8 +159,17 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
let inferredDescription: string | undefined; let inferredDescription: string | undefined;
if (i18nNs && translationPath) { if (i18nNs && translationPath) {
const prefixedDescriptionKey =
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${translationPath}.description`
: undefined;
const descriptionKey = `${translationPath}.description`; const descriptionKey = `${translationPath}.description`;
if (i18n.exists(descriptionKey, { ns: i18nNs })) { if (
prefixedDescriptionKey &&
i18n.exists(prefixedDescriptionKey, { ns: i18nNs })
) {
inferredDescription = t(prefixedDescriptionKey, { ns: i18nNs });
} else if (i18n.exists(descriptionKey, { ns: i18nNs })) {
inferredDescription = t(descriptionKey, { ns: i18nNs }); inferredDescription = t(descriptionKey, { ns: i18nNs });
} }
} }

View File

@ -20,5 +20,6 @@ export type ConfigFormContext = {
fullCameraConfig?: CameraConfig; fullCameraConfig?: CameraConfig;
fullConfig?: FrigateConfig; fullConfig?: FrigateConfig;
i18nNamespace?: string; i18nNamespace?: string;
sectionI18nPrefix?: string;
t?: (key: string, options?: Record<string, unknown>) => string; t?: (key: string, options?: Record<string, unknown>) => string;
}; };

View File

@ -53,6 +53,7 @@ i18n
"views/exports", "views/exports",
"views/explore", "views/explore",
// Config section translations // Config section translations
"config/cameras",
"config/detect", "config/detect",
"config/record", "config/record",
"config/snapshots", "config/snapshots",

View File

@ -187,6 +187,7 @@ const CameraConfigContent = memo(function CameraConfigContent({
"config/objects", "config/objects",
"config/review", "config/review",
"config/audio", "config/audio",
"config/cameras",
"config/audio_transcription", "config/audio_transcription",
"config/birdseye", "config/birdseye",
"config/camera_mqtt", "config/camera_mqtt",
@ -322,11 +323,15 @@ const CameraConfigContent = memo(function CameraConfigContent({
<ul className="space-y-1"> <ul className="space-y-1">
{sections.map((section) => { {sections.map((section) => {
const isOverridden = overriddenSections.includes(section.key); const isOverridden = overriddenSections.includes(section.key);
const sectionLabel = t("label", { const defaultSectionLabel =
ns: section.i18nNamespace, section.key.charAt(0).toUpperCase() +
defaultValue: section.key.slice(1).replace(/_/g, " ");
section.key.charAt(0).toUpperCase() + const sectionLabel = t(`${section.key}.label`, {
section.key.slice(1).replace(/_/g, " "), ns: "config/cameras",
defaultValue: t("label", {
ns: section.i18nNamespace,
defaultValue: defaultSectionLabel,
}),
}); });
return ( return (