configure for full i18n support

This commit is contained in:
Josh Hawkins 2026-01-23 09:58:40 -06:00
parent 425e68c51c
commit 737de2f53a
6 changed files with 552 additions and 223 deletions

View File

@ -2,6 +2,15 @@
import type { FieldTemplateProps } from "@rjsf/utils"; import type { FieldTemplateProps } from "@rjsf/utils";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils"; 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 | number>): string {
return path.filter((segment) => typeof segment === "string").join(".");
}
export function FieldTemplate(props: FieldTemplateProps) { export function FieldTemplate(props: FieldTemplateProps) {
const { const {
@ -16,8 +25,17 @@ export function FieldTemplate(props: FieldTemplateProps) {
displayLabel, displayLabel,
schema, schema,
uiSchema, uiSchema,
registry,
fieldPathId,
} = props; } = props;
// Get i18n namespace from form context (passed through registry)
const formContext = registry?.formContext as
| Record<string, unknown>
| undefined;
const i18nNamespace = formContext?.i18nNamespace as string | undefined;
const { t } = useTranslation([i18nNamespace || "common"]);
if (hidden) { if (hidden) {
return <div className="hidden">{children}</div>; return <div className="hidden">{children}</div>;
} }
@ -29,6 +47,51 @@ export function FieldTemplate(props: FieldTemplateProps) {
// Boolean fields (switches) render label inline // Boolean fields (switches) render label inline
const isBoolean = schema.type === "boolean"; 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<string, unknown>).title as
| string
| undefined;
const schemaDescription = (schema as Record<string, unknown>).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 ( return (
<div <div
className={cn( className={cn(
@ -37,7 +100,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
isBoolean && "flex items-center justify-between gap-4", isBoolean && "flex items-center justify-between gap-4",
)} )}
> >
{displayLabel && label && !isBoolean && ( {displayLabel && finalLabel && !isBoolean && (
<Label <Label
htmlFor={id} htmlFor={id}
className={cn( className={cn(
@ -45,7 +108,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
errors && errors.props?.errors?.length > 0 && "text-destructive", errors && errors.props?.errors?.length > 0 && "text-destructive",
)} )}
> >
{label} {finalLabel}
{required && <span className="ml-1 text-destructive">*</span>} {required && <span className="ml-1 text-destructive">*</span>}
</Label> </Label>
)} )}
@ -53,22 +116,26 @@ export function FieldTemplate(props: FieldTemplateProps) {
{isBoolean ? ( {isBoolean ? (
<div className="flex w-full items-center justify-between gap-4"> <div className="flex w-full items-center justify-between gap-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
{displayLabel && label && ( {displayLabel && finalLabel && (
<Label htmlFor={id} className="text-sm font-medium"> <Label htmlFor={id} className="text-sm font-medium">
{label} {finalLabel}
{required && <span className="ml-1 text-destructive">*</span>} {required && <span className="ml-1 text-destructive">*</span>}
</Label> </Label>
)} )}
{description && ( {finalDescription && (
<p className="text-sm text-muted-foreground">{description}</p> <p className="max-w-md text-sm text-muted-foreground">
{String(finalDescription)}
</p>
)} )}
</div> </div>
{children} {children}
</div> </div>
) : ( ) : (
<> <>
{description && ( {finalDescription && (
<p className="text-sm text-muted-foreground">{description}</p> <p className="text-sm text-muted-foreground">
{String(finalDescription)}
</p>
)} )}
{children} {children}
</> </>

View File

@ -37,4 +37,3 @@ export function MultiSchemaFieldTemplate<
</> </>
); );
} }

View File

@ -19,6 +19,8 @@ export interface UiSchemaOptions {
widgetMappings?: Record<string, string>; widgetMappings?: Record<string, string>;
/** Whether to include descriptions */ /** Whether to include descriptions */
includeDescriptions?: boolean; includeDescriptions?: boolean;
/** i18n namespace for field labels (e.g., "config/detect") */
i18nNamespace?: string;
} }
// Type guard for schema objects // Type guard for schema objects

View File

@ -52,6 +52,26 @@ i18n
"views/system", "views/system",
"views/exports", "views/exports",
"views/explore", "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", defaultNS: "common",

View File

@ -1,27 +1,26 @@
// Camera Configuration View // Camera Configuration View
// Per-camera configuration with tab navigation and override indicators // 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 useSWR from "swr";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { DetectSection } from "@/components/config-form/sections/DetectSection";
DetectSection, import { RecordSection } from "@/components/config-form/sections/RecordSection";
RecordSection, import { SnapshotsSection } from "@/components/config-form/sections/SnapshotsSection";
SnapshotsSection, import { MotionSection } from "@/components/config-form/sections/MotionSection";
MotionSection, import { ObjectsSection } from "@/components/config-form/sections/ObjectsSection";
ObjectsSection, import { ReviewSection } from "@/components/config-form/sections/ReviewSection";
ReviewSection, import { AudioSection } from "@/components/config-form/sections/AudioSection";
AudioSection, import { NotificationsSection } from "@/components/config-form/sections/NotificationsSection";
NotificationsSection, import { LiveSection } from "@/components/config-form/sections/LiveSection";
LiveSection, import { TimestampSection } from "@/components/config-form/sections/TimestampSection";
TimestampSection,
} from "@/components/config-form/sections";
import { useAllCameraOverrides } from "@/hooks/use-config-override"; import { useAllCameraOverrides } from "@/hooks/use-config-override";
import type { FrigateConfig } from "@/types/frigateConfig"; import type { FrigateConfig } from "@/types/frigateConfig";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import Heading from "@/components/ui/heading";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface CameraConfigViewProps { interface CameraConfigViewProps {
@ -83,14 +82,14 @@ export default function CameraConfigView({
} }
return ( return (
<div className="space-y-6"> <div className="flex size-full flex-col">
<div> <div className="mb-4">
<h2 className="text-2xl font-bold tracking-tight"> <Heading as="h2">
{t("configForm.camera.title", { {t("configForm.camera.title", {
defaultValue: "Camera Configuration", defaultValue: "Camera Configuration",
})} })}
</h2> </Heading>
<p className="text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("configForm.camera.description", { {t("configForm.camera.description", {
defaultValue: defaultValue:
"Configure settings for individual cameras. Overridden settings are highlighted.", "Configure settings for individual cameras. Overridden settings are highlighted.",
@ -103,7 +102,7 @@ export default function CameraConfigView({
<Tabs <Tabs
value={selectedCamera} value={selectedCamera}
onValueChange={handleCameraChange} onValueChange={handleCameraChange}
className="w-full" className="flex flex-1 flex-col"
> >
<ScrollArea className="w-full"> <ScrollArea className="w-full">
<TabsList className="inline-flex w-max"> <TabsList className="inline-flex w-max">
@ -134,7 +133,7 @@ export default function CameraConfigView({
</ScrollArea> </ScrollArea>
{cameras.map((camera) => ( {cameras.map((camera) => (
<TabsContent key={camera} value={camera} className="mt-4"> <TabsContent key={camera} value={camera} className="mt-4 flex-1">
<CameraConfigContent <CameraConfigContent
cameraName={camera} cameraName={camera}
config={config} config={config}
@ -166,13 +165,26 @@ interface CameraConfigContentProps {
onSave: () => void; onSave: () => void;
} }
function CameraConfigContent({ const CameraConfigContent = memo(function CameraConfigContent({
cameraName, cameraName,
config, config,
overriddenSections, overriddenSections,
onSave, onSave,
}: CameraConfigContentProps) { }: 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 [activeSection, setActiveSection] = useState("detect");
const cameraConfig = config.cameras?.[cameraName]; const cameraConfig = config.cameras?.[cameraName];
@ -180,35 +192,73 @@ function CameraConfigContent({
if (!cameraConfig) { if (!cameraConfig) {
return ( return (
<div className="text-muted-foreground"> <div className="text-muted-foreground">
{t("configForm.camera.notFound", { defaultValue: "Camera not found" })} {t("configForm.camera.notFound", {
ns: "views/settings",
defaultValue: "Camera not found",
})}
</div> </div>
); );
} }
const sections = [ const sections = [
{ key: "detect", label: "Detect", component: DetectSection }, {
{ key: "record", label: "Record", component: RecordSection }, key: "detect",
{ key: "snapshots", label: "Snapshots", component: SnapshotsSection }, i18nNamespace: "config/detect",
{ key: "motion", label: "Motion", component: MotionSection }, component: DetectSection,
{ key: "objects", label: "Objects", component: ObjectsSection }, },
{ key: "review", label: "Review", component: ReviewSection }, {
{ key: "audio", label: "Audio", component: AudioSection }, 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", key: "notifications",
label: "Notifications", i18nNamespace: "config/notifications",
component: NotificationsSection, component: NotificationsSection,
}, },
{ key: "live", label: "Live", component: LiveSection }, { key: "live", i18nNamespace: "config/live", component: LiveSection },
{ key: "timestamp_style", label: "Timestamp", component: TimestampSection }, {
key: "timestamp_style",
i18nNamespace: "config/timestamp_style",
component: TimestampSection,
},
]; ];
return ( return (
<div className="flex gap-6"> <div className="flex flex-1 gap-6 overflow-hidden">
{/* Section Navigation */} {/* Section Navigation */}
<nav className="w-48 shrink-0"> <nav className="w-48 shrink-0">
<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", {
ns: section.i18nNamespace,
defaultValue:
section.key.charAt(0).toUpperCase() +
section.key.slice(1).replace(/_/g, " "),
});
return ( return (
<li key={section.key}> <li key={section.key}>
<button <button
@ -220,14 +270,13 @@ function CameraConfigContent({
: "hover:bg-muted", : "hover:bg-muted",
)} )}
> >
<span> <span>{sectionLabel}</span>
{t(`configForm.${section.key}.title`, {
defaultValue: section.label,
})}
</span>
{isOverridden && ( {isOverridden && (
<Badge variant="secondary" className="h-5 px-1.5 text-xs"> <Badge variant="secondary" className="h-5 px-1.5 text-xs">
{t("common.modified", { defaultValue: "Modified" })} {t("button.modified", {
ns: "common",
defaultValue: "Modified",
})}
</Badge> </Badge>
)} )}
</button> </button>
@ -238,28 +287,25 @@ function CameraConfigContent({
</nav> </nav>
{/* Section Content */} {/* Section Content */}
<ScrollArea className="h-[calc(100vh-300px)] flex-1"> <div className="scrollbar-container flex-1 overflow-y-auto pr-4">
<div className="pr-4"> {sections.map((section) => {
{sections.map((section) => { const SectionComponent = section.component;
const SectionComponent = section.component; return (
return ( <div
<div key={section.key}
key={section.key} className={cn(activeSection === section.key ? "block" : "hidden")}
className={cn( >
activeSection === section.key ? "block" : "hidden", <SectionComponent
)} level="camera"
> cameraName={cameraName}
<SectionComponent showOverrideIndicator
level="camera" onSave={onSave}
cameraName={cameraName} showTitle={true}
showOverrideIndicator />
onSave={onSave} </div>
/> );
</div> })}
); </div>
})}
</div>
</ScrollArea>
</div> </div>
); );
} });

View File

@ -1,38 +1,75 @@
// Global Configuration View // Global Configuration View
// Main view for configuring global Frigate settings // 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 useSWR from "swr";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ConfigForm } from "@/components/config-form/ConfigForm"; import { ConfigForm } from "@/components/config-form/ConfigForm";
import { import { DetectSection } from "@/components/config-form/sections/DetectSection";
DetectSection, import { RecordSection } from "@/components/config-form/sections/RecordSection";
RecordSection, import { SnapshotsSection } from "@/components/config-form/sections/SnapshotsSection";
SnapshotsSection, import { MotionSection } from "@/components/config-form/sections/MotionSection";
MotionSection, import { ObjectsSection } from "@/components/config-form/sections/ObjectsSection";
ObjectsSection, import { ReviewSection } from "@/components/config-form/sections/ReviewSection";
ReviewSection, import { AudioSection } from "@/components/config-form/sections/AudioSection";
AudioSection, import { NotificationsSection } from "@/components/config-form/sections/NotificationsSection";
NotificationsSection, import { LiveSection } from "@/components/config-form/sections/LiveSection";
LiveSection, import { TimestampSection } from "@/components/config-form/sections/TimestampSection";
TimestampSection,
} from "@/components/config-form/sections";
import type { RJSFSchema } from "@rjsf/utils"; import type { RJSFSchema } from "@rjsf/utils";
import type { FrigateConfig } from "@/types/frigateConfig"; import type { FrigateConfig } from "@/types/frigateConfig";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { extractSchemaSection } from "@/lib/config-schema"; import { extractSchemaSection } from "@/lib/config-schema";
import ActivityIndicator from "@/components/indicators/activity-indicator"; 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< const globalSectionConfigs: Record<
string, string,
{ fieldOrder?: string[]; hiddenFields?: string[]; advancedFields?: string[] } {
fieldOrder?: string[];
hiddenFields?: string[];
advancedFields?: string[];
i18nNamespace: string;
}
> = { > = {
mqtt: { mqtt: {
i18nNamespace: "config/mqtt",
fieldOrder: [ fieldOrder: [
"enabled", "enabled",
"host", "host",
@ -56,10 +93,12 @@ const globalSectionConfigs: Record<
], ],
}, },
database: { database: {
i18nNamespace: "config/database",
fieldOrder: ["path"], fieldOrder: ["path"],
advancedFields: [], advancedFields: [],
}, },
auth: { auth: {
i18nNamespace: "config/auth",
fieldOrder: [ fieldOrder: [
"enabled", "enabled",
"reset_admin_password", "reset_admin_password",
@ -70,14 +109,17 @@ const globalSectionConfigs: Record<
advancedFields: ["failed_login_rate_limit", "trusted_proxies"], advancedFields: ["failed_login_rate_limit", "trusted_proxies"],
}, },
tls: { tls: {
i18nNamespace: "config/tls",
fieldOrder: ["enabled", "cert", "key"], fieldOrder: ["enabled", "cert", "key"],
advancedFields: [], advancedFields: [],
}, },
telemetry: { telemetry: {
i18nNamespace: "config/telemetry",
fieldOrder: ["network_interfaces", "stats", "version_check"], fieldOrder: ["network_interfaces", "stats", "version_check"],
advancedFields: ["stats"], advancedFields: ["stats"],
}, },
birdseye: { birdseye: {
i18nNamespace: "config/birdseye",
fieldOrder: [ fieldOrder: [
"enabled", "enabled",
"restream", "restream",
@ -91,14 +133,17 @@ const globalSectionConfigs: Record<
advancedFields: ["width", "height", "quality", "inactivity_threshold"], advancedFields: ["width", "height", "quality", "inactivity_threshold"],
}, },
semantic_search: { semantic_search: {
i18nNamespace: "config/semantic_search",
fieldOrder: ["enabled", "reindex", "model_size"], fieldOrder: ["enabled", "reindex", "model_size"],
advancedFields: ["reindex"], advancedFields: ["reindex"],
}, },
face_recognition: { face_recognition: {
i18nNamespace: "config/face_recognition",
fieldOrder: ["enabled", "threshold", "min_area", "model_size"], fieldOrder: ["enabled", "threshold", "min_area", "model_size"],
advancedFields: ["threshold", "min_area"], advancedFields: ["threshold", "min_area"],
}, },
lpr: { lpr: {
i18nNamespace: "config/lpr",
fieldOrder: [ fieldOrder: [
"enabled", "enabled",
"threshold", "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 { interface GlobalConfigSectionProps {
sectionKey: string; sectionKey: string;
schema: RJSFSchema | null; schema: RJSFSchema | null;
@ -118,13 +174,23 @@ interface GlobalConfigSectionProps {
onSave: () => void; onSave: () => void;
} }
function GlobalConfigSection({ const GlobalConfigSection = memo(function GlobalConfigSection({
sectionKey, sectionKey,
schema, schema,
config, config,
onSave, onSave,
}: GlobalConfigSectionProps) { }: GlobalConfigSectionProps) {
const { t } = useTranslation(["views/settings"]); const sectionConfig = globalSectionConfigs[sectionKey];
const { t } = useTranslation([
sectionConfig?.i18nNamespace || "common",
"views/settings",
"common",
]);
const [pendingData, setPendingData] = useState<Record<
string,
unknown
> | null>(null);
const [isSaving, setIsSaving] = useState(false);
const formData = useMemo((): Record<string, unknown> => { const formData = useMemo((): Record<string, unknown> => {
if (!config) return {} as Record<string, unknown>; if (!config) return {} as Record<string, unknown>;
@ -134,67 +200,118 @@ function GlobalConfigSection({
); );
}, [config, sectionKey]); }, [config, sectionKey]);
const handleSubmit = useCallback( const hasChanges = useMemo(() => {
async (data: Record<string, unknown>) => { if (!pendingData) return false;
try { return !isEqual(formData, pendingData);
await axios.put("config/set", { }, [formData, pendingData]);
requires_restart: 1,
config_data: {
[sectionKey]: data,
},
});
toast.success( const handleChange = useCallback((data: Record<string, unknown>) => {
t(`configForm.${sectionKey}.toast.success`, { setPendingData(data);
defaultValue: "Settings saved successfully", }, []);
}),
);
onSave(); const handleSave = useCallback(async () => {
} catch (error) { if (!pendingData) return;
toast.error(
t(`configForm.${sectionKey}.toast.error`, {
defaultValue: "Failed to save settings",
}),
);
}
},
[sectionKey, t, onSave],
);
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; return null;
} }
const sectionConfig = globalSectionConfigs[sectionKey] || {};
return ( return (
<Card> <div className="space-y-4">
<CardHeader> <ConfigForm
<CardTitle className="text-lg"> schema={schema}
{t(`configForm.${sectionKey}.title`, { formData={pendingData || formData}
defaultValue: onChange={handleChange}
sectionKey.charAt(0).toUpperCase() + sectionKey.slice(1), fieldOrder={sectionConfig.fieldOrder}
})} hiddenFields={sectionConfig.hiddenFields}
</CardTitle> advancedFields={sectionConfig.advancedFields}
</CardHeader> showSubmit={false}
<CardContent> i18nNamespace={sectionConfig.i18nNamespace}
<ConfigForm disabled={isSaving}
schema={schema} />
formData={formData}
onSubmit={handleSubmit} <div className="flex items-center justify-between pt-2">
fieldOrder={sectionConfig.fieldOrder} <div>
hiddenFields={sectionConfig.hiddenFields} {hasChanges && (
advancedFields={sectionConfig.advancedFields} <span className="text-sm text-muted-foreground">
/> {t("unsavedChanges", {
</CardContent> ns: "views/settings",
</Card> defaultValue: "You have unsaved changes",
})}
</span>
)}
</div>
<Button
onClick={handleSave}
disabled={!hasChanges || isSaving}
className="gap-2"
>
<LuSave className="h-4 w-4" />
{isSaving
? t("saving", { ns: "common", defaultValue: "Saving..." })
: t("save", { ns: "common", defaultValue: "Save" })}
</Button>
</div>
</div>
); );
} });
export default function GlobalConfigView() { 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 [activeTab, setActiveTab] = useState("shared");
const [activeSection, setActiveSection] = useState("detect");
const { data: config, mutate: refreshConfig } = const { data: config, mutate: refreshConfig } =
useSWR<FrigateConfig>("config"); useSWR<FrigateConfig>("config");
@ -204,6 +321,37 @@ export default function GlobalConfigView() {
refreshConfig(); refreshConfig();
}, [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) { if (!config || !schema) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
@ -213,14 +361,14 @@ export default function GlobalConfigView() {
} }
return ( return (
<div className="space-y-6"> <div className="flex size-full flex-col">
<div> <div className="mb-4">
<h2 className="text-2xl font-bold tracking-tight"> <Heading as="h2">
{t("configForm.global.title", { {t("configForm.global.title", {
defaultValue: "Global Configuration", defaultValue: "Global Configuration",
})} })}
</h2> </Heading>
<p className="text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("configForm.global.description", { {t("configForm.global.description", {
defaultValue: defaultValue:
"Configure global settings that apply to all cameras by default.", "Configure global settings that apply to all cameras by default.",
@ -228,7 +376,11 @@ export default function GlobalConfigView() {
</p> </p>
</div> </div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> <Tabs
value={activeTab}
onValueChange={handleTabChange}
className="flex flex-1 flex-col overflow-hidden"
>
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="shared"> <TabsTrigger value="shared">
{t("configForm.global.tabs.shared", { {t("configForm.global.tabs.shared", {
@ -245,83 +397,126 @@ export default function GlobalConfigView() {
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<ScrollArea className="h-[calc(100vh-300px)]"> <div className="mt-4 flex flex-1 gap-6 overflow-hidden">
<TabsContent value="shared" className="space-y-6 p-1"> {/* Section Navigation */}
{/* Shared config sections - these can be overridden per camera */} <nav className="w-48 shrink-0">
<DetectSection level="global" onSave={handleSave} /> <ul className="space-y-1">
<RecordSection level="global" onSave={handleSave} /> {currentSections.map((section) => {
<SnapshotsSection level="global" onSave={handleSave} /> const sectionLabel = t("label", {
<MotionSection level="global" onSave={handleSave} /> ns: section.i18nNamespace,
<ObjectsSection level="global" onSave={handleSave} /> defaultValue:
<ReviewSection level="global" onSave={handleSave} /> section.key.charAt(0).toUpperCase() +
<AudioSection level="global" onSave={handleSave} /> section.key.slice(1).replace(/_/g, " "),
<NotificationsSection level="global" onSave={handleSave} /> });
<LiveSection level="global" onSave={handleSave} />
<TimestampSection level="global" onSave={handleSave} />
</TabsContent>
<TabsContent value="system" className="space-y-6 p-1"> return (
{/* System configuration sections */} <li key={section.key}>
<GlobalConfigSection <button
sectionKey="database" onClick={() => setActiveSection(section.key)}
schema={extractSchemaSection(schema, "database")} className={cn(
config={config} "flex w-full items-center justify-between rounded-md px-3 py-2 text-sm transition-colors",
onSave={handleSave} activeSection === section.key
/> ? "bg-accent text-accent-foreground"
<GlobalConfigSection : "hover:bg-muted",
sectionKey="tls" )}
schema={extractSchemaSection(schema, "tls")} >
config={config} <span>{sectionLabel}</span>
onSave={handleSave} </button>
/> </li>
<GlobalConfigSection );
sectionKey="auth" })}
schema={extractSchemaSection(schema, "auth")} </ul>
config={config} </nav>
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="telemetry"
schema={extractSchemaSection(schema, "telemetry")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="birdseye"
schema={extractSchemaSection(schema, "birdseye")}
config={config}
onSave={handleSave}
/>
</TabsContent>
<TabsContent value="integrations" className="space-y-6 p-1"> {/* Section Content */}
{/* Integration configuration sections */} <div className="scrollbar-container flex-1 overflow-y-auto pr-4">
<GlobalConfigSection {activeTab === "shared" && (
sectionKey="mqtt" <>
schema={extractSchemaSection(schema, "mqtt")} {sharedSections.map((section) => {
config={config} const SectionComponent = section.component;
onSave={handleSave} return (
/> <div
<GlobalConfigSection key={section.key}
sectionKey="semantic_search" className={cn(
schema={extractSchemaSection(schema, "semantic_search")} activeSection === section.key ? "block" : "hidden",
config={config} )}
onSave={handleSave} >
/> <Heading as="h4" className="mb-4">
<GlobalConfigSection {t("label", {
sectionKey="face_recognition" ns: section.i18nNamespace,
schema={extractSchemaSection(schema, "face_recognition")} defaultValue:
config={config} section.key.charAt(0).toUpperCase() +
onSave={handleSave} section.key.slice(1).replace(/_/g, " "),
/> })}
<GlobalConfigSection </Heading>
sectionKey="lpr" <SectionComponent
schema={extractSchemaSection(schema, "lpr")} level="global"
config={config} onSave={handleSave}
onSave={handleSave} showTitle={false}
/> />
</TabsContent> </div>
</ScrollArea> );
})}
</>
)}
{activeTab === "system" && (
<>
{systemSections.map((sectionKey) => (
<div
key={sectionKey}
className={cn(
activeSection === sectionKey ? "block" : "hidden",
)}
>
<Heading as="h4" className="mb-4">
{t("label", {
ns: globalSectionConfigs[sectionKey].i18nNamespace,
defaultValue:
sectionKey.charAt(0).toUpperCase() +
sectionKey.slice(1).replace(/_/g, " "),
})}
</Heading>
<GlobalConfigSection
sectionKey={sectionKey}
schema={extractSchemaSection(schema, sectionKey)}
config={config}
onSave={handleSave}
/>
</div>
))}
</>
)}
{activeTab === "integrations" && (
<>
{integrationSections.map((sectionKey) => (
<div
key={sectionKey}
className={cn(
activeSection === sectionKey ? "block" : "hidden",
)}
>
<Heading as="h4" className="mb-4">
{t("label", {
ns: globalSectionConfigs[sectionKey].i18nNamespace,
defaultValue:
sectionKey.charAt(0).toUpperCase() +
sectionKey.slice(1).replace(/_/g, " "),
})}
</Heading>
<GlobalConfigSection
sectionKey={sectionKey}
schema={extractSchemaSection(schema, sectionKey)}
config={config}
onSave={handleSave}
/>
</div>
))}
</>
)}
</div>
</div>
</Tabs> </Tabs>
</div> </div>
); );