mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-29 15:19:00 +03:00
add section description from schema and clarify global vs camera level descriptions
This commit is contained in:
@@ -130,7 +130,12 @@ export function createConfigSection({
|
||||
defaultCollapsed = false,
|
||||
showTitle,
|
||||
}: BaseSectionProps) {
|
||||
const { t } = useTranslation([i18nNamespace, "views/settings", "common"]);
|
||||
const { t, i18n } = useTranslation([
|
||||
i18nNamespace,
|
||||
"config/cameras",
|
||||
"views/settings",
|
||||
"common",
|
||||
]);
|
||||
const [isOpen, setIsOpen] = useState(!defaultCollapsed);
|
||||
const [pendingData, setPendingData] = useState<ConfigSectionData | null>(
|
||||
null,
|
||||
@@ -455,13 +460,31 @@ export function createConfigSection({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get section title from config namespace
|
||||
const title = t("label", {
|
||||
ns: i18nNamespace,
|
||||
defaultValue:
|
||||
sectionPath.charAt(0).toUpperCase() +
|
||||
sectionPath.slice(1).replace(/_/g, " "),
|
||||
});
|
||||
// Get section title from config namespace. For camera-level sections we
|
||||
// prefer the `config/cameras` namespace where keys are nested under the
|
||||
// section name (e.g., `audio.label`). Fall back to provided i18nNamespace.
|
||||
const defaultTitle =
|
||||
sectionPath.charAt(0).toUpperCase() +
|
||||
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 = (
|
||||
<div className="space-y-6">
|
||||
@@ -491,6 +514,9 @@ export function createConfigSection({
|
||||
? config?.cameras?.[cameraName]
|
||||
: undefined,
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
@@ -597,21 +623,30 @@ export function createConfigSection({
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{shouldShowTitle && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Heading as="h4">{title}</Heading>
|
||||
{showOverrideIndicator && level === "camera" && isOverridden && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("overridden", {
|
||||
ns: "common",
|
||||
defaultValue: "Overridden",
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("modified", { ns: "common", defaultValue: "Modified" })}
|
||||
</Badge>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<Heading as="h4">{title}</Heading>
|
||||
{showOverrideIndicator &&
|
||||
level === "camera" &&
|
||||
isOverridden && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("overridden", {
|
||||
ns: "common",
|
||||
defaultValue: "Overridden",
|
||||
})}
|
||||
</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>
|
||||
{level === "camera" && isOverridden && (
|
||||
|
||||
@@ -1,16 +1,40 @@
|
||||
// Description Field Template
|
||||
import type { DescriptionFieldProps } from "@rjsf/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfigFormContext } from "@/types/configForm";
|
||||
|
||||
export function DescriptionFieldTemplate(props: DescriptionFieldProps) {
|
||||
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 (
|
||||
<p id={id} className="text-sm text-muted-foreground">
|
||||
{description}
|
||||
{resolvedDescription}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,13 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
// Get i18n namespace from form context (passed through registry)
|
||||
const formContext = registry?.formContext as ConfigFormContext | 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([
|
||||
effectiveNamespace || i18nNamespace || "common",
|
||||
i18nNamespace || "common",
|
||||
"views/settings",
|
||||
]);
|
||||
@@ -126,10 +132,21 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
|
||||
// Try to get translated label, falling back to schema title, then RJSF 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`;
|
||||
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) {
|
||||
finalLabel = schemaTitle;
|
||||
} else if (translatedFilterObjectLabel) {
|
||||
@@ -145,11 +162,25 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
let fieldLabel = schemaTitle;
|
||||
if (!fieldLabel) {
|
||||
const fieldTranslationKey = `${fieldName}.label`;
|
||||
const prefixedFieldTranslationKey =
|
||||
sectionI18nPrefix &&
|
||||
!fieldTranslationKey.startsWith(`${sectionI18nPrefix}.`)
|
||||
? `${sectionI18nPrefix}.${fieldTranslationKey}`
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
i18nNamespace &&
|
||||
i18n.exists(fieldTranslationKey, { ns: i18nNamespace })
|
||||
prefixedFieldTranslationKey &&
|
||||
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 {
|
||||
fieldLabel = humanizeKey(fieldName);
|
||||
}
|
||||
@@ -177,11 +208,25 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
let fieldLabel = schemaTitle;
|
||||
if (!fieldLabel) {
|
||||
const fieldTranslationKey = `${fieldName}.label`;
|
||||
const prefixedFieldTranslationKey =
|
||||
sectionI18nPrefix &&
|
||||
!fieldTranslationKey.startsWith(`${sectionI18nPrefix}.`)
|
||||
? `${sectionI18nPrefix}.${fieldTranslationKey}`
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
i18nNamespace &&
|
||||
i18n.exists(fieldTranslationKey, { ns: i18nNamespace })
|
||||
prefixedFieldTranslationKey &&
|
||||
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 {
|
||||
fieldLabel = humanizeKey(fieldName);
|
||||
}
|
||||
@@ -198,10 +243,19 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
|
||||
// Try to get translated description, falling back to schema description
|
||||
let finalDescription = description || "";
|
||||
if (i18nNamespace && translationPath) {
|
||||
if (effectiveNamespace && translationPath) {
|
||||
const prefixedDescriptionKey =
|
||||
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
|
||||
? `${sectionI18nPrefix}.${translationPath}.description`
|
||||
: undefined;
|
||||
const descriptionKey = `${translationPath}.description`;
|
||||
if (i18n.exists(descriptionKey, { ns: i18nNamespace })) {
|
||||
finalDescription = t(descriptionKey, { ns: i18nNamespace });
|
||||
if (
|
||||
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) {
|
||||
finalDescription = schemaDescription;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,14 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
|
||||
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([
|
||||
effectiveNamespace || formContext?.i18nNamespace || "common",
|
||||
formContext?.i18nNamespace || "common",
|
||||
"config/groups",
|
||||
"common",
|
||||
@@ -127,12 +134,18 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
}
|
||||
|
||||
// Try i18n translation, fall back to schema or original values
|
||||
const i18nNs = formContext?.i18nNamespace;
|
||||
const i18nNs = effectiveNamespace;
|
||||
|
||||
let inferredLabel: string | undefined;
|
||||
if (i18nNs && translationPath) {
|
||||
const prefixedLabelKey =
|
||||
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
|
||||
? `${sectionI18nPrefix}.${translationPath}.label`
|
||||
: undefined;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -146,8 +159,17 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
|
||||
let inferredDescription: string | undefined;
|
||||
if (i18nNs && translationPath) {
|
||||
const prefixedDescriptionKey =
|
||||
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
|
||||
? `${sectionI18nPrefix}.${translationPath}.description`
|
||||
: undefined;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,5 +20,6 @@ export type ConfigFormContext = {
|
||||
fullCameraConfig?: CameraConfig;
|
||||
fullConfig?: FrigateConfig;
|
||||
i18nNamespace?: string;
|
||||
sectionI18nPrefix?: string;
|
||||
t?: (key: string, options?: Record<string, unknown>) => string;
|
||||
};
|
||||
|
||||
@@ -53,6 +53,7 @@ i18n
|
||||
"views/exports",
|
||||
"views/explore",
|
||||
// Config section translations
|
||||
"config/cameras",
|
||||
"config/detect",
|
||||
"config/record",
|
||||
"config/snapshots",
|
||||
|
||||
@@ -187,6 +187,7 @@ const CameraConfigContent = memo(function CameraConfigContent({
|
||||
"config/objects",
|
||||
"config/review",
|
||||
"config/audio",
|
||||
"config/cameras",
|
||||
"config/audio_transcription",
|
||||
"config/birdseye",
|
||||
"config/camera_mqtt",
|
||||
@@ -322,11 +323,15 @@ const CameraConfigContent = memo(function CameraConfigContent({
|
||||
<ul className="space-y-1">
|
||||
{sections.map((section) => {
|
||||
const isOverridden = overriddenSections.includes(section.key);
|
||||
const sectionLabel = t("label", {
|
||||
ns: section.i18nNamespace,
|
||||
defaultValue:
|
||||
section.key.charAt(0).toUpperCase() +
|
||||
section.key.slice(1).replace(/_/g, " "),
|
||||
const defaultSectionLabel =
|
||||
section.key.charAt(0).toUpperCase() +
|
||||
section.key.slice(1).replace(/_/g, " ");
|
||||
const sectionLabel = t(`${section.key}.label`, {
|
||||
ns: "config/cameras",
|
||||
defaultValue: t("label", {
|
||||
ns: section.i18nNamespace,
|
||||
defaultValue: defaultSectionLabel,
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user