frigate/web/src/components/config-form/sections/BaseSection.tsx
2026-02-27 09:40:21 -06:00

984 lines
31 KiB
TypeScript

// Base Section Component for config form sections
// Used as a foundation for reusable section components
import { useMemo, useCallback, useState, useEffect, useRef } from "react";
import useSWR from "swr";
import axios from "axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import sectionRenderers, {
RendererComponent,
} from "@/components/config-form/sectionExtras/registry";
import { ConfigForm } from "../ConfigForm";
import type { FormValidation, UiSchema } from "@rjsf/utils";
import {
modifySchemaForSection,
getEffectiveDefaultsForSection,
sanitizeOverridesForSection,
} from "./section-special-cases";
import { getSectionValidation } from "../section-validations";
import { useConfigOverride } from "@/hooks/use-config-override";
import { useSectionSchema } from "@/hooks/use-config-schema";
import type { FrigateConfig } from "@/types/frigateConfig";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import Heading from "@/components/ui/heading";
import get from "lodash/get";
import cloneDeep from "lodash/cloneDeep";
import isEqual from "lodash/isEqual";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { applySchemaDefaults } from "@/lib/config-schema";
import { cn } from "@/lib/utils";
import { ConfigSectionData, JsonValue } from "@/types/configForm";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import {
cameraUpdateTopicMap,
buildOverrides,
sanitizeSectionData as sharedSanitizeSectionData,
requiresRestartForOverrides as sharedRequiresRestartForOverrides,
} from "@/utils/configSaveUtil";
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
import { useRestart } from "@/api/ws";
export interface SectionConfig {
/** Field ordering within the section */
fieldOrder?: string[];
/** Fields to group together */
fieldGroups?: Record<string, string[]>;
/** Fields to hide from UI */
hiddenFields?: string[];
/** Fields to show in advanced section */
advancedFields?: string[];
/** Fields to compare for override detection */
overrideFields?: string[];
/** Documentation link for the section */
sectionDocs?: string;
/** Per-field documentation links */
fieldDocs?: Record<string, string>;
/** Fields that require restart when modified (empty means none; undefined uses default) */
restartRequired?: string[];
/** Whether to enable live validation */
liveValidate?: boolean;
/** Additional uiSchema overrides */
uiSchema?: UiSchema;
/** Optional per-section renderers usable by FieldTemplate `ui:before`/`ui:after` */
renderers?: Record<string, RendererComponent>;
/** Optional custom validation for section data */
customValidate?: (
formData: unknown,
errors: FormValidation,
) => FormValidation;
}
export interface BaseSectionProps {
/** Whether this is at global or camera level */
level: "global" | "camera";
/** Camera name (required if level is "camera") */
cameraName?: string;
/** Whether to show override indicator badge */
showOverrideIndicator?: boolean;
/** Custom section configuration */
sectionConfig?: SectionConfig;
/** Whether the section is disabled */
disabled?: boolean;
/** Whether the section is read-only */
readonly?: boolean;
/** Callback when settings are saved */
onSave?: () => void;
/** Whether a restart is required after changes */
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;
/** Callback when section status changes */
onStatusChange?: (status: {
hasChanges: boolean;
isOverridden: boolean;
}) => void;
/** Pending form data keyed by "sectionKey" or "cameraName::sectionKey" */
pendingDataBySection?: Record<string, unknown>;
/** Callback to update pending data for a section */
onPendingDataChange?: (
sectionKey: string,
cameraName: string | undefined,
data: ConfigSectionData | null,
) => void;
}
export interface CreateSectionOptions {
/** The config path for this section (e.g., "detect", "record") */
sectionPath: string;
/** Default section configuration */
defaultConfig: SectionConfig;
}
export type ConfigSectionProps = BaseSectionProps & CreateSectionOptions;
export function ConfigSection({
sectionPath,
defaultConfig,
level,
cameraName,
showOverrideIndicator = true,
sectionConfig = defaultConfig,
disabled = false,
readonly = false,
onSave,
requiresRestart = true,
collapsible = false,
defaultCollapsed = true,
showTitle,
onStatusChange,
pendingDataBySection,
onPendingDataChange,
}: ConfigSectionProps) {
const { t, i18n } = useTranslation([
level === "camera" ? "config/cameras" : "config/global",
"config/cameras",
"views/settings",
"common",
"components/dialog",
]);
const [isOpen, setIsOpen] = useState(!defaultCollapsed);
const { send: sendRestart } = useRestart();
// Create a key for this section's pending data
const pendingDataKey = useMemo(
() =>
level === "camera" && cameraName
? `${cameraName}::${sectionPath}`
: sectionPath,
[level, cameraName, sectionPath],
);
// Use pending data from parent if available, otherwise use local state
const [localPendingData, setLocalPendingData] =
useState<ConfigSectionData | null>(null);
const [pendingOverrides, setPendingOverrides] = useState<
JsonValue | undefined
>(undefined);
const [dirtyOverrides, setDirtyOverrides] = useState<JsonValue | undefined>(
undefined,
);
const [baselineFormData, setBaselineFormData] =
useState<ConfigSectionData | null>(null);
const pendingData =
pendingDataBySection !== undefined
? (pendingDataBySection[pendingDataKey] as ConfigSectionData | null)
: localPendingData;
const setPendingData = useCallback(
(data: ConfigSectionData | null) => {
if (onPendingDataChange) {
onPendingDataChange(sectionPath, cameraName, data);
} else {
setLocalPendingData(data);
}
},
[onPendingDataChange, sectionPath, cameraName],
);
const [isSaving, setIsSaving] = useState(false);
const [formKey, setFormKey] = useState(0);
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const isResettingRef = useRef(false);
const isInitializingRef = useRef(true);
const updateTopic =
level === "camera" && cameraName
? cameraUpdateTopicMap[sectionPath]
? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}`
: undefined
: `config/${sectionPath}`;
// Default: show title for camera level (since it might be collapsible), hide for global
const shouldShowTitle = showTitle ?? level === "camera";
// Fetch config
const { data: config, mutate: refreshConfig } =
useSWR<FrigateConfig>("config");
// Get section schema using cached hook
const sectionSchema = useSectionSchema(sectionPath, level);
// Apply special case handling for sections with problematic schema defaults
const modifiedSchema = useMemo(
() =>
modifySchemaForSection(sectionPath, level, sectionSchema ?? undefined),
[sectionPath, level, sectionSchema],
);
// Get override status
const { isOverridden, globalValue, cameraValue } = useConfigOverride({
config,
cameraName: level === "camera" ? cameraName : undefined,
sectionPath,
compareFields: sectionConfig.overrideFields,
});
// Get current form data
const rawSectionValue = useMemo(() => {
if (!config) return undefined;
if (level === "camera" && cameraName) {
return get(config.cameras?.[cameraName], sectionPath);
}
return get(config, sectionPath);
}, [config, level, cameraName, sectionPath]);
const rawFormData = useMemo(() => {
if (!config) return {};
if (rawSectionValue === undefined || rawSectionValue === null) {
return {};
}
return rawSectionValue;
}, [config, rawSectionValue]);
const sanitizeSectionData = useCallback(
(data: ConfigSectionData) =>
sharedSanitizeSectionData(data, sectionConfig.hiddenFields),
[sectionConfig.hiddenFields],
);
const formData = useMemo(() => {
const baseData = modifiedSchema
? applySchemaDefaults(modifiedSchema, rawFormData)
: rawFormData;
return sanitizeSectionData(baseData);
}, [rawFormData, modifiedSchema, sanitizeSectionData]);
const schemaDefaults = useMemo(() => {
if (!modifiedSchema) {
return {};
}
return applySchemaDefaults(modifiedSchema, {});
}, [modifiedSchema]);
// Get effective defaults, handling special cases where schema defaults
// don't match semantic intent
const effectiveSchemaDefaults = useMemo(
() =>
getEffectiveDefaultsForSection(
sectionPath,
level,
modifiedSchema,
schemaDefaults,
),
[level, schemaDefaults, sectionPath, modifiedSchema],
);
const compareBaseData = useMemo(
() => sanitizeSectionData(rawFormData as ConfigSectionData),
[rawFormData, sanitizeSectionData],
);
// Clear pendingData whenever formData changes (e.g., from server refresh)
// This prevents RJSF's initial onChange call from being treated as a user edit
// Only clear if pendingData is managed locally (not by parent)
useEffect(() => {
if (!pendingData) {
isInitializingRef.current = true;
setPendingOverrides(undefined);
setDirtyOverrides(undefined);
setBaselineFormData(cloneDeep(formData as ConfigSectionData));
}
if (onPendingDataChange === undefined) {
setPendingData(null);
}
}, [
formData,
pendingData,
setPendingData,
setBaselineFormData,
onPendingDataChange,
]);
useEffect(() => {
if (isResettingRef.current) {
isResettingRef.current = false;
}
}, [formKey]);
// Track if there are unsaved changes
const hasChanges = useMemo(() => {
if (!pendingData) return false;
return !isEqual(formData, pendingData);
}, [formData, pendingData]);
useEffect(() => {
onStatusChange?.({ hasChanges, isOverridden });
}, [hasChanges, isOverridden, onStatusChange]);
// Handle form data change
const handleChange = useCallback(
(data: unknown) => {
if (isResettingRef.current) {
setPendingData(null);
setPendingOverrides(undefined);
return;
}
if (!data || typeof data !== "object") {
setPendingData(null);
setPendingOverrides(undefined);
return;
}
const sanitizedData = sanitizeSectionData(data as ConfigSectionData);
let nextBaselineFormData = baselineFormData ?? formData;
const overrides = buildOverrides(
sanitizedData,
compareBaseData,
effectiveSchemaDefaults,
);
setPendingOverrides(overrides as JsonValue | undefined);
if (isInitializingRef.current && !pendingData) {
isInitializingRef.current = false;
if (!baselineFormData) {
// Always use formData (server data + schema defaults) for the
// baseline snapshot, NOT sanitizedData from the onChange callback.
// If a custom component (e.g., zone checkboxes) triggers onChange
// before RJSF's initial onChange, sanitizedData would include the
// user's modification, corrupting the baseline.
const baselineSnapshot = cloneDeep(formData as ConfigSectionData);
setBaselineFormData(baselineSnapshot);
nextBaselineFormData = baselineSnapshot;
}
if (overrides === undefined) {
setPendingData(null);
setPendingOverrides(undefined);
setDirtyOverrides(undefined);
return;
}
}
const dirty = buildOverrides(
sanitizedData,
nextBaselineFormData,
undefined,
);
setDirtyOverrides(dirty as JsonValue | undefined);
if (overrides === undefined) {
setPendingData(null);
setPendingOverrides(undefined);
setDirtyOverrides(undefined);
return;
}
setPendingData(sanitizedData);
},
[
pendingData,
compareBaseData,
sanitizeSectionData,
effectiveSchemaDefaults,
setPendingData,
setPendingOverrides,
setDirtyOverrides,
baselineFormData,
setBaselineFormData,
formData,
],
);
const currentFormData = pendingData || formData;
const effectiveBaselineFormData = baselineFormData ?? formData;
const currentOverrides = useMemo(() => {
if (!currentFormData || typeof currentFormData !== "object") {
return undefined;
}
const sanitizedData = sanitizeSectionData(
currentFormData as ConfigSectionData,
);
return buildOverrides(
sanitizedData,
compareBaseData,
effectiveSchemaDefaults,
);
}, [
currentFormData,
sanitizeSectionData,
compareBaseData,
effectiveSchemaDefaults,
]);
const effectiveOverrides = pendingData
? (pendingOverrides ?? currentOverrides)
: undefined;
const uiOverrides = dirtyOverrides ?? effectiveOverrides;
const requiresRestartForOverrides = useCallback(
(overrides: unknown) =>
sharedRequiresRestartForOverrides(
overrides,
sectionConfig.restartRequired,
requiresRestart,
),
[requiresRestart, sectionConfig.restartRequired],
);
const handleReset = useCallback(() => {
isResettingRef.current = true;
setPendingData(null);
setPendingOverrides(undefined);
setDirtyOverrides(undefined);
setFormKey((prev) => prev + 1);
}, [setPendingData, setPendingOverrides, setDirtyOverrides]);
// Handle save button click
const handleSave = useCallback(async () => {
if (!pendingData) return;
setIsSaving(true);
try {
const basePath =
level === "camera" && cameraName
? `cameras.${cameraName}.${sectionPath}`
: sectionPath;
const rawData = sanitizeSectionData(rawFormData);
const overrides = buildOverrides(
pendingData,
rawData,
effectiveSchemaDefaults,
);
const sanitizedOverrides = sanitizeOverridesForSection(
sectionPath,
level,
overrides,
);
if (
!sanitizedOverrides ||
typeof sanitizedOverrides !== "object" ||
Object.keys(sanitizedOverrides).length === 0
) {
setPendingData(null);
return;
}
const needsRestart = requiresRestartForOverrides(sanitizedOverrides);
await axios.put("config/set", {
requires_restart: needsRestart ? 1 : 0,
update_topic: updateTopic,
config_data: {
[basePath]: sanitizedOverrides,
},
});
// log save to console for debugging
// eslint-disable-next-line no-console
console.log("Saved config data:", {
[basePath]: sanitizedOverrides,
update_topic: updateTopic,
requires_restart: needsRestart ? 1 : 0,
});
if (needsRestart) {
toast.success(
t("toast.successRestartRequired", {
ns: "views/settings",
defaultValue:
"Settings saved successfully. Restart Frigate to apply your changes.",
}),
{
action: (
<a onClick={() => setRestartDialogOpen(true)}>
<Button>
{t("restart.button", { ns: "components/dialog" })}
</Button>
</a>
),
},
);
} else {
toast.success(
t("toast.success", {
ns: "views/settings",
defaultValue: "Settings saved successfully",
}),
);
}
setPendingData(null);
refreshConfig();
onSave?.();
} 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 {
toast.error(
t("toast.error", {
ns: "views/settings",
defaultValue: "Failed to save settings",
}),
);
}
} else {
toast.error(
t("toast.error", {
ns: "views/settings",
defaultValue: "Failed to save settings",
}),
);
}
} finally {
setIsSaving(false);
}
}, [
sectionPath,
pendingData,
level,
cameraName,
t,
refreshConfig,
onSave,
rawFormData,
sanitizeSectionData,
effectiveSchemaDefaults,
updateTopic,
setPendingData,
requiresRestartForOverrides,
]);
// Handle reset to global/defaults - removes camera-level override or resets global to defaults
const handleResetToGlobal = useCallback(async () => {
if (level === "camera" && !cameraName) return;
try {
const basePath =
level === "camera" && cameraName
? `cameras.${cameraName}.${sectionPath}`
: sectionPath;
const configData = "";
await axios.put("config/set", {
requires_restart: requiresRestart ? 0 : 1,
update_topic: updateTopic,
config_data: {
[basePath]: configData,
},
});
// log reset to console for debugging
// eslint-disable-next-line no-console
console.log(
level === "global"
? "Reset to defaults for path:"
: "Reset to global config for path:",
basePath,
{
update_topic: updateTopic,
requires_restart: requiresRestart ? 0 : 1,
},
);
toast.success(
t("toast.resetSuccess", {
ns: "views/settings",
defaultValue:
level === "global"
? "Reset to defaults"
: "Reset to global defaults",
}),
);
setPendingData(null);
refreshConfig();
} catch {
toast.error(
t("toast.resetError", {
ns: "views/settings",
defaultValue: "Failed to reset settings",
}),
);
}
}, [
sectionPath,
level,
cameraName,
requiresRestart,
t,
refreshConfig,
updateTopic,
setPendingData,
]);
const sectionValidation = useMemo(
() => getSectionValidation({ sectionPath, level, t }),
[sectionPath, level, t],
);
const customValidate = useMemo(() => {
const validators: Array<
(formData: unknown, errors: FormValidation) => FormValidation
> = [];
if (sectionConfig.customValidate) {
validators.push(sectionConfig.customValidate);
}
if (sectionValidation) {
validators.push(sectionValidation);
}
if (validators.length === 0) {
return undefined;
}
return (formData: unknown, errors: FormValidation) =>
validators.reduce(
(currentErrors, validatorFn) => validatorFn(formData, currentErrors),
errors,
);
}, [sectionConfig.customValidate, sectionValidation]);
// Wrap renderers with runtime props (selectedCamera, setUnsavedChanges, etc.)
const wrappedRenderers = useMemo(() => {
const baseRenderers =
sectionConfig?.renderers ?? sectionRenderers?.[sectionPath];
if (!baseRenderers) return undefined;
// Create wrapper that injects runtime props
return Object.fromEntries(
Object.entries(baseRenderers).map(([key, RendererComponent]) => [
key,
(staticProps: Record<string, unknown> = {}) => (
<RendererComponent
{...staticProps}
selectedCamera={cameraName}
setUnsavedChanges={(hasChanges: boolean) => {
// Translate setUnsavedChanges to pending data state
if (hasChanges && !pendingData) {
// Component signaled changes but we don't have pending data yet
// This can happen when the component manages its own state
} else if (!hasChanges && pendingData) {
// Component signaled no changes, clear pending
setPendingData(null);
}
}}
/>
),
]),
);
}, [
sectionConfig?.renderers,
sectionPath,
cameraName,
pendingData,
setPendingData,
]);
if (!modifiedSchema) {
return null;
}
// Get section title from config namespace
const defaultTitle =
sectionPath.charAt(0).toUpperCase() +
sectionPath.slice(1).replace(/_/g, " ");
// For camera-level sections, keys live under `config/cameras` and are
// nested under the section name (e.g., `audio.label`). For global-level
// sections, keys are nested under the section name in `config/global`.
const configNamespace =
level === "camera" ? "config/cameras" : "config/global";
const title = t(`${sectionPath}.label`, {
ns: configNamespace,
defaultValue: defaultTitle,
});
const sectionDescription = i18n.exists(`${sectionPath}.description`, {
ns: configNamespace,
})
? t(`${sectionPath}.description`, { ns: configNamespace })
: undefined;
if (!sectionSchema || !config) {
return <ActivityIndicator />;
}
const sectionContent = (
<div className="space-y-6">
<ConfigForm
key={formKey}
schema={modifiedSchema}
formData={currentFormData}
onChange={handleChange}
fieldOrder={sectionConfig.fieldOrder}
fieldGroups={sectionConfig.fieldGroups}
hiddenFields={sectionConfig.hiddenFields}
advancedFields={sectionConfig.advancedFields}
liveValidate={sectionConfig.liveValidate}
uiSchema={sectionConfig.uiSchema}
disabled={disabled || isSaving}
readonly={readonly}
showSubmit={false}
i18nNamespace={configNamespace}
customValidate={customValidate}
formContext={{
level,
cameraName,
globalValue,
cameraValue,
hasChanges,
overrides: uiOverrides as JsonValue | undefined,
formData: currentFormData as ConfigSectionData,
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
// For widgets that need access to full camera config (e.g., zone names)
fullCameraConfig:
level === "camera" && cameraName
? config?.cameras?.[cameraName]
: undefined,
fullConfig: config,
// When rendering camera-level sections, provide the section path so
// field templates can look up keys under the `config/cameras` namespace
// When using a consolidated global namespace, keys are nested
// under the section name (e.g., `audio.label`) so provide the
// section prefix to templates so they can attempt `${section}.${field}` lookups.
sectionI18nPrefix: sectionPath,
t,
renderers: wrappedRenderers,
sectionDocs: sectionConfig.sectionDocs,
fieldDocs: sectionConfig.fieldDocs,
hiddenFields: sectionConfig.hiddenFields,
}}
/>
{/* Save button */}
<div className="sticky bottom-0 z-50 w-full border-t border-secondary bg-background pb-5 pt-0">
<div
className={cn(
"flex flex-col items-center gap-4 pt-2 md:flex-row",
hasChanges ? "justify-between" : "justify-end",
)}
>
{hasChanges && (
<div className="flex items-center gap-2">
<span className="text-sm text-danger">
{t("unsavedChanges", {
ns: "views/settings",
defaultValue: "You have unsaved changes",
})}
</span>
</div>
)}
<div className="flex w-full items-center gap-2 md:w-auto">
{((level === "camera" && isOverridden) || level === "global") &&
!hasChanges && (
<Button
onClick={() => setIsResetDialogOpen(true)}
variant="outline"
disabled={isSaving || disabled}
className="flex flex-1 gap-2"
>
{level === "global"
? t("button.resetToDefault", {
ns: "common",
defaultValue: "Reset to Default",
})
: t("button.resetToGlobal", {
ns: "common",
defaultValue: "Reset to Global",
})}
</Button>
)}
{hasChanges && (
<Button
onClick={handleReset}
variant="outline"
disabled={isSaving || disabled}
className="flex min-w-36 flex-1 gap-2"
>
{t("button.undo", { ns: "common", defaultValue: "Undo" })}
</Button>
)}
<Button
onClick={handleSave}
variant="select"
disabled={!hasChanges || isSaving || disabled}
className="flex min-w-36 flex-1 gap-2"
>
{isSaving ? (
<>
<ActivityIndicator className="h-4 w-4" />
{t("button.saving", {
ns: "common",
defaultValue: "Saving...",
})}
</>
) : (
<>{t("button.save", { ns: "common", defaultValue: "Save" })}</>
)}
</Button>
</div>
</div>
</div>
<AlertDialog open={isResetDialogOpen} onOpenChange={setIsResetDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("confirmReset", { ns: "views/settings" })}
</AlertDialogTitle>
<AlertDialogDescription>
{level === "global"
? t("resetToDefaultDescription", { ns: "views/settings" })
: t("resetToGlobalDescription", { ns: "views/settings" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className="bg-selected text-white hover:bg-selected/90"
onClick={async () => {
await handleResetToGlobal();
setIsResetDialogOpen(false);
}}
>
{level === "global"
? t("button.resetToDefault", { ns: "common" })
: t("button.resetToGlobal", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</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("button.overridden", {
ns: "common",
defaultValue: "Overridden",
})}
</Badge>
)}
{hasChanges && (
<Badge variant="outline" className="text-xs">
{t("modified", {
ns: "common",
defaultValue: "Modified",
})}
</Badge>
)}
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="pl-7">{sectionContent}</div>
</CollapsibleContent>
</div>
</Collapsible>
<RestartDialog
isOpen={restartDialogOpen}
onClose={() => setRestartDialogOpen(false)}
onRestart={() => sendRestart("restart")}
/>
</>
);
}
return (
<>
<div className="space-y-3">
{shouldShowTitle && (
<div className="flex items-start justify-between">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3">
<Heading as="h4">{title}</Heading>
{showOverrideIndicator &&
level === "camera" &&
isOverridden && (
<Badge
variant="secondary"
className="cursor-default border-2 border-selected text-xs text-primary-variant"
>
{t("button.overridden", {
ns: "common",
defaultValue: "Overridden",
})}
</Badge>
)}
{hasChanges && (
<Badge
variant="secondary"
className="cursor-default bg-danger text-xs text-white hover:bg-danger"
>
{t("modified", { ns: "common", defaultValue: "Modified" })}
</Badge>
)}
</div>
{sectionDescription && (
<p className="text-sm text-muted-foreground">
{sectionDescription}
</p>
)}
</div>
</div>
)}
{sectionContent}
</div>
<RestartDialog
isOpen={restartDialogOpen}
onClose={() => setRestartDialogOpen(false)}
onRestart={() => sendRestart("restart")}
/>
</>
);
}