From 737de2f53a6b022235c8a31c5f945928791675cf Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 23 Jan 2026 09:58:40 -0600 Subject: [PATCH] configure for full i18n support --- .../theme/templates/FieldTemplate.tsx | 83 ++- .../templates/MultiSchemaFieldTemplate.tsx | 1 - web/src/lib/config-schema/transformer.ts | 2 + web/src/utils/i18n.ts | 20 + web/src/views/settings/CameraConfigView.tsx | 172 +++--- web/src/views/settings/GlobalConfigView.tsx | 497 ++++++++++++------ 6 files changed, 552 insertions(+), 223 deletions(-) diff --git a/web/src/components/config-form/theme/templates/FieldTemplate.tsx b/web/src/components/config-form/theme/templates/FieldTemplate.tsx index d404ce73c..9f4f06e0d 100644 --- a/web/src/components/config-form/theme/templates/FieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/FieldTemplate.tsx @@ -2,6 +2,15 @@ import type { FieldTemplateProps } from "@rjsf/utils"; import { Label } from "@/components/ui/label"; import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; + +/** + * Build the i18n translation key path for nested fields using the field path + * provided by RJSF. This avoids ambiguity with underscores in field names. + */ +function buildTranslationPath(path: Array): string { + return path.filter((segment) => typeof segment === "string").join("."); +} export function FieldTemplate(props: FieldTemplateProps) { const { @@ -16,8 +25,17 @@ export function FieldTemplate(props: FieldTemplateProps) { displayLabel, schema, uiSchema, + registry, + fieldPathId, } = props; + // Get i18n namespace from form context (passed through registry) + const formContext = registry?.formContext as + | Record + | undefined; + const i18nNamespace = formContext?.i18nNamespace as string | undefined; + const { t } = useTranslation([i18nNamespace || "common"]); + if (hidden) { return
{children}
; } @@ -29,6 +47,51 @@ export function FieldTemplate(props: FieldTemplateProps) { // Boolean fields (switches) render label inline const isBoolean = schema.type === "boolean"; + // Get translation path for this field + const translationPath = buildTranslationPath(fieldPathId.path); + + // Use schema title/description as primary source (from JSON Schema) + const schemaTitle = (schema as Record).title as + | string + | undefined; + const schemaDescription = (schema as Record).description as + | string + | undefined; + + // Try to get translated label, falling back to schema title, then RJSF label + let finalLabel = label; + if (i18nNamespace && translationPath) { + const translationKey = `${translationPath}.label`; + const translatedLabel = t(translationKey, { + ns: i18nNamespace, + defaultValue: "", + }); + // Only use translation if it's not the key itself (which means translation exists) + if (translatedLabel && translatedLabel !== translationKey) { + finalLabel = translatedLabel; + } else if (schemaTitle) { + finalLabel = schemaTitle; + } + } else if (schemaTitle) { + finalLabel = schemaTitle; + } + + // Try to get translated description, falling back to schema description + let finalDescription = description || ""; + if (i18nNamespace && translationPath) { + const translatedDesc = t(`${translationPath}.description`, { + ns: i18nNamespace, + defaultValue: "", + }); + if (translatedDesc && translatedDesc !== `${translationPath}.description`) { + finalDescription = translatedDesc; + } else if (schemaDescription) { + finalDescription = schemaDescription; + } + } else if (schemaDescription) { + finalDescription = schemaDescription; + } + return (
- {displayLabel && label && !isBoolean && ( + {displayLabel && finalLabel && !isBoolean && ( )} @@ -53,22 +116,26 @@ export function FieldTemplate(props: FieldTemplateProps) { {isBoolean ? (
- {displayLabel && label && ( + {displayLabel && finalLabel && ( )} - {description && ( -

{description}

+ {finalDescription && ( +

+ {String(finalDescription)} +

)}
{children}
) : ( <> - {description && ( -

{description}

+ {finalDescription && ( +

+ {String(finalDescription)} +

)} {children} diff --git a/web/src/components/config-form/theme/templates/MultiSchemaFieldTemplate.tsx b/web/src/components/config-form/theme/templates/MultiSchemaFieldTemplate.tsx index 5457475b1..4760f47ab 100644 --- a/web/src/components/config-form/theme/templates/MultiSchemaFieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/MultiSchemaFieldTemplate.tsx @@ -37,4 +37,3 @@ export function MultiSchemaFieldTemplate< ); } - diff --git a/web/src/lib/config-schema/transformer.ts b/web/src/lib/config-schema/transformer.ts index a8587ffc3..00b7b1140 100644 --- a/web/src/lib/config-schema/transformer.ts +++ b/web/src/lib/config-schema/transformer.ts @@ -19,6 +19,8 @@ export interface UiSchemaOptions { widgetMappings?: Record; /** Whether to include descriptions */ includeDescriptions?: boolean; + /** i18n namespace for field labels (e.g., "config/detect") */ + i18nNamespace?: string; } // Type guard for schema objects diff --git a/web/src/utils/i18n.ts b/web/src/utils/i18n.ts index f149f467e..b53b67ca8 100644 --- a/web/src/utils/i18n.ts +++ b/web/src/utils/i18n.ts @@ -52,6 +52,26 @@ i18n "views/system", "views/exports", "views/explore", + // Config section translations + "config/detect", + "config/record", + "config/snapshots", + "config/motion", + "config/objects", + "config/review", + "config/audio", + "config/notifications", + "config/live", + "config/timestamp_style", + "config/mqtt", + "config/database", + "config/auth", + "config/tls", + "config/telemetry", + "config/birdseye", + "config/semantic_search", + "config/face_recognition", + "config/lpr", ], defaultNS: "common", diff --git a/web/src/views/settings/CameraConfigView.tsx b/web/src/views/settings/CameraConfigView.tsx index bee679957..c52633bc5 100644 --- a/web/src/views/settings/CameraConfigView.tsx +++ b/web/src/views/settings/CameraConfigView.tsx @@ -1,27 +1,26 @@ // Camera Configuration View // Per-camera configuration with tab navigation and override indicators -import { useMemo, useCallback, useState } from "react"; +import { useMemo, useCallback, useState, memo } from "react"; import useSWR from "swr"; import { useTranslation } from "react-i18next"; -import { - DetectSection, - RecordSection, - SnapshotsSection, - MotionSection, - ObjectsSection, - ReviewSection, - AudioSection, - NotificationsSection, - LiveSection, - TimestampSection, -} from "@/components/config-form/sections"; +import { DetectSection } from "@/components/config-form/sections/DetectSection"; +import { RecordSection } from "@/components/config-form/sections/RecordSection"; +import { SnapshotsSection } from "@/components/config-form/sections/SnapshotsSection"; +import { MotionSection } from "@/components/config-form/sections/MotionSection"; +import { ObjectsSection } from "@/components/config-form/sections/ObjectsSection"; +import { ReviewSection } from "@/components/config-form/sections/ReviewSection"; +import { AudioSection } from "@/components/config-form/sections/AudioSection"; +import { NotificationsSection } from "@/components/config-form/sections/NotificationsSection"; +import { LiveSection } from "@/components/config-form/sections/LiveSection"; +import { TimestampSection } from "@/components/config-form/sections/TimestampSection"; import { useAllCameraOverrides } from "@/hooks/use-config-override"; import type { FrigateConfig } from "@/types/frigateConfig"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Badge } from "@/components/ui/badge"; import ActivityIndicator from "@/components/indicators/activity-indicator"; +import Heading from "@/components/ui/heading"; import { cn } from "@/lib/utils"; interface CameraConfigViewProps { @@ -83,14 +82,14 @@ export default function CameraConfigView({ } return ( -
-
-

+
+
+ {t("configForm.camera.title", { defaultValue: "Camera Configuration", })} -

-

+ +

{t("configForm.camera.description", { defaultValue: "Configure settings for individual cameras. Overridden settings are highlighted.", @@ -103,7 +102,7 @@ export default function CameraConfigView({ @@ -134,7 +133,7 @@ export default function CameraConfigView({ {cameras.map((camera) => ( - + void; } -function CameraConfigContent({ +const CameraConfigContent = memo(function CameraConfigContent({ cameraName, config, overriddenSections, onSave, }: CameraConfigContentProps) { - const { t } = useTranslation(["views/settings"]); + const { t } = useTranslation([ + "config/detect", + "config/record", + "config/snapshots", + "config/motion", + "config/objects", + "config/review", + "config/audio", + "config/notifications", + "config/live", + "config/timestamp_style", + "views/settings", + "common", + ]); const [activeSection, setActiveSection] = useState("detect"); const cameraConfig = config.cameras?.[cameraName]; @@ -180,35 +192,73 @@ function CameraConfigContent({ if (!cameraConfig) { return (

- {t("configForm.camera.notFound", { defaultValue: "Camera not found" })} + {t("configForm.camera.notFound", { + ns: "views/settings", + defaultValue: "Camera not found", + })}
); } const sections = [ - { key: "detect", label: "Detect", component: DetectSection }, - { key: "record", label: "Record", component: RecordSection }, - { key: "snapshots", label: "Snapshots", component: SnapshotsSection }, - { key: "motion", label: "Motion", component: MotionSection }, - { key: "objects", label: "Objects", component: ObjectsSection }, - { key: "review", label: "Review", component: ReviewSection }, - { key: "audio", label: "Audio", component: AudioSection }, + { + key: "detect", + i18nNamespace: "config/detect", + component: DetectSection, + }, + { + key: "record", + i18nNamespace: "config/record", + component: RecordSection, + }, + { + key: "snapshots", + i18nNamespace: "config/snapshots", + component: SnapshotsSection, + }, + { + key: "motion", + i18nNamespace: "config/motion", + component: MotionSection, + }, + { + key: "objects", + i18nNamespace: "config/objects", + component: ObjectsSection, + }, + { + key: "review", + i18nNamespace: "config/review", + component: ReviewSection, + }, + { key: "audio", i18nNamespace: "config/audio", component: AudioSection }, { key: "notifications", - label: "Notifications", + i18nNamespace: "config/notifications", component: NotificationsSection, }, - { key: "live", label: "Live", component: LiveSection }, - { key: "timestamp_style", label: "Timestamp", component: TimestampSection }, + { key: "live", i18nNamespace: "config/live", component: LiveSection }, + { + key: "timestamp_style", + i18nNamespace: "config/timestamp_style", + component: TimestampSection, + }, ]; return ( -
+
{/* Section Navigation */} {/* Section Content */} - -
- {sections.map((section) => { - const SectionComponent = section.component; - return ( -
- -
- ); - })} -
-
+
+ {sections.map((section) => { + const SectionComponent = section.component; + return ( +
+ +
+ ); + })} +
); -} +}); diff --git a/web/src/views/settings/GlobalConfigView.tsx b/web/src/views/settings/GlobalConfigView.tsx index 69720f9e6..c620c7cd9 100644 --- a/web/src/views/settings/GlobalConfigView.tsx +++ b/web/src/views/settings/GlobalConfigView.tsx @@ -1,38 +1,75 @@ // Global Configuration View // Main view for configuring global Frigate settings -import { useMemo, useCallback, useState } from "react"; +import { useMemo, useCallback, useState, memo } from "react"; import useSWR from "swr"; import axios from "axios"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { ConfigForm } from "@/components/config-form/ConfigForm"; -import { - DetectSection, - RecordSection, - SnapshotsSection, - MotionSection, - ObjectsSection, - ReviewSection, - AudioSection, - NotificationsSection, - LiveSection, - TimestampSection, -} from "@/components/config-form/sections"; +import { DetectSection } from "@/components/config-form/sections/DetectSection"; +import { RecordSection } from "@/components/config-form/sections/RecordSection"; +import { SnapshotsSection } from "@/components/config-form/sections/SnapshotsSection"; +import { MotionSection } from "@/components/config-form/sections/MotionSection"; +import { ObjectsSection } from "@/components/config-form/sections/ObjectsSection"; +import { ReviewSection } from "@/components/config-form/sections/ReviewSection"; +import { AudioSection } from "@/components/config-form/sections/AudioSection"; +import { NotificationsSection } from "@/components/config-form/sections/NotificationsSection"; +import { LiveSection } from "@/components/config-form/sections/LiveSection"; +import { TimestampSection } from "@/components/config-form/sections/TimestampSection"; import type { RJSFSchema } from "@rjsf/utils"; import type { FrigateConfig } from "@/types/frigateConfig"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; import { extractSchemaSection } from "@/lib/config-schema"; import ActivityIndicator from "@/components/indicators/activity-indicator"; +import Heading from "@/components/ui/heading"; +import { LuSave } from "react-icons/lu"; +import isEqual from "lodash/isEqual"; +import { cn } from "@/lib/utils"; -// Section configurations for global-only settings +// Shared sections that can be overridden at camera level +const sharedSections = [ + { key: "detect", i18nNamespace: "config/detect", component: DetectSection }, + { key: "record", i18nNamespace: "config/record", component: RecordSection }, + { + key: "snapshots", + i18nNamespace: "config/snapshots", + component: SnapshotsSection, + }, + { key: "motion", i18nNamespace: "config/motion", component: MotionSection }, + { + key: "objects", + i18nNamespace: "config/objects", + component: ObjectsSection, + }, + { key: "review", i18nNamespace: "config/review", component: ReviewSection }, + { key: "audio", i18nNamespace: "config/audio", component: AudioSection }, + { + key: "notifications", + i18nNamespace: "config/notifications", + component: NotificationsSection, + }, + { key: "live", i18nNamespace: "config/live", component: LiveSection }, + { + key: "timestamp_style", + i18nNamespace: "config/timestamp_style", + component: TimestampSection, + }, +]; + +// Section configurations for global-only settings (system and integrations) const globalSectionConfigs: Record< string, - { fieldOrder?: string[]; hiddenFields?: string[]; advancedFields?: string[] } + { + fieldOrder?: string[]; + hiddenFields?: string[]; + advancedFields?: string[]; + i18nNamespace: string; + } > = { mqtt: { + i18nNamespace: "config/mqtt", fieldOrder: [ "enabled", "host", @@ -56,10 +93,12 @@ const globalSectionConfigs: Record< ], }, database: { + i18nNamespace: "config/database", fieldOrder: ["path"], advancedFields: [], }, auth: { + i18nNamespace: "config/auth", fieldOrder: [ "enabled", "reset_admin_password", @@ -70,14 +109,17 @@ const globalSectionConfigs: Record< advancedFields: ["failed_login_rate_limit", "trusted_proxies"], }, tls: { + i18nNamespace: "config/tls", fieldOrder: ["enabled", "cert", "key"], advancedFields: [], }, telemetry: { + i18nNamespace: "config/telemetry", fieldOrder: ["network_interfaces", "stats", "version_check"], advancedFields: ["stats"], }, birdseye: { + i18nNamespace: "config/birdseye", fieldOrder: [ "enabled", "restream", @@ -91,14 +133,17 @@ const globalSectionConfigs: Record< advancedFields: ["width", "height", "quality", "inactivity_threshold"], }, semantic_search: { + i18nNamespace: "config/semantic_search", fieldOrder: ["enabled", "reindex", "model_size"], advancedFields: ["reindex"], }, face_recognition: { + i18nNamespace: "config/face_recognition", fieldOrder: ["enabled", "threshold", "min_area", "model_size"], advancedFields: ["threshold", "min_area"], }, lpr: { + i18nNamespace: "config/lpr", fieldOrder: [ "enabled", "threshold", @@ -111,6 +156,17 @@ const globalSectionConfigs: Record< }, }; +// System sections (global only) +const systemSections = ["database", "tls", "auth", "telemetry", "birdseye"]; + +// Integration sections (global only) +const integrationSections = [ + "mqtt", + "semantic_search", + "face_recognition", + "lpr", +]; + interface GlobalConfigSectionProps { sectionKey: string; schema: RJSFSchema | null; @@ -118,13 +174,23 @@ interface GlobalConfigSectionProps { onSave: () => void; } -function GlobalConfigSection({ +const GlobalConfigSection = memo(function GlobalConfigSection({ sectionKey, schema, config, onSave, }: GlobalConfigSectionProps) { - const { t } = useTranslation(["views/settings"]); + const sectionConfig = globalSectionConfigs[sectionKey]; + const { t } = useTranslation([ + sectionConfig?.i18nNamespace || "common", + "views/settings", + "common", + ]); + const [pendingData, setPendingData] = useState | null>(null); + const [isSaving, setIsSaving] = useState(false); const formData = useMemo((): Record => { if (!config) return {} as Record; @@ -134,67 +200,118 @@ function GlobalConfigSection({ ); }, [config, sectionKey]); - const handleSubmit = useCallback( - async (data: Record) => { - try { - await axios.put("config/set", { - requires_restart: 1, - config_data: { - [sectionKey]: data, - }, - }); + const hasChanges = useMemo(() => { + if (!pendingData) return false; + return !isEqual(formData, pendingData); + }, [formData, pendingData]); - toast.success( - t(`configForm.${sectionKey}.toast.success`, { - defaultValue: "Settings saved successfully", - }), - ); + const handleChange = useCallback((data: Record) => { + setPendingData(data); + }, []); - onSave(); - } catch (error) { - toast.error( - t(`configForm.${sectionKey}.toast.error`, { - defaultValue: "Failed to save settings", - }), - ); - } - }, - [sectionKey, t, onSave], - ); + const handleSave = useCallback(async () => { + if (!pendingData) return; - if (!schema) { + setIsSaving(true); + try { + await axios.put("config/set", { + requires_restart: 1, + config_data: { + [sectionKey]: pendingData, + }, + }); + + toast.success( + t("toast.success", { + ns: "views/settings", + defaultValue: "Settings saved successfully", + }), + ); + + setPendingData(null); + onSave(); + } catch { + toast.error( + t("toast.error", { + ns: "views/settings", + defaultValue: "Failed to save settings", + }), + ); + } finally { + setIsSaving(false); + } + }, [sectionKey, pendingData, t, onSave]); + + if (!schema || !sectionConfig) { return null; } - const sectionConfig = globalSectionConfigs[sectionKey] || {}; - return ( - - - - {t(`configForm.${sectionKey}.title`, { - defaultValue: - sectionKey.charAt(0).toUpperCase() + sectionKey.slice(1), - })} - - - - - - +
+ + +
+
+ {hasChanges && ( + + {t("unsavedChanges", { + ns: "views/settings", + defaultValue: "You have unsaved changes", + })} + + )} +
+ +
+
); -} +}); export default function GlobalConfigView() { - const { t } = useTranslation(["views/settings"]); + const { t } = useTranslation([ + "views/settings", + "config/detect", + "config/record", + "config/snapshots", + "config/motion", + "config/objects", + "config/review", + "config/audio", + "config/notifications", + "config/live", + "config/timestamp_style", + "config/mqtt", + "config/database", + "config/auth", + "config/tls", + "config/telemetry", + "config/birdseye", + "config/semantic_search", + "config/face_recognition", + "config/lpr", + "common", + ]); const [activeTab, setActiveTab] = useState("shared"); + const [activeSection, setActiveSection] = useState("detect"); const { data: config, mutate: refreshConfig } = useSWR("config"); @@ -204,6 +321,37 @@ export default function GlobalConfigView() { refreshConfig(); }, [refreshConfig]); + // Get the sections for the current tab + const currentSections = useMemo(() => { + if (activeTab === "shared") { + return sharedSections; + } else if (activeTab === "system") { + return systemSections.map((key) => ({ + key, + i18nNamespace: globalSectionConfigs[key].i18nNamespace, + component: null, // Uses GlobalConfigSection instead + })); + } else { + return integrationSections.map((key) => ({ + key, + i18nNamespace: globalSectionConfigs[key].i18nNamespace, + component: null, + })); + } + }, [activeTab]); + + // Reset active section when tab changes + const handleTabChange = useCallback((tab: string) => { + setActiveTab(tab); + if (tab === "shared") { + setActiveSection("detect"); + } else if (tab === "system") { + setActiveSection("database"); + } else { + setActiveSection("mqtt"); + } + }, []); + if (!config || !schema) { return (
@@ -213,14 +361,14 @@ export default function GlobalConfigView() { } return ( -
-
-

+
+
+ {t("configForm.global.title", { defaultValue: "Global Configuration", })} -

-

+ +

{t("configForm.global.description", { defaultValue: "Configure global settings that apply to all cameras by default.", @@ -228,7 +376,11 @@ export default function GlobalConfigView() {

- + {t("configForm.global.tabs.shared", { @@ -245,83 +397,126 @@ export default function GlobalConfigView() { - - - {/* Shared config sections - these can be overridden per camera */} - - - - - - - - - - - +
+ {/* Section Navigation */} + - - {/* Integration configuration sections */} - - - - - - + {/* Section Content */} +
+ {activeTab === "shared" && ( + <> + {sharedSections.map((section) => { + const SectionComponent = section.component; + return ( +
+ + {t("label", { + ns: section.i18nNamespace, + defaultValue: + section.key.charAt(0).toUpperCase() + + section.key.slice(1).replace(/_/g, " "), + })} + + +
+ ); + })} + + )} + + {activeTab === "system" && ( + <> + {systemSections.map((sectionKey) => ( +
+ + {t("label", { + ns: globalSectionConfigs[sectionKey].i18nNamespace, + defaultValue: + sectionKey.charAt(0).toUpperCase() + + sectionKey.slice(1).replace(/_/g, " "), + })} + + +
+ ))} + + )} + + {activeTab === "integrations" && ( + <> + {integrationSections.map((sectionKey) => ( +
+ + {t("label", { + ns: globalSectionConfigs[sectionKey].i18nNamespace, + defaultValue: + sectionKey.charAt(0).toUpperCase() + + sectionKey.slice(1).replace(/_/g, " "), + })} + + +
+ ))} + + )} +
+
);