move profile dropdown from section panes to settings header

This commit is contained in:
Josh Hawkins 2026-03-11 11:01:51 -05:00
parent eccad7aa21
commit 096a13bce9
4 changed files with 160 additions and 122 deletions

View File

@ -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<string, SettingsType> = {
timestamp_style: "cameraTimestampStyle",
};
// Reverse mapping: page key → config section key
const REVERSE_CAMERA_SECTION_MAPPING: Record<string, string> = 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<string, SettingsType> = {
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 && (
<ProfileSectionDropdown
cameraName={selectedCamera}
sectionKey={currentSectionKey}
allProfileNames={allProfileNames}
editingProfile={headerEditingProfile}
hasProfileData={headerHasProfileData}
onSelectProfile={(profile) =>
handleSelectProfile(
selectedCamera,
currentSectionKey,
profile,
)
}
onAddProfile={handleAddProfile}
onDeleteProfileSection={
handleDeleteProfileForCurrentSection
}
/>
)}
<CameraSelectButton
allCameras={cameras}
selectedCamera={selectedCamera}
@ -1510,6 +1641,24 @@ export default function Settings() {
updateZoneMaskFilter={setFilterZoneMask}
/>
)}
{showProfileDropdown && currentSectionKey && (
<ProfileSectionDropdown
cameraName={selectedCamera}
sectionKey={currentSectionKey}
allProfileNames={allProfileNames}
editingProfile={headerEditingProfile}
hasProfileData={headerHasProfileData}
onSelectProfile={(profile) =>
handleSelectProfile(
selectedCamera,
currentSectionKey,
profile,
)
}
onAddProfile={handleAddProfile}
onDeleteProfileSection={handleDeleteProfileForCurrentSection}
/>
)}
<CameraSelectButton
allCameras={cameras}
selectedCamera={selectedCamera}

View File

@ -72,6 +72,10 @@ export interface CameraConfig {
};
enabled: boolean;
enabled_in_config: boolean;
face_recognition: {
enabled: boolean;
min_area: number;
};
ffmpeg: {
global_args: string[];
hwaccel_args: string;
@ -99,6 +103,12 @@ export interface CameraConfig {
quality: number;
streams: { [key: string]: string };
};
lpr: {
enabled: boolean;
expire_time: number;
min_area: number;
enhancement: number;
};
motion: {
contour_area: number;
delta_alpha: number;

View File

@ -36,10 +36,6 @@ import { useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { cn } from "@/lib/utils";
import { ProfileState } from "@/types/profile";
import { ProfileSectionDropdown } from "@/components/settings/ProfileSectionDropdown";
import axios from "axios";
import { useSWRConfig } from "swr";
type MasksAndZoneViewProps = {
selectedCamera: string;
selectedZoneMask?: PolygonType[];
@ -56,7 +52,6 @@ export default function MasksAndZonesView({
const { t } = useTranslation(["views/settings"]);
const { getLocaleDocUrl } = useDocDomain();
const { data: config } = useSWR<FrigateConfig>("config");
const { mutate } = useSWRConfig();
const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]);
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({
<>
<div className="mb-2 flex items-center justify-between">
<Heading as="h4">{t("menu.masksAndZones")}</Heading>
{profileState && selectedCamera && (
<ProfileSectionDropdown
cameraName={selectedCamera}
sectionKey="masksAndZones"
allProfileNames={profileState.allProfileNames}
editingProfile={currentEditingProfile}
hasProfileData={hasProfileData}
onSelectProfile={(profile) =>
profileState.onSelectProfile(
selectedCamera,
"masksAndZones",
profile,
)
}
onAddProfile={profileState.onAddProfile}
onDeleteProfileSection={(profileName) =>
handleDeleteProfileMasksAndZones(profileName)
}
/>
)}
</div>
<div className="flex w-full flex-col">
{(selectedZoneMask === undefined ||

View File

@ -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<FrigateConfig>("config");
const [sectionStatus, setSectionStatus] = useState<SectionStatus>({
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({
</div>
<div className="flex flex-col items-end gap-2 md:flex-row md:items-center">
<div className="flex flex-wrap items-center justify-end gap-2">
{isProfileEligible && selectedCamera && profileState && (
<ProfileSectionDropdown
cameraName={selectedCamera}
sectionKey={sectionKey}
allProfileNames={profileState.allProfileNames}
editingProfile={currentEditingProfile}
hasProfileData={(profile) => {
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 && (