Files
frigate/web/src/views/settings/SingleSectionPage.tsx
T

296 lines
11 KiB
TypeScript
Raw Normal View History

2026-02-27 09:55:36 -06:00
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { SectionConfig } from "@/components/config-form/sections";
import { ConfigSectionTemplate } from "@/components/config-form/sections";
2026-05-04 10:50:00 -05:00
import { CameraOverridesBadge } from "@/components/config-form/sections/CameraOverridesBadge";
2026-02-27 09:55:36 -06:00
import type { PolygonType } from "@/types/canvas";
import { Badge } from "@/components/ui/badge";
2026-03-19 09:47:57 -05:00
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
2026-02-27 09:55:36 -06:00
import type { ConfigSectionData } from "@/types/configForm";
2026-03-19 09:47:57 -05:00
import type { ProfileState } from "@/types/profile";
2026-02-27 09:55:36 -06:00
import { getSectionConfig } from "@/utils/configUtil";
2026-03-19 09:47:57 -05:00
import { getProfileColor } from "@/utils/profileColors";
import { cn } from "@/lib/utils";
2026-02-27 09:55:36 -06:00
import { useDocDomain } from "@/hooks/use-doc-domain";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import Heading from "@/components/ui/heading";
export type SettingsPageProps = {
selectedCamera?: string;
setUnsavedChanges?: React.Dispatch<React.SetStateAction<boolean>>;
selectedZoneMask?: PolygonType[];
onSectionStatusChange?: (
sectionKey: string,
level: "global" | "camera",
status: SectionStatus,
) => void;
2026-03-19 09:47:57 -05:00
pendingDataBySection?: Record<string, ConfigSectionData>;
2026-02-27 09:55:36 -06:00
onPendingDataChange?: (
sectionKey: string,
cameraName: string | undefined,
data: ConfigSectionData | null,
) => void;
2026-03-19 09:47:57 -05:00
profileState?: ProfileState;
/** Callback to delete the current profile's overrides for the current section */
onDeleteProfileSection?: (profileName: string) => void;
profilesUIEnabled?: boolean;
setProfilesUIEnabled?: React.Dispatch<React.SetStateAction<boolean>>;
2026-03-26 13:47:24 -05:00
/** Whether a SaveAll operation is in progress */
isSavingAll?: boolean;
/** Callback when a section's saving state changes */
onSectionSavingChange?: (isSaving: boolean) => void;
2026-02-27 09:55:36 -06:00
};
export type SectionStatus = {
hasChanges: boolean;
isOverridden: boolean;
2026-03-19 09:47:57 -05:00
/** Where the override comes from: "global" = camera overrides global, "profile" = profile overrides base */
overrideSource?: "global" | "profile";
2026-02-27 09:55:36 -06:00
hasValidationErrors: boolean;
};
export type SingleSectionPageOptions = {
sectionKey: string;
level: "global" | "camera";
sectionConfig?: SectionConfig;
requiresRestart?: boolean;
showOverrideIndicator?: boolean;
};
export type SingleSectionPageProps = SettingsPageProps &
SingleSectionPageOptions;
export function SingleSectionPage({
sectionKey,
level,
sectionConfig,
requiresRestart,
showOverrideIndicator = true,
selectedCamera,
setUnsavedChanges,
onSectionStatusChange,
pendingDataBySection,
onPendingDataChange,
2026-03-19 09:47:57 -05:00
profileState,
onDeleteProfileSection,
2026-03-26 13:47:24 -05:00
isSavingAll,
onSectionSavingChange,
2026-02-27 09:55:36 -06:00
}: SingleSectionPageProps) {
const sectionNamespace =
level === "camera" ? "config/cameras" : "config/global";
const { t, i18n } = useTranslation([
sectionNamespace,
"views/settings",
"common",
]);
const { getLocaleDocUrl } = useDocDomain();
const [sectionStatus, setSectionStatus] = useState<SectionStatus>({
hasChanges: false,
isOverridden: false,
hasValidationErrors: false,
});
const resolvedSectionConfig = useMemo(
() => sectionConfig ?? getSectionConfig(sectionKey, level),
[level, sectionConfig, sectionKey],
);
const sectionDocsUrl = resolvedSectionConfig.sectionDocs
? getLocaleDocUrl(resolvedSectionConfig.sectionDocs)
: undefined;
2026-03-26 13:47:24 -05:00
const currentEditingProfile =
level === "camera" && selectedCamera
? (profileState?.editingProfile[selectedCamera] ?? null)
: null;
2026-03-19 09:47:57 -05:00
const profileColor = useMemo(
() =>
currentEditingProfile && profileState?.allProfileNames
? getProfileColor(currentEditingProfile, profileState.allProfileNames)
: undefined,
[currentEditingProfile, profileState?.allProfileNames],
);
const handleDeleteProfileSection = useCallback(() => {
if (currentEditingProfile && onDeleteProfileSection) {
onDeleteProfileSection(currentEditingProfile);
}
}, [currentEditingProfile, onDeleteProfileSection]);
2026-02-27 09:55:36 -06:00
const handleSectionStatusChange = useCallback(
(status: SectionStatus) => {
setSectionStatus(status);
onSectionStatusChange?.(sectionKey, level, status);
},
[level, onSectionStatusChange, sectionKey],
);
if (level === "camera" && !selectedCamera) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
{t("configForm.camera.noCameras", { ns: "views/settings" })}
</div>
);
}
return (
<div className="flex size-full flex-col lg:pr-2">
2026-03-20 08:24:34 -05:00
<div className="mb-5 flex flex-col gap-2">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col">
<Heading as="h4">
{t(`${sectionKey}.label`, { ns: sectionNamespace })}
</Heading>
{i18n.exists(`${sectionKey}.description`, {
ns: sectionNamespace,
}) && (
<div className="my-1 text-sm text-muted-foreground">
{t(`${sectionKey}.description`, { ns: sectionNamespace })}
</div>
)}
{sectionDocsUrl && (
<div className="flex items-center text-sm text-primary-variant">
<Link
to={sectionDocsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
)}
</div>
{/* Desktop: badge inline next to title */}
<div className="hidden shrink-0 sm:flex sm:flex-wrap sm:items-center sm:gap-2">
2026-05-04 10:50:00 -05:00
{level === "global" && showOverrideIndicator && (
<CameraOverridesBadge sectionPath={sectionKey} />
)}
2026-02-27 09:55:36 -06:00
{level === "camera" &&
showOverrideIndicator &&
sectionStatus.isOverridden && (
2026-03-19 09:47:57 -05:00
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="secondary"
className={cn(
"cursor-default border-2 text-center text-xs text-primary-variant",
sectionStatus.overrideSource === "profile" &&
profileColor
? profileColor.border
: "border-selected",
)}
>
{sectionStatus.overrideSource === "profile"
? t("button.overriddenBaseConfig", {
ns: "views/settings",
defaultValue: "Overridden (Base Config)",
})
: t("button.overriddenGlobal", {
ns: "views/settings",
defaultValue: "Overridden (Global)",
})}
</Badge>
</TooltipTrigger>
<TooltipContent>
{sectionStatus.overrideSource === "profile"
? t("button.overriddenBaseConfigTooltip", {
ns: "views/settings",
profile: currentEditingProfile
? (profileState?.profileFriendlyNames.get(
currentEditingProfile,
) ?? currentEditingProfile)
: "",
})
: t("button.overriddenGlobalTooltip", {
ns: "views/settings",
})}
</TooltipContent>
</Tooltip>
2026-02-27 09:55:36 -06:00
)}
{sectionStatus.hasChanges && (
<Badge
variant="secondary"
2026-04-22 10:19:30 -05:00
className="cursor-default bg-unsaved text-xs text-black hover:bg-unsaved"
2026-02-27 09:55:36 -06:00
>
2026-03-22 13:03:24 -05:00
{t("button.modified", {
ns: "common",
defaultValue: "Modified",
})}
2026-02-27 09:55:36 -06:00
</Badge>
)}
</div>
</div>
2026-03-20 08:24:34 -05:00
{/* Mobile: badge below title/description */}
<div className="flex flex-wrap items-center gap-2 sm:hidden">
2026-05-04 10:50:00 -05:00
{level === "global" && showOverrideIndicator && (
<CameraOverridesBadge sectionPath={sectionKey} />
)}
2026-03-20 08:24:34 -05:00
{level === "camera" &&
showOverrideIndicator &&
sectionStatus.isOverridden && (
<Badge
variant="secondary"
className={cn(
"cursor-default border-2 text-center text-xs text-primary-variant",
sectionStatus.overrideSource === "profile" && profileColor
? profileColor.border
: "border-selected",
)}
>
{sectionStatus.overrideSource === "profile"
? t("button.overriddenBaseConfig", {
ns: "views/settings",
defaultValue: "Overridden (Base Config)",
})
: t("button.overriddenGlobal", {
ns: "views/settings",
defaultValue: "Overridden (Global)",
})}
</Badge>
)}
{sectionStatus.hasChanges && (
<Badge
variant="secondary"
2026-04-22 10:19:30 -05:00
className="cursor-default bg-unsaved text-xs text-black hover:bg-unsaved"
2026-03-20 08:24:34 -05:00
>
2026-03-22 13:03:24 -05:00
{t("button.modified", { ns: "common", defaultValue: "Modified" })}
2026-03-20 08:24:34 -05:00
</Badge>
)}
</div>
2026-02-27 09:55:36 -06:00
</div>
<ConfigSectionTemplate
sectionKey={sectionKey}
level={level}
cameraName={level === "camera" ? selectedCamera : undefined}
showOverrideIndicator={showOverrideIndicator}
onSave={() => setUnsavedChanges?.(false)}
showTitle={false}
sectionConfig={resolvedSectionConfig}
pendingDataBySection={pendingDataBySection}
onPendingDataChange={onPendingDataChange}
requiresRestart={requiresRestart}
onStatusChange={handleSectionStatusChange}
2026-03-19 09:47:57 -05:00
profileName={currentEditingProfile ?? undefined}
profileFriendlyName={
currentEditingProfile
? (profileState?.profileFriendlyNames.get(currentEditingProfile) ??
currentEditingProfile)
: undefined
}
profileBorderColor={profileColor?.border}
onDeleteProfileSection={
currentEditingProfile ? handleDeleteProfileSection : undefined
}
2026-03-26 13:47:24 -05:00
isSavingAll={isSavingAll}
onSavingChange={onSectionSavingChange}
2026-02-27 09:55:36 -06:00
/>
</div>
);
}