mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-21 07:38:22 +03:00
section fields
This commit is contained in:
parent
737de2f53a
commit
0280c2ec43
@ -43,6 +43,8 @@ export interface ConfigFormProps {
|
|||||||
liveValidate?: boolean;
|
liveValidate?: boolean;
|
||||||
/** Form context passed to all widgets */
|
/** Form context passed to all widgets */
|
||||||
formContext?: Record<string, unknown>;
|
formContext?: Record<string, unknown>;
|
||||||
|
/** i18n namespace for field labels */
|
||||||
|
i18nNamespace?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfigForm({
|
export function ConfigForm({
|
||||||
@ -61,8 +63,9 @@ export function ConfigForm({
|
|||||||
className,
|
className,
|
||||||
liveValidate = false,
|
liveValidate = false,
|
||||||
formContext,
|
formContext,
|
||||||
|
i18nNamespace,
|
||||||
}: ConfigFormProps) {
|
}: ConfigFormProps) {
|
||||||
const { t } = useTranslation(["views/settings"]);
|
const { t } = useTranslation([i18nNamespace || "common", "views/settings"]);
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
// Determine which fields to hide based on advanced toggle
|
// Determine which fields to hide based on advanced toggle
|
||||||
@ -81,8 +84,16 @@ export function ConfigForm({
|
|||||||
fieldOrder,
|
fieldOrder,
|
||||||
hiddenFields: effectiveHiddenFields,
|
hiddenFields: effectiveHiddenFields,
|
||||||
advancedFields: showAdvanced ? advancedFields : [],
|
advancedFields: showAdvanced ? advancedFields : [],
|
||||||
|
i18nNamespace,
|
||||||
}),
|
}),
|
||||||
[schema, fieldOrder, effectiveHiddenFields, advancedFields, showAdvanced],
|
[
|
||||||
|
schema,
|
||||||
|
fieldOrder,
|
||||||
|
effectiveHiddenFields,
|
||||||
|
advancedFields,
|
||||||
|
showAdvanced,
|
||||||
|
i18nNamespace,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Merge generated uiSchema with custom overrides
|
// Merge generated uiSchema with custom overrides
|
||||||
@ -116,6 +127,16 @@ export function ConfigForm({
|
|||||||
|
|
||||||
const hasAdvancedFields = advancedFields && advancedFields.length > 0;
|
const hasAdvancedFields = advancedFields && advancedFields.length > 0;
|
||||||
|
|
||||||
|
// Extended form context with i18n info
|
||||||
|
const extendedFormContext = useMemo(
|
||||||
|
() => ({
|
||||||
|
...formContext,
|
||||||
|
i18nNamespace,
|
||||||
|
t,
|
||||||
|
}),
|
||||||
|
[formContext, i18nNamespace, t],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("config-form", className)}>
|
<div className={cn("config-form", className)}>
|
||||||
{hasAdvancedFields && (
|
{hasAdvancedFields && (
|
||||||
@ -130,6 +151,7 @@ export function ConfigForm({
|
|||||||
className="cursor-pointer text-sm text-muted-foreground"
|
className="cursor-pointer text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t("configForm.showAdvanced", {
|
{t("configForm.showAdvanced", {
|
||||||
|
ns: "views/settings",
|
||||||
defaultValue: "Show Advanced Settings",
|
defaultValue: "Show Advanced Settings",
|
||||||
})}
|
})}
|
||||||
</Label>
|
</Label>
|
||||||
@ -146,7 +168,7 @@ export function ConfigForm({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
readonly={readonly}
|
readonly={readonly}
|
||||||
liveValidate={liveValidate}
|
liveValidate={liveValidate}
|
||||||
formContext={formContext}
|
formContext={extendedFormContext}
|
||||||
transformErrors={errorTransformer}
|
transformErrors={errorTransformer}
|
||||||
{...frigateTheme}
|
{...frigateTheme}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,30 +1,27 @@
|
|||||||
// Audio Section Component
|
// Audio Section Component
|
||||||
// Reusable for both global and camera-level audio settings
|
// Reusable for both global and camera-level audio settings
|
||||||
|
|
||||||
import { createConfigSection, type SectionConfig } from "./BaseSection";
|
import { createConfigSection } from "./BaseSection";
|
||||||
|
|
||||||
// Configuration for the audio section
|
|
||||||
export const audioSectionConfig: SectionConfig = {
|
|
||||||
fieldOrder: [
|
|
||||||
"enabled",
|
|
||||||
"listen",
|
|
||||||
"filters",
|
|
||||||
"min_volume",
|
|
||||||
"max_not_heard",
|
|
||||||
"num_threads",
|
|
||||||
],
|
|
||||||
fieldGroups: {
|
|
||||||
detection: ["listen", "filters"],
|
|
||||||
sensitivity: ["min_volume", "max_not_heard"],
|
|
||||||
},
|
|
||||||
hiddenFields: ["enabled_in_config"],
|
|
||||||
advancedFields: ["min_volume", "max_not_heard", "num_threads"],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AudioSection = createConfigSection({
|
export const AudioSection = createConfigSection({
|
||||||
sectionPath: "audio",
|
sectionPath: "audio",
|
||||||
translationKey: "configForm.audio",
|
i18nNamespace: "config/audio",
|
||||||
defaultConfig: audioSectionConfig,
|
defaultConfig: {
|
||||||
|
fieldOrder: [
|
||||||
|
"enabled",
|
||||||
|
"listen",
|
||||||
|
"filters",
|
||||||
|
"min_volume",
|
||||||
|
"max_not_heard",
|
||||||
|
"num_threads",
|
||||||
|
],
|
||||||
|
fieldGroups: {
|
||||||
|
detection: ["listen", "filters"],
|
||||||
|
sensitivity: ["min_volume", "max_not_heard"],
|
||||||
|
},
|
||||||
|
hiddenFields: ["enabled_in_config"],
|
||||||
|
advancedFields: ["min_volume", "max_not_heard", "num_threads"],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default AudioSection;
|
export default AudioSection;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// Base Section Component for config form sections
|
// Base Section Component for config form sections
|
||||||
// Used as a foundation for reusable section components
|
// Used as a foundation for reusable section components
|
||||||
|
|
||||||
import { useMemo, useCallback } from "react";
|
import { useMemo, useCallback, useState } 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";
|
||||||
@ -10,11 +10,22 @@ import { ConfigForm } from "../ConfigForm";
|
|||||||
import { useConfigOverride } from "@/hooks/use-config-override";
|
import { useConfigOverride } from "@/hooks/use-config-override";
|
||||||
import { useSectionSchema } from "@/hooks/use-config-schema";
|
import { useSectionSchema } from "@/hooks/use-config-schema";
|
||||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { LuRotateCcw } from "react-icons/lu";
|
import {
|
||||||
|
LuRotateCcw,
|
||||||
|
LuSave,
|
||||||
|
LuChevronDown,
|
||||||
|
LuChevronRight,
|
||||||
|
} from "react-icons/lu";
|
||||||
|
import Heading from "@/components/ui/heading";
|
||||||
import get from "lodash/get";
|
import get from "lodash/get";
|
||||||
|
import isEqual from "lodash/isEqual";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
|
||||||
export interface SectionConfig {
|
export interface SectionConfig {
|
||||||
/** Field ordering within the section */
|
/** Field ordering within the section */
|
||||||
@ -44,13 +55,19 @@ export interface BaseSectionProps {
|
|||||||
onSave?: () => void;
|
onSave?: () => void;
|
||||||
/** Whether a restart is required after changes */
|
/** Whether a restart is required after changes */
|
||||||
requiresRestart?: boolean;
|
requiresRestart?: boolean;
|
||||||
|
/** Whether section is collapsible */
|
||||||
|
collapsible?: boolean;
|
||||||
|
/** Default collapsed state */
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
|
/** Whether to show the section title (default: false for global, true for camera) */
|
||||||
|
showTitle?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateSectionOptions {
|
export interface CreateSectionOptions {
|
||||||
/** The config path for this section (e.g., "detect", "record") */
|
/** The config path for this section (e.g., "detect", "record") */
|
||||||
sectionPath: string;
|
sectionPath: string;
|
||||||
/** Translation key prefix for this section */
|
/** i18n namespace for this section (e.g., "config/detect") */
|
||||||
translationKey: string;
|
i18nNamespace: string;
|
||||||
/** Default section configuration */
|
/** Default section configuration */
|
||||||
defaultConfig: SectionConfig;
|
defaultConfig: SectionConfig;
|
||||||
}
|
}
|
||||||
@ -60,10 +77,10 @@ export interface CreateSectionOptions {
|
|||||||
*/
|
*/
|
||||||
export function createConfigSection({
|
export function createConfigSection({
|
||||||
sectionPath,
|
sectionPath,
|
||||||
translationKey,
|
i18nNamespace,
|
||||||
defaultConfig,
|
defaultConfig,
|
||||||
}: CreateSectionOptions) {
|
}: CreateSectionOptions) {
|
||||||
return function ConfigSection({
|
const ConfigSection = function ConfigSection({
|
||||||
level,
|
level,
|
||||||
cameraName,
|
cameraName,
|
||||||
showOverrideIndicator = true,
|
showOverrideIndicator = true,
|
||||||
@ -72,8 +89,20 @@ export function createConfigSection({
|
|||||||
readonly = false,
|
readonly = false,
|
||||||
onSave,
|
onSave,
|
||||||
requiresRestart = true,
|
requiresRestart = true,
|
||||||
|
collapsible = false,
|
||||||
|
defaultCollapsed = false,
|
||||||
|
showTitle,
|
||||||
}: BaseSectionProps) {
|
}: BaseSectionProps) {
|
||||||
const { t } = useTranslation(["views/settings"]);
|
const { t } = useTranslation([i18nNamespace, "views/settings", "common"]);
|
||||||
|
const [isOpen, setIsOpen] = useState(!defaultCollapsed);
|
||||||
|
const [pendingData, setPendingData] = useState<Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
> | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// Default: show title for camera level (since it might be collapsible), hide for global
|
||||||
|
const shouldShowTitle = showTitle ?? level === "camera";
|
||||||
|
|
||||||
// Fetch config
|
// Fetch config
|
||||||
const { data: config, mutate: refreshConfig } =
|
const { data: config, mutate: refreshConfig } =
|
||||||
@ -83,12 +112,11 @@ export function createConfigSection({
|
|||||||
const sectionSchema = useSectionSchema(sectionPath, level);
|
const sectionSchema = useSectionSchema(sectionPath, level);
|
||||||
|
|
||||||
// Get override status
|
// Get override status
|
||||||
const { isOverridden, globalValue, cameraValue, resetToGlobal } =
|
const { isOverridden, globalValue, cameraValue } = useConfigOverride({
|
||||||
useConfigOverride({
|
config,
|
||||||
config,
|
cameraName: level === "camera" ? cameraName : undefined,
|
||||||
cameraName: level === "camera" ? cameraName : undefined,
|
sectionPath,
|
||||||
sectionPath,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Get current form data
|
// Get current form data
|
||||||
const formData = useMemo(() => {
|
const formData = useMemo(() => {
|
||||||
@ -101,119 +129,286 @@ export function createConfigSection({
|
|||||||
return get(config, sectionPath) || {};
|
return get(config, sectionPath) || {};
|
||||||
}, [config, level, cameraName]);
|
}, [config, level, cameraName]);
|
||||||
|
|
||||||
// Handle form submission
|
// Track if there are unsaved changes
|
||||||
const handleSubmit = useCallback(
|
const hasChanges = useMemo(() => {
|
||||||
async (data: Record<string, unknown>) => {
|
if (!pendingData) return false;
|
||||||
try {
|
return !isEqual(formData, pendingData);
|
||||||
const basePath =
|
}, [formData, pendingData]);
|
||||||
level === "camera" && cameraName
|
|
||||||
? `cameras.${cameraName}.${sectionPath}`
|
|
||||||
: sectionPath;
|
|
||||||
|
|
||||||
await axios.put("config/set", {
|
// Handle form data change
|
||||||
requires_restart: requiresRestart ? 1 : 0,
|
const handleChange = useCallback((data: Record<string, unknown>) => {
|
||||||
config_data: {
|
setPendingData(data);
|
||||||
[basePath]: data,
|
}, []);
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.success(
|
// Handle save button click
|
||||||
t(`${translationKey}.toast.success`, {
|
const handleSave = useCallback(async () => {
|
||||||
defaultValue: "Settings saved successfully",
|
if (!pendingData) return;
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
refreshConfig();
|
setIsSaving(true);
|
||||||
onSave?.();
|
try {
|
||||||
} catch (error) {
|
const basePath =
|
||||||
// Parse Pydantic validation errors from API response
|
level === "camera" && cameraName
|
||||||
if (axios.isAxiosError(error) && error.response?.data) {
|
? `cameras.${cameraName}.${sectionPath}`
|
||||||
const responseData = error.response.data;
|
: sectionPath;
|
||||||
// Pydantic errors come as { detail: [...] } or { message: "..." }
|
|
||||||
if (responseData.detail && Array.isArray(responseData.detail)) {
|
await axios.put("config/set", {
|
||||||
const validationMessages = responseData.detail
|
requires_restart: requiresRestart ? 1 : 0,
|
||||||
.map((err: { loc?: string[]; msg?: string }) => {
|
config_data: {
|
||||||
const field = err.loc?.slice(1).join(".") || "unknown";
|
[basePath]: pendingData,
|
||||||
return `${field}: ${err.msg || "Invalid value"}`;
|
},
|
||||||
})
|
});
|
||||||
.join(", ");
|
|
||||||
toast.error(
|
toast.success(
|
||||||
t(`${translationKey}.toast.validationError`, {
|
t("toast.success", {
|
||||||
defaultValue: `Validation failed: ${validationMessages}`,
|
ns: "views/settings",
|
||||||
}),
|
defaultValue: "Settings saved successfully",
|
||||||
);
|
}),
|
||||||
} else if (responseData.message) {
|
);
|
||||||
toast.error(responseData.message);
|
|
||||||
} else {
|
setPendingData(null);
|
||||||
toast.error(
|
refreshConfig();
|
||||||
t(`${translationKey}.toast.error`, {
|
onSave?.();
|
||||||
defaultValue: "Failed to save settings",
|
} catch (error) {
|
||||||
}),
|
// Parse Pydantic validation errors from API response
|
||||||
);
|
if (axios.isAxiosError(error) && error.response?.data) {
|
||||||
}
|
const responseData = error.response.data;
|
||||||
|
if (responseData.detail && Array.isArray(responseData.detail)) {
|
||||||
|
const validationMessages = responseData.detail
|
||||||
|
.map((err: { loc?: string[]; msg?: string }) => {
|
||||||
|
const field = err.loc?.slice(1).join(".") || "unknown";
|
||||||
|
return `${field}: ${err.msg || "Invalid value"}`;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
toast.error(
|
||||||
|
t("toast.validationError", {
|
||||||
|
ns: "views/settings",
|
||||||
|
defaultValue: `Validation failed: ${validationMessages}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (responseData.message) {
|
||||||
|
toast.error(responseData.message);
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
t(`${translationKey}.toast.error`, {
|
t("toast.error", {
|
||||||
|
ns: "views/settings",
|
||||||
defaultValue: "Failed to save settings",
|
defaultValue: "Failed to save settings",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
t("toast.error", {
|
||||||
|
ns: "views/settings",
|
||||||
|
defaultValue: "Failed to save settings",
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
} finally {
|
||||||
[level, cameraName, requiresRestart, t, refreshConfig, onSave],
|
setIsSaving(false);
|
||||||
);
|
}
|
||||||
|
}, [
|
||||||
|
pendingData,
|
||||||
|
level,
|
||||||
|
cameraName,
|
||||||
|
requiresRestart,
|
||||||
|
t,
|
||||||
|
refreshConfig,
|
||||||
|
onSave,
|
||||||
|
]);
|
||||||
|
|
||||||
// Handle reset to global
|
// Handle reset to global - removes camera-level override by deleting the section
|
||||||
const handleResetToGlobal = useCallback(async () => {
|
const handleResetToGlobal = useCallback(async () => {
|
||||||
if (level !== "camera" || !cameraName) return;
|
if (level !== "camera" || !cameraName) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const basePath = `cameras.${cameraName}.${sectionPath}`;
|
const basePath = `cameras.${cameraName}.${sectionPath}`;
|
||||||
|
|
||||||
// Reset by setting to null/undefined or removing the override
|
// Send empty string to delete the key from config (see update_yaml in backend)
|
||||||
await axios.put("config/set", {
|
await axios.put("config/set", {
|
||||||
requires_restart: requiresRestart ? 1 : 0,
|
requires_restart: requiresRestart ? 1 : 0,
|
||||||
config_data: {
|
config_data: {
|
||||||
[basePath]: resetToGlobal(),
|
[basePath]: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
t(`${translationKey}.toast.resetSuccess`, {
|
t("toast.resetSuccess", {
|
||||||
|
ns: "views/settings",
|
||||||
defaultValue: "Reset to global defaults",
|
defaultValue: "Reset to global defaults",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
setPendingData(null);
|
||||||
refreshConfig();
|
refreshConfig();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(
|
toast.error(
|
||||||
t(`${translationKey}.toast.resetError`, {
|
t("toast.resetError", {
|
||||||
|
ns: "views/settings",
|
||||||
defaultValue: "Failed to reset settings",
|
defaultValue: "Failed to reset settings",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [level, cameraName, requiresRestart, t, refreshConfig, resetToGlobal]);
|
}, [level, cameraName, requiresRestart, t, refreshConfig]);
|
||||||
|
|
||||||
if (!sectionSchema) {
|
if (!sectionSchema) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = t(`${translationKey}.title`, {
|
// Get section title from config namespace
|
||||||
defaultValue: sectionPath.charAt(0).toUpperCase() + sectionPath.slice(1),
|
const title = t("label", {
|
||||||
|
ns: i18nNamespace,
|
||||||
|
defaultValue:
|
||||||
|
sectionPath.charAt(0).toUpperCase() +
|
||||||
|
sectionPath.slice(1).replace(/_/g, " "),
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
const sectionContent = (
|
||||||
<Card>
|
<div className="space-y-6">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<ConfigForm
|
||||||
|
schema={sectionSchema}
|
||||||
|
formData={pendingData || formData}
|
||||||
|
onChange={handleChange}
|
||||||
|
fieldOrder={sectionConfig.fieldOrder}
|
||||||
|
hiddenFields={sectionConfig.hiddenFields}
|
||||||
|
advancedFields={sectionConfig.advancedFields}
|
||||||
|
disabled={disabled || isSaving}
|
||||||
|
readonly={readonly}
|
||||||
|
showSubmit={false}
|
||||||
|
i18nNamespace={i18nNamespace}
|
||||||
|
formContext={{
|
||||||
|
level,
|
||||||
|
cameraName,
|
||||||
|
globalValue,
|
||||||
|
cameraValue,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Save button */}
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle className="text-lg">{title}</CardTitle>
|
{hasChanges && (
|
||||||
{showOverrideIndicator && level === "camera" && isOverridden && (
|
<span className="text-sm text-muted-foreground">
|
||||||
<Badge variant="secondary" className="text-xs">
|
{t("unsavedChanges", {
|
||||||
{t("common.overridden", { defaultValue: "Overridden" })}
|
ns: "views/settings",
|
||||||
</Badge>
|
defaultValue: "You have unsaved changes",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{level === "camera" && isOverridden && (
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges || isSaving || disabled}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (collapsible) {
|
||||||
|
return (
|
||||||
|
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<div className="flex cursor-pointer items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isOpen ? (
|
||||||
|
<LuChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<LuChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<Heading as="h4">{title}</Heading>
|
||||||
|
{showOverrideIndicator &&
|
||||||
|
level === "camera" &&
|
||||||
|
isOverridden && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{t("overridden", {
|
||||||
|
ns: "common",
|
||||||
|
defaultValue: "Overridden",
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{hasChanges && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{t("modified", {
|
||||||
|
ns: "common",
|
||||||
|
defaultValue: "Modified",
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{level === "camera" && isOverridden && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleResetToGlobal();
|
||||||
|
}}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<LuRotateCcw className="h-4 w-4" />
|
||||||
|
{t("button.resetToGlobal", {
|
||||||
|
ns: "common",
|
||||||
|
defaultValue: "Reset to Global",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="pl-7">{sectionContent}</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{shouldShowTitle && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Heading as="h4">{title}</Heading>
|
||||||
|
{showOverrideIndicator && level === "camera" && isOverridden && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{t("overridden", {
|
||||||
|
ns: "common",
|
||||||
|
defaultValue: "Overridden",
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{hasChanges && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{t("modified", { ns: "common", defaultValue: "Modified" })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{level === "camera" && isOverridden && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleResetToGlobal}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<LuRotateCcw className="h-4 w-4" />
|
||||||
|
{t("button.resetToGlobal", {
|
||||||
|
ns: "common",
|
||||||
|
defaultValue: "Reset to Global",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reset button when title is hidden but we're at camera level with override */}
|
||||||
|
{!shouldShowTitle && level === "camera" && isOverridden && (
|
||||||
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -221,29 +416,18 @@ export function createConfigSection({
|
|||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<LuRotateCcw className="h-4 w-4" />
|
<LuRotateCcw className="h-4 w-4" />
|
||||||
{t("common.resetToGlobal", { defaultValue: "Reset to Global" })}
|
{t("button.resetToGlobal", {
|
||||||
|
ns: "common",
|
||||||
|
defaultValue: "Reset to Global",
|
||||||
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
</CardHeader>
|
)}
|
||||||
<CardContent>
|
|
||||||
<ConfigForm
|
{sectionContent}
|
||||||
schema={sectionSchema}
|
</div>
|
||||||
formData={formData}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
fieldOrder={sectionConfig.fieldOrder}
|
|
||||||
hiddenFields={sectionConfig.hiddenFields}
|
|
||||||
advancedFields={sectionConfig.advancedFields}
|
|
||||||
disabled={disabled}
|
|
||||||
readonly={readonly}
|
|
||||||
formContext={{
|
|
||||||
level,
|
|
||||||
cameraName,
|
|
||||||
globalValue,
|
|
||||||
cameraValue,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return ConfigSection;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,37 +1,34 @@
|
|||||||
// Detect Section Component
|
// Detect Section Component
|
||||||
// Reusable for both global and camera-level detect settings
|
// Reusable for both global and camera-level detect settings
|
||||||
|
|
||||||
import { createConfigSection, type SectionConfig } from "./BaseSection";
|
import { createConfigSection } from "./BaseSection";
|
||||||
|
|
||||||
// Configuration for the detect section
|
|
||||||
export const detectSectionConfig: SectionConfig = {
|
|
||||||
fieldOrder: [
|
|
||||||
"enabled",
|
|
||||||
"fps",
|
|
||||||
"width",
|
|
||||||
"height",
|
|
||||||
"min_initialized",
|
|
||||||
"max_disappeared",
|
|
||||||
"annotation_offset",
|
|
||||||
"stationary",
|
|
||||||
],
|
|
||||||
fieldGroups: {
|
|
||||||
resolution: ["width", "height"],
|
|
||||||
tracking: ["min_initialized", "max_disappeared"],
|
|
||||||
},
|
|
||||||
hiddenFields: ["enabled_in_config"],
|
|
||||||
advancedFields: [
|
|
||||||
"min_initialized",
|
|
||||||
"max_disappeared",
|
|
||||||
"annotation_offset",
|
|
||||||
"stationary",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DetectSection = createConfigSection({
|
export const DetectSection = createConfigSection({
|
||||||
sectionPath: "detect",
|
sectionPath: "detect",
|
||||||
translationKey: "configForm.detect",
|
i18nNamespace: "config/detect",
|
||||||
defaultConfig: detectSectionConfig,
|
defaultConfig: {
|
||||||
|
fieldOrder: [
|
||||||
|
"enabled",
|
||||||
|
"fps",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"min_initialized",
|
||||||
|
"max_disappeared",
|
||||||
|
"annotation_offset",
|
||||||
|
"stationary",
|
||||||
|
],
|
||||||
|
fieldGroups: {
|
||||||
|
resolution: ["width", "height"],
|
||||||
|
tracking: ["min_initialized", "max_disappeared"],
|
||||||
|
},
|
||||||
|
hiddenFields: ["enabled_in_config"],
|
||||||
|
advancedFields: [
|
||||||
|
"min_initialized",
|
||||||
|
"max_disappeared",
|
||||||
|
"annotation_offset",
|
||||||
|
"stationary",
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default DetectSection;
|
export default DetectSection;
|
||||||
|
|||||||
@ -1,20 +1,17 @@
|
|||||||
// Live Section Component
|
// Live Section Component
|
||||||
// Reusable for both global and camera-level live settings
|
// Reusable for both global and camera-level live settings
|
||||||
|
|
||||||
import { createConfigSection, type SectionConfig } from "./BaseSection";
|
import { createConfigSection } from "./BaseSection";
|
||||||
|
|
||||||
// Configuration for the live section
|
|
||||||
export const liveSectionConfig: SectionConfig = {
|
|
||||||
fieldOrder: ["stream_name", "height", "quality"],
|
|
||||||
fieldGroups: {},
|
|
||||||
hiddenFields: ["enabled_in_config"],
|
|
||||||
advancedFields: ["quality"],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LiveSection = createConfigSection({
|
export const LiveSection = createConfigSection({
|
||||||
sectionPath: "live",
|
sectionPath: "live",
|
||||||
translationKey: "configForm.live",
|
i18nNamespace: "config/live",
|
||||||
defaultConfig: liveSectionConfig,
|
defaultConfig: {
|
||||||
|
fieldOrder: ["stream_name", "height", "quality"],
|
||||||
|
fieldGroups: {},
|
||||||
|
hiddenFields: ["enabled_in_config"],
|
||||||
|
advancedFields: ["quality"],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default LiveSection;
|
export default LiveSection;
|
||||||
|
|||||||
@ -1,42 +1,39 @@
|
|||||||
// Motion Section Component
|
// Motion Section Component
|
||||||
// Reusable for both global and camera-level motion settings
|
// Reusable for both global and camera-level motion settings
|
||||||
|
|
||||||
import { createConfigSection, type SectionConfig } from "./BaseSection";
|
import { createConfigSection } from "./BaseSection";
|
||||||
|
|
||||||
// Configuration for the motion section
|
|
||||||
export const motionSectionConfig: SectionConfig = {
|
|
||||||
fieldOrder: [
|
|
||||||
"enabled",
|
|
||||||
"threshold",
|
|
||||||
"lightning_threshold",
|
|
||||||
"improve_contrast",
|
|
||||||
"contour_area",
|
|
||||||
"delta_alpha",
|
|
||||||
"frame_alpha",
|
|
||||||
"frame_height",
|
|
||||||
"mask",
|
|
||||||
"mqtt_off_delay",
|
|
||||||
],
|
|
||||||
fieldGroups: {
|
|
||||||
sensitivity: ["threshold", "lightning_threshold", "contour_area"],
|
|
||||||
algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"],
|
|
||||||
},
|
|
||||||
hiddenFields: ["enabled_in_config"],
|
|
||||||
advancedFields: [
|
|
||||||
"lightning_threshold",
|
|
||||||
"improve_contrast",
|
|
||||||
"contour_area",
|
|
||||||
"delta_alpha",
|
|
||||||
"frame_alpha",
|
|
||||||
"frame_height",
|
|
||||||
"mqtt_off_delay",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MotionSection = createConfigSection({
|
export const MotionSection = createConfigSection({
|
||||||
sectionPath: "motion",
|
sectionPath: "motion",
|
||||||
translationKey: "configForm.motion",
|
i18nNamespace: "config/motion",
|
||||||
defaultConfig: motionSectionConfig,
|
defaultConfig: {
|
||||||
|
fieldOrder: [
|
||||||
|
"enabled",
|
||||||
|
"threshold",
|
||||||
|
"lightning_threshold",
|
||||||
|
"improve_contrast",
|
||||||
|
"contour_area",
|
||||||
|
"delta_alpha",
|
||||||
|
"frame_alpha",
|
||||||
|
"frame_height",
|
||||||
|
"mask",
|
||||||
|
"mqtt_off_delay",
|
||||||
|
],
|
||||||
|
fieldGroups: {
|
||||||
|
sensitivity: ["threshold", "lightning_threshold", "contour_area"],
|
||||||
|
algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"],
|
||||||
|
},
|
||||||
|
hiddenFields: ["enabled_in_config"],
|
||||||
|
advancedFields: [
|
||||||
|
"lightning_threshold",
|
||||||
|
"improve_contrast",
|
||||||
|
"contour_area",
|
||||||
|
"delta_alpha",
|
||||||
|
"frame_alpha",
|
||||||
|
"frame_height",
|
||||||
|
"mqtt_off_delay",
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default MotionSection;
|
export default MotionSection;
|
||||||
|
|||||||
@ -1,20 +1,17 @@
|
|||||||
// Notifications Section Component
|
// Notifications Section Component
|
||||||
// Reusable for both global and camera-level notification settings
|
// Reusable for both global and camera-level notification settings
|
||||||
|
|
||||||
import { createConfigSection, type SectionConfig } from "./BaseSection";
|
import { createConfigSection } from "./BaseSection";
|
||||||
|
|
||||||
// Configuration for the notifications section
|
|
||||||
export const notificationsSectionConfig: SectionConfig = {
|
|
||||||
fieldOrder: ["enabled", "email"],
|
|
||||||
fieldGroups: {},
|
|
||||||
hiddenFields: ["enabled_in_config"],
|
|
||||||
advancedFields: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NotificationsSection = createConfigSection({
|
export const NotificationsSection = createConfigSection({
|
||||||
sectionPath: "notifications",
|
sectionPath: "notifications",
|
||||||
translationKey: "configForm.notifications",
|
i18nNamespace: "config/notifications",
|
||||||
defaultConfig: notificationsSectionConfig,
|
defaultConfig: {
|
||||||
|
fieldOrder: ["enabled", "email"],
|
||||||
|
fieldGroups: {},
|
||||||
|
hiddenFields: ["enabled_in_config"],
|
||||||
|
advancedFields: [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default NotificationsSection;
|
export default NotificationsSection;
|
||||||
|
|||||||
@ -1,23 +1,20 @@
|
|||||||
// Objects Section Component
|
// Objects Section Component
|
||||||
// Reusable for both global and camera-level objects settings
|
// Reusable for both global and camera-level objects settings
|
||||||
|
|
||||||
import { createConfigSection, type SectionConfig } from "./BaseSection";
|
import { createConfigSection } from "./BaseSection";
|
||||||
|
|
||||||
// Configuration for the objects section
|
|
||||||
export const objectsSectionConfig: SectionConfig = {
|
|
||||||
fieldOrder: ["track", "alert", "detect", "filters", "mask"],
|
|
||||||
fieldGroups: {
|
|
||||||
tracking: ["track", "alert", "detect"],
|
|
||||||
filtering: ["filters", "mask"],
|
|
||||||
},
|
|
||||||
hiddenFields: ["enabled_in_config"],
|
|
||||||
advancedFields: ["filters", "mask"],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ObjectsSection = createConfigSection({
|
export const ObjectsSection = createConfigSection({
|
||||||
sectionPath: "objects",
|
sectionPath: "objects",
|
||||||
translationKey: "configForm.objects",
|
i18nNamespace: "config/objects",
|
||||||
defaultConfig: objectsSectionConfig,
|
defaultConfig: {
|
||||||
|
fieldOrder: ["track", "alert", "detect", "filters", "mask"],
|
||||||
|
fieldGroups: {
|
||||||
|
tracking: ["track", "alert", "detect"],
|
||||||
|
filtering: ["filters", "mask"],
|
||||||
|
},
|
||||||
|
hiddenFields: ["enabled_in_config"],
|
||||||
|
advancedFields: ["filters", "mask"],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default ObjectsSection;
|
export default ObjectsSection;
|
||||||
|
|||||||
@ -1,32 +1,29 @@
|
|||||||
// Record Section Component
|
// Record Section Component
|
||||||
// Reusable for both global and camera-level record settings
|
// Reusable for both global and camera-level record settings
|
||||||
|
|
||||||
import { createConfigSection, type SectionConfig } from "./BaseSection";
|
import { createConfigSection } from "./BaseSection";
|
||||||
|
|
||||||
// Configuration for the record section
|
|
||||||
export const recordSectionConfig: SectionConfig = {
|
|
||||||
fieldOrder: [
|
|
||||||
"enabled",
|
|
||||||
"expire_interval",
|
|
||||||
"continuous",
|
|
||||||
"motion",
|
|
||||||
"alerts",
|
|
||||||
"detections",
|
|
||||||
"preview",
|
|
||||||
"export",
|
|
||||||
],
|
|
||||||
fieldGroups: {
|
|
||||||
retention: ["continuous", "motion"],
|
|
||||||
events: ["alerts", "detections"],
|
|
||||||
},
|
|
||||||
hiddenFields: ["enabled_in_config"],
|
|
||||||
advancedFields: ["expire_interval", "preview", "export"],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RecordSection = createConfigSection({
|
export const RecordSection = createConfigSection({
|
||||||
sectionPath: "record",
|
sectionPath: "record",
|
||||||
translationKey: "configForm.record",
|
i18nNamespace: "config/record",
|
||||||
defaultConfig: recordSectionConfig,
|
defaultConfig: {
|
||||||
|
fieldOrder: [
|
||||||
|
"enabled",
|
||||||
|
"expire_interval",
|
||||||
|
"continuous",
|
||||||
|
"motion",
|
||||||
|
"alerts",
|
||||||
|
"detections",
|
||||||
|
"preview",
|
||||||
|
"export",
|
||||||
|
],
|
||||||
|
fieldGroups: {
|
||||||
|
retention: ["continuous", "motion"],
|
||||||
|
events: ["alerts", "detections"],
|
||||||
|
},
|
||||||
|
hiddenFields: ["enabled_in_config"],
|
||||||
|
advancedFields: ["expire_interval", "preview", "export"],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default RecordSection;
|
export default RecordSection;
|
||||||
|
|||||||
@ -1,20 +1,17 @@
|
|||||||
// Review Section Component
|
// Review Section Component
|
||||||
// Reusable for both global and camera-level review settings
|
// Reusable for both global and camera-level review settings
|
||||||
|
|
||||||
import { createConfigSection, type SectionConfig } from "./BaseSection";
|
import { createConfigSection } from "./BaseSection";
|
||||||
|
|
||||||
// Configuration for the review section
|
|
||||||
export const reviewSectionConfig: SectionConfig = {
|
|
||||||
fieldOrder: ["alerts", "detections"],
|
|
||||||
fieldGroups: {},
|
|
||||||
hiddenFields: ["enabled_in_config"],
|
|
||||||
advancedFields: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ReviewSection = createConfigSection({
|
export const ReviewSection = createConfigSection({
|
||||||
sectionPath: "review",
|
sectionPath: "review",
|
||||||
translationKey: "configForm.review",
|
i18nNamespace: "config/review",
|
||||||
defaultConfig: reviewSectionConfig,
|
defaultConfig: {
|
||||||
|
fieldOrder: ["alerts", "detections"],
|
||||||
|
fieldGroups: {},
|
||||||
|
hiddenFields: ["enabled_in_config"],
|
||||||
|
advancedFields: [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default ReviewSection;
|
export default ReviewSection;
|
||||||
|
|||||||
@ -1,29 +1,26 @@
|
|||||||
// Snapshots Section Component
|
// Snapshots Section Component
|
||||||
// Reusable for both global and camera-level snapshots settings
|
// Reusable for both global and camera-level snapshots settings
|
||||||
|
|
||||||
import { createConfigSection, type SectionConfig } from "./BaseSection";
|
import { createConfigSection } from "./BaseSection";
|
||||||
|
|
||||||
// Configuration for the snapshots section
|
|
||||||
export const snapshotsSectionConfig: SectionConfig = {
|
|
||||||
fieldOrder: [
|
|
||||||
"enabled",
|
|
||||||
"bounding_box",
|
|
||||||
"crop",
|
|
||||||
"quality",
|
|
||||||
"timestamp",
|
|
||||||
"retain",
|
|
||||||
],
|
|
||||||
fieldGroups: {
|
|
||||||
display: ["bounding_box", "crop", "quality", "timestamp"],
|
|
||||||
},
|
|
||||||
hiddenFields: ["enabled_in_config"],
|
|
||||||
advancedFields: ["quality", "retain"],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SnapshotsSection = createConfigSection({
|
export const SnapshotsSection = createConfigSection({
|
||||||
sectionPath: "snapshots",
|
sectionPath: "snapshots",
|
||||||
translationKey: "configForm.snapshots",
|
i18nNamespace: "config/snapshots",
|
||||||
defaultConfig: snapshotsSectionConfig,
|
defaultConfig: {
|
||||||
|
fieldOrder: [
|
||||||
|
"enabled",
|
||||||
|
"bounding_box",
|
||||||
|
"crop",
|
||||||
|
"quality",
|
||||||
|
"timestamp",
|
||||||
|
"retain",
|
||||||
|
],
|
||||||
|
fieldGroups: {
|
||||||
|
display: ["bounding_box", "crop", "quality", "timestamp"],
|
||||||
|
},
|
||||||
|
hiddenFields: ["enabled_in_config"],
|
||||||
|
advancedFields: ["quality", "retain"],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default SnapshotsSection;
|
export default SnapshotsSection;
|
||||||
|
|||||||
@ -1,22 +1,19 @@
|
|||||||
// Timestamp Section Component
|
// Timestamp Section Component
|
||||||
// Reusable for both global and camera-level timestamp_style settings
|
// Reusable for both global and camera-level timestamp_style settings
|
||||||
|
|
||||||
import { createConfigSection, type SectionConfig } from "./BaseSection";
|
import { createConfigSection } from "./BaseSection";
|
||||||
|
|
||||||
// Configuration for the timestamp_style section
|
|
||||||
export const timestampSectionConfig: SectionConfig = {
|
|
||||||
fieldOrder: ["position", "format", "color", "thickness", "effect"],
|
|
||||||
fieldGroups: {
|
|
||||||
appearance: ["color", "thickness", "effect"],
|
|
||||||
},
|
|
||||||
hiddenFields: ["enabled_in_config"],
|
|
||||||
advancedFields: ["thickness", "effect"],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TimestampSection = createConfigSection({
|
export const TimestampSection = createConfigSection({
|
||||||
sectionPath: "timestamp_style",
|
sectionPath: "timestamp_style",
|
||||||
translationKey: "configForm.timestampStyle",
|
i18nNamespace: "config/timestamp_style",
|
||||||
defaultConfig: timestampSectionConfig,
|
defaultConfig: {
|
||||||
|
fieldOrder: ["position", "format", "color", "thickness", "effect"],
|
||||||
|
fieldGroups: {
|
||||||
|
appearance: ["color", "thickness", "effect"],
|
||||||
|
},
|
||||||
|
hiddenFields: ["enabled_in_config"],
|
||||||
|
advancedFields: ["thickness", "effect"],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default TimestampSection;
|
export default TimestampSection;
|
||||||
|
|||||||
@ -8,16 +8,13 @@ export {
|
|||||||
type CreateSectionOptions,
|
type CreateSectionOptions,
|
||||||
} from "./BaseSection";
|
} from "./BaseSection";
|
||||||
|
|
||||||
export { DetectSection, detectSectionConfig } from "./DetectSection";
|
export { DetectSection } from "./DetectSection";
|
||||||
export { RecordSection, recordSectionConfig } from "./RecordSection";
|
export { RecordSection } from "./RecordSection";
|
||||||
export { SnapshotsSection, snapshotsSectionConfig } from "./SnapshotsSection";
|
export { SnapshotsSection } from "./SnapshotsSection";
|
||||||
export { MotionSection, motionSectionConfig } from "./MotionSection";
|
export { MotionSection } from "./MotionSection";
|
||||||
export { ObjectsSection, objectsSectionConfig } from "./ObjectsSection";
|
export { ObjectsSection } from "./ObjectsSection";
|
||||||
export { ReviewSection, reviewSectionConfig } from "./ReviewSection";
|
export { ReviewSection } from "./ReviewSection";
|
||||||
export { AudioSection, audioSectionConfig } from "./AudioSection";
|
export { AudioSection } from "./AudioSection";
|
||||||
export {
|
export { NotificationsSection } from "./NotificationsSection";
|
||||||
NotificationsSection,
|
export { LiveSection } from "./LiveSection";
|
||||||
notificationsSectionConfig,
|
export { TimestampSection } from "./TimestampSection";
|
||||||
} from "./NotificationsSection";
|
|
||||||
export { LiveSection, liveSectionConfig } from "./LiveSection";
|
|
||||||
export { TimestampSection, timestampSectionConfig } from "./TimestampSection";
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user