mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-20 23:28:23 +03:00
move profile dropdown from section panes to settings header
This commit is contained in:
parent
eccad7aa21
commit
096a13bce9
@ -90,9 +90,11 @@ import {
|
|||||||
buildConfigDataForPath,
|
buildConfigDataForPath,
|
||||||
parseProfileFromSectionPath,
|
parseProfileFromSectionPath,
|
||||||
prepareSectionSavePayload,
|
prepareSectionSavePayload,
|
||||||
|
PROFILE_ELIGIBLE_SECTIONS,
|
||||||
} from "@/utils/configUtil";
|
} from "@/utils/configUtil";
|
||||||
import type { ProfileState } from "@/types/profile";
|
import type { ProfileState } from "@/types/profile";
|
||||||
import { getProfileColor } from "@/utils/profileColors";
|
import { getProfileColor } from "@/utils/profileColors";
|
||||||
|
import { ProfileSectionDropdown } from "@/components/settings/ProfileSectionDropdown";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
|
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
|
||||||
@ -512,6 +514,24 @@ const CAMERA_SECTION_MAPPING: Record<string, SettingsType> = {
|
|||||||
timestamp_style: "cameraTimestampStyle",
|
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
|
// keys for global sections
|
||||||
const GLOBAL_SECTION_MAPPING: Record<string, SettingsType> = {
|
const GLOBAL_SECTION_MAPPING: Record<string, SettingsType> = {
|
||||||
detect: "globalDetect",
|
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(
|
const handleSectionStatusChange = useCallback(
|
||||||
(sectionKey: string, level: "global" | "camera", status: SectionStatus) => {
|
(sectionKey: string, level: "global" | "camera", status: SectionStatus) => {
|
||||||
// Map section keys to menu keys based on level
|
// Map section keys to menu keys based on level
|
||||||
@ -1368,6 +1479,26 @@ export default function Settings() {
|
|||||||
updateZoneMaskFilter={setFilterZoneMask}
|
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
|
<CameraSelectButton
|
||||||
allCameras={cameras}
|
allCameras={cameras}
|
||||||
selectedCamera={selectedCamera}
|
selectedCamera={selectedCamera}
|
||||||
@ -1510,6 +1641,24 @@ export default function Settings() {
|
|||||||
updateZoneMaskFilter={setFilterZoneMask}
|
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
|
<CameraSelectButton
|
||||||
allCameras={cameras}
|
allCameras={cameras}
|
||||||
selectedCamera={selectedCamera}
|
selectedCamera={selectedCamera}
|
||||||
|
|||||||
@ -72,6 +72,10 @@ export interface CameraConfig {
|
|||||||
};
|
};
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
enabled_in_config: boolean;
|
enabled_in_config: boolean;
|
||||||
|
face_recognition: {
|
||||||
|
enabled: boolean;
|
||||||
|
min_area: number;
|
||||||
|
};
|
||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
global_args: string[];
|
global_args: string[];
|
||||||
hwaccel_args: string;
|
hwaccel_args: string;
|
||||||
@ -99,6 +103,12 @@ export interface CameraConfig {
|
|||||||
quality: number;
|
quality: number;
|
||||||
streams: { [key: string]: string };
|
streams: { [key: string]: string };
|
||||||
};
|
};
|
||||||
|
lpr: {
|
||||||
|
enabled: boolean;
|
||||||
|
expire_time: number;
|
||||||
|
min_area: number;
|
||||||
|
enhancement: number;
|
||||||
|
};
|
||||||
motion: {
|
motion: {
|
||||||
contour_area: number;
|
contour_area: number;
|
||||||
delta_alpha: number;
|
delta_alpha: number;
|
||||||
|
|||||||
@ -36,10 +36,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ProfileState } from "@/types/profile";
|
import { ProfileState } from "@/types/profile";
|
||||||
import { ProfileSectionDropdown } from "@/components/settings/ProfileSectionDropdown";
|
|
||||||
import axios from "axios";
|
|
||||||
import { useSWRConfig } from "swr";
|
|
||||||
|
|
||||||
type MasksAndZoneViewProps = {
|
type MasksAndZoneViewProps = {
|
||||||
selectedCamera: string;
|
selectedCamera: string;
|
||||||
selectedZoneMask?: PolygonType[];
|
selectedZoneMask?: PolygonType[];
|
||||||
@ -56,7 +52,6 @@ export default function MasksAndZonesView({
|
|||||||
const { t } = useTranslation(["views/settings"]);
|
const { t } = useTranslation(["views/settings"]);
|
||||||
const { getLocaleDocUrl } = useDocDomain();
|
const { getLocaleDocUrl } = useDocDomain();
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const { mutate } = useSWRConfig();
|
|
||||||
const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
|
const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
|
||||||
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]);
|
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@ -82,58 +77,6 @@ export default function MasksAndZonesView({
|
|||||||
const currentEditingProfile =
|
const currentEditingProfile =
|
||||||
profileState?.editingProfile[profileSectionKey] ?? null;
|
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(() => {
|
const cameraConfig = useMemo(() => {
|
||||||
if (config && selectedCamera) {
|
if (config && selectedCamera) {
|
||||||
return config.cameras[selectedCamera];
|
return config.cameras[selectedCamera];
|
||||||
@ -829,26 +772,6 @@ export default function MasksAndZonesView({
|
|||||||
<>
|
<>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<Heading as="h4">{t("menu.masksAndZones")}</Heading>
|
<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>
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col">
|
||||||
{(selectedZoneMask === undefined ||
|
{(selectedZoneMask === undefined ||
|
||||||
|
|||||||
@ -1,22 +1,16 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useSWR from "swr";
|
|
||||||
import type { SectionConfig } from "@/components/config-form/sections";
|
import type { SectionConfig } from "@/components/config-form/sections";
|
||||||
import { ConfigSectionTemplate } from "@/components/config-form/sections";
|
import { ConfigSectionTemplate } from "@/components/config-form/sections";
|
||||||
import type { PolygonType } from "@/types/canvas";
|
import type { PolygonType } from "@/types/canvas";
|
||||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import type { ConfigSectionData } from "@/types/configForm";
|
import type { ConfigSectionData } from "@/types/configForm";
|
||||||
import type { ProfileState } from "@/types/profile";
|
import type { ProfileState } from "@/types/profile";
|
||||||
import {
|
import { getSectionConfig } from "@/utils/configUtil";
|
||||||
getSectionConfig,
|
|
||||||
PROFILE_ELIGIBLE_SECTIONS,
|
|
||||||
} from "@/utils/configUtil";
|
|
||||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { LuExternalLink } from "react-icons/lu";
|
import { LuExternalLink } from "react-icons/lu";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import { ProfileSectionDropdown } from "@/components/settings/ProfileSectionDropdown";
|
|
||||||
|
|
||||||
export type SettingsPageProps = {
|
export type SettingsPageProps = {
|
||||||
selectedCamera?: string;
|
selectedCamera?: string;
|
||||||
@ -74,7 +68,6 @@ export function SingleSectionPage({
|
|||||||
"common",
|
"common",
|
||||||
]);
|
]);
|
||||||
const { getLocaleDocUrl } = useDocDomain();
|
const { getLocaleDocUrl } = useDocDomain();
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
|
||||||
const [sectionStatus, setSectionStatus] = useState<SectionStatus>({
|
const [sectionStatus, setSectionStatus] = useState<SectionStatus>({
|
||||||
hasChanges: false,
|
hasChanges: false,
|
||||||
isOverridden: false,
|
isOverridden: false,
|
||||||
@ -88,13 +81,6 @@ export function SingleSectionPage({
|
|||||||
? getLocaleDocUrl(resolvedSectionConfig.sectionDocs)
|
? getLocaleDocUrl(resolvedSectionConfig.sectionDocs)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Profile support: determine if this section supports profiles
|
|
||||||
const isProfileEligible =
|
|
||||||
level === "camera" &&
|
|
||||||
selectedCamera &&
|
|
||||||
profileState &&
|
|
||||||
PROFILE_ELIGIBLE_SECTIONS.has(sectionKey);
|
|
||||||
|
|
||||||
const profileKey = selectedCamera
|
const profileKey = selectedCamera
|
||||||
? `${selectedCamera}::${sectionKey}`
|
? `${selectedCamera}::${sectionKey}`
|
||||||
: undefined;
|
: undefined;
|
||||||
@ -148,36 +134,6 @@ export function SingleSectionPage({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2 md:flex-row md:items-center">
|
<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">
|
<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" &&
|
{level === "camera" &&
|
||||||
showOverrideIndicator &&
|
showOverrideIndicator &&
|
||||||
sectionStatus.isOverridden && (
|
sectionStatus.isOverridden && (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user