From 096a13bce9edd5a84168c3baa737dba3560120bb Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:01:51 -0500 Subject: [PATCH] move profile dropdown from section panes to settings header --- web/src/pages/Settings.tsx | 149 +++++++++++++++++++ web/src/types/frigateConfig.ts | 10 ++ web/src/views/settings/MasksAndZonesView.tsx | 77 ---------- web/src/views/settings/SingleSectionPage.tsx | 46 +----- 4 files changed, 160 insertions(+), 122 deletions(-) diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 9bbb165c6..701009da1 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -90,9 +90,11 @@ import { buildConfigDataForPath, parseProfileFromSectionPath, prepareSectionSavePayload, + PROFILE_ELIGIBLE_SECTIONS, } from "@/utils/configUtil"; import type { ProfileState } from "@/types/profile"; import { getProfileColor } from "@/utils/profileColors"; +import { ProfileSectionDropdown } from "@/components/settings/ProfileSectionDropdown"; import { Badge } from "@/components/ui/badge"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; @@ -512,6 +514,24 @@ const CAMERA_SECTION_MAPPING: Record = { timestamp_style: "cameraTimestampStyle", }; +// Reverse mapping: page key → config section key +const REVERSE_CAMERA_SECTION_MAPPING: Record = Object.fromEntries( + Object.entries(CAMERA_SECTION_MAPPING).map(([section, page]) => [page, section]), +); +// masksAndZones is a composite page, not in CAMERA_SECTION_MAPPING +REVERSE_CAMERA_SECTION_MAPPING["masksAndZones"] = "masksAndZones"; + +// Pages where the profile dropdown should appear +const PROFILE_DROPDOWN_PAGES = new Set( + Object.entries(REVERSE_CAMERA_SECTION_MAPPING) + .filter( + ([, sectionKey]) => + PROFILE_ELIGIBLE_SECTIONS.has(sectionKey) || + sectionKey === "masksAndZones", + ) + .map(([pageKey]) => pageKey), +); + // keys for global sections const GLOBAL_SECTION_MAPPING: Record = { detect: "globalDetect", @@ -1092,6 +1112,97 @@ export default function Settings() { ], ); + // Header profile dropdown: derive section key from current page + const currentSectionKey = useMemo( + () => REVERSE_CAMERA_SECTION_MAPPING[pageToggle] ?? null, + [pageToggle], + ); + + const headerEditingProfile = useMemo(() => { + if (!selectedCamera || !currentSectionKey) return null; + const key = `${selectedCamera}::${currentSectionKey}`; + return editingProfile[key] ?? null; + }, [selectedCamera, currentSectionKey, editingProfile]); + + const showProfileDropdown = + PROFILE_DROPDOWN_PAGES.has(pageToggle) && + !!selectedCamera && + allProfileNames.length > 0; + + const headerHasProfileData = useCallback( + (profileName: string): boolean => { + if (!config || !selectedCamera || !currentSectionKey) return false; + const profileData = + config.cameras[selectedCamera]?.profiles?.[profileName]; + if (!profileData) return false; + + if (currentSectionKey === "masksAndZones") { + const hasZones = + profileData.zones && Object.keys(profileData.zones).length > 0; + const hasMotionMasks = + profileData.motion?.mask && + Object.keys(profileData.motion.mask).length > 0; + const hasObjectMasks = + (profileData.objects?.mask && + Object.keys(profileData.objects.mask).length > 0) || + (profileData.objects?.filters && + Object.values(profileData.objects.filters).some( + (f) => f.mask && Object.keys(f.mask).length > 0, + )); + return !!(hasZones || hasMotionMasks || hasObjectMasks); + } + + return !!profileData[ + currentSectionKey as keyof typeof profileData + ]; + }, + [config, selectedCamera, currentSectionKey], + ); + + const handleDeleteProfileForCurrentSection = useCallback( + async (profileName: string) => { + if (!selectedCamera || !currentSectionKey) return; + + if (currentSectionKey === "masksAndZones") { + try { + await axios.put("config/set", { + config_data: { + cameras: { + [selectedCamera]: { + profiles: { + [profileName]: { + zones: "", + motion: { mask: "" }, + objects: { mask: "", filters: "" }, + }, + }, + }, + }, + }, + }); + await mutate("config"); + handleSelectProfile(selectedCamera, "masksAndZones", null); + toast.success(t("toast.save.success", { ns: "common" })); + } catch { + toast.error(t("toast.save.error.title", { ns: "common" })); + } + } else { + await handleDeleteProfileSection( + selectedCamera, + currentSectionKey, + profileName, + ); + } + }, + [ + selectedCamera, + currentSectionKey, + handleSelectProfile, + handleDeleteProfileSection, + t, + ], + ); + const handleSectionStatusChange = useCallback( (sectionKey: string, level: "global" | "camera", status: SectionStatus) => { // Map section keys to menu keys based on level @@ -1368,6 +1479,26 @@ export default function Settings() { updateZoneMaskFilter={setFilterZoneMask} /> )} + {showProfileDropdown && currentSectionKey && ( + + handleSelectProfile( + selectedCamera, + currentSectionKey, + profile, + ) + } + onAddProfile={handleAddProfile} + onDeleteProfileSection={ + handleDeleteProfileForCurrentSection + } + /> + )} )} + {showProfileDropdown && currentSectionKey && ( + + handleSelectProfile( + selectedCamera, + currentSectionKey, + profile, + ) + } + onAddProfile={handleAddProfile} + onDeleteProfileSection={handleDeleteProfileForCurrentSection} + /> + )} ("config"); - const { mutate } = useSWRConfig(); const [allPolygons, setAllPolygons] = useState([]); const [editingPolygons, setEditingPolygons] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -82,58 +77,6 @@ export default function MasksAndZonesView({ const currentEditingProfile = profileState?.editingProfile[profileSectionKey] ?? null; - const hasProfileData = useCallback( - (profileName: string) => { - if (!config || !selectedCamera) return false; - const profileData = - config.cameras[selectedCamera]?.profiles?.[profileName]; - if (!profileData) return false; - const hasZones = - profileData.zones && Object.keys(profileData.zones).length > 0; - const hasMotionMasks = - profileData.motion?.mask && - Object.keys(profileData.motion.mask).length > 0; - const hasObjectMasks = - (profileData.objects?.mask && - Object.keys(profileData.objects.mask).length > 0) || - (profileData.objects?.filters && - Object.values(profileData.objects.filters).some( - (f) => f.mask && Object.keys(f.mask).length > 0, - )); - return !!(hasZones || hasMotionMasks || hasObjectMasks); - }, - [config, selectedCamera], - ); - - const handleDeleteProfileMasksAndZones = useCallback( - async (profileName: string) => { - try { - // Delete zones, motion masks, and object masks from the profile - await axios.put("config/set", { - config_data: { - cameras: { - [selectedCamera]: { - profiles: { - [profileName]: { - zones: "", - motion: { mask: "" }, - objects: { mask: "", filters: "" }, - }, - }, - }, - }, - }, - }); - await mutate("config"); - profileState?.onSelectProfile(selectedCamera, "masksAndZones", null); - toast.success(t("toast.save.success", { ns: "common" })); - } catch { - toast.error(t("toast.save.error.noMessage", { ns: "common" })); - } - }, - [selectedCamera, mutate, profileState, t], - ); - const cameraConfig = useMemo(() => { if (config && selectedCamera) { return config.cameras[selectedCamera]; @@ -829,26 +772,6 @@ export default function MasksAndZonesView({ <>
{t("menu.masksAndZones")} - {profileState && selectedCamera && ( - - profileState.onSelectProfile( - selectedCamera, - "masksAndZones", - profile, - ) - } - onAddProfile={profileState.onAddProfile} - onDeleteProfileSection={(profileName) => - handleDeleteProfileMasksAndZones(profileName) - } - /> - )}
{(selectedZoneMask === undefined || diff --git a/web/src/views/settings/SingleSectionPage.tsx b/web/src/views/settings/SingleSectionPage.tsx index 5dd0feb14..b72b97a3e 100644 --- a/web/src/views/settings/SingleSectionPage.tsx +++ b/web/src/views/settings/SingleSectionPage.tsx @@ -1,22 +1,16 @@ import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import useSWR from "swr"; import type { SectionConfig } from "@/components/config-form/sections"; import { ConfigSectionTemplate } from "@/components/config-form/sections"; import type { PolygonType } from "@/types/canvas"; -import type { FrigateConfig } from "@/types/frigateConfig"; import { Badge } from "@/components/ui/badge"; import type { ConfigSectionData } from "@/types/configForm"; import type { ProfileState } from "@/types/profile"; -import { - getSectionConfig, - PROFILE_ELIGIBLE_SECTIONS, -} from "@/utils/configUtil"; +import { getSectionConfig } from "@/utils/configUtil"; 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"; -import { ProfileSectionDropdown } from "@/components/settings/ProfileSectionDropdown"; export type SettingsPageProps = { selectedCamera?: string; @@ -74,7 +68,6 @@ export function SingleSectionPage({ "common", ]); const { getLocaleDocUrl } = useDocDomain(); - const { data: config } = useSWR("config"); const [sectionStatus, setSectionStatus] = useState({ hasChanges: false, isOverridden: false, @@ -88,13 +81,6 @@ export function SingleSectionPage({ ? getLocaleDocUrl(resolvedSectionConfig.sectionDocs) : undefined; - // Profile support: determine if this section supports profiles - const isProfileEligible = - level === "camera" && - selectedCamera && - profileState && - PROFILE_ELIGIBLE_SECTIONS.has(sectionKey); - const profileKey = selectedCamera ? `${selectedCamera}::${sectionKey}` : undefined; @@ -148,36 +134,6 @@ export function SingleSectionPage({
- {isProfileEligible && selectedCamera && profileState && ( - { - const profileData = - config?.cameras?.[selectedCamera]?.profiles?.[profile]; - return !!profileData?.[ - sectionKey as keyof typeof profileData - ]; - }} - onSelectProfile={(profile) => - profileState.onSelectProfile( - selectedCamera, - sectionKey, - profile, - ) - } - onAddProfile={profileState.onAddProfile} - onDeleteProfileSection={(profile) => - profileState.onDeleteProfileSection( - selectedCamera, - sectionKey, - profile, - ) - } - /> - )} {level === "camera" && showOverrideIndicator && sectionStatus.isOverridden && (