mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-21 07:38:22 +03:00
configure for full i18n support
This commit is contained in:
parent
425e68c51c
commit
737de2f53a
@ -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}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -37,4 +37,3 @@ export function MultiSchemaFieldTemplate<
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user