diff --git a/web/src/components/settings/ProfileSectionDropdown.tsx b/web/src/components/settings/ProfileSectionDropdown.tsx new file mode 100644 index 000000000..5a456d6ab --- /dev/null +++ b/web/src/components/settings/ProfileSectionDropdown.tsx @@ -0,0 +1,310 @@ +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Check, ChevronDown, Plus, Trash2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { getProfileColor } from "@/utils/profileColors"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +type ProfileSectionDropdownProps = { + cameraName: string; + sectionKey: string; + allProfileNames: string[]; + editingProfile: string | null; + hasProfileData: (profileName: string) => boolean; + onSelectProfile: (profileName: string | null) => void; + onAddProfile: (name: string) => void; + onDeleteProfileSection: (profileName: string) => void; +}; + +export function ProfileSectionDropdown({ + cameraName, + sectionKey, + allProfileNames, + editingProfile, + hasProfileData, + onSelectProfile, + onAddProfile, + onDeleteProfileSection, +}: ProfileSectionDropdownProps) { + const { t } = useTranslation(["views/settings", "common"]); + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [deleteConfirmProfile, setDeleteConfirmProfile] = useState< + string | null + >(null); + const [newProfileName, setNewProfileName] = useState(""); + const [nameError, setNameError] = useState(null); + + const validateName = useCallback( + (name: string): string | null => { + if (!name.trim()) return null; + if (!/^[a-z0-9_]+$/.test(name)) { + return t("profiles.nameInvalid", { + ns: "views/settings", + defaultValue: "Only lowercase letters, numbers, and underscores", + }); + } + if (allProfileNames.includes(name)) { + return t("profiles.nameDuplicate", { + ns: "views/settings", + defaultValue: "Profile already exists", + }); + } + return null; + }, + [allProfileNames, t], + ); + + const handleAddSubmit = useCallback(() => { + const name = newProfileName.trim(); + if (!name) return; + const error = validateName(name); + if (error) { + setNameError(error); + return; + } + onAddProfile(name); + onSelectProfile(name); + setAddDialogOpen(false); + setNewProfileName(""); + setNameError(null); + }, [newProfileName, validateName, onAddProfile, onSelectProfile]); + + const handleDeleteConfirm = useCallback(() => { + if (!deleteConfirmProfile) return; + onDeleteProfileSection(deleteConfirmProfile); + if (editingProfile === deleteConfirmProfile) { + onSelectProfile(null); + } + setDeleteConfirmProfile(null); + }, [ + deleteConfirmProfile, + editingProfile, + onDeleteProfileSection, + onSelectProfile, + ]); + + const activeColor = editingProfile + ? getProfileColor(editingProfile, allProfileNames) + : null; + + return ( + <> + + + + + + onSelectProfile(null)}> +
+ {editingProfile === null && ( + + )} + + {t("profiles.baseConfig", { + ns: "views/settings", + defaultValue: "Base Config", + })} + +
+
+ + {allProfileNames.length > 0 && } + + {allProfileNames.map((profile) => { + const color = getProfileColor(profile, allProfileNames); + const hasData = hasProfileData(profile); + const isActive = editingProfile === profile; + + return ( + onSelectProfile(profile)} + > +
+ {isActive && } + + {profile} + {!hasData && ( + + {t("profiles.noOverrides", { + ns: "views/settings", + defaultValue: "no overrides", + })} + + )} +
+ {hasData && ( + + )} +
+ ); + })} + + + { + setNewProfileName(""); + setNameError(null); + setAddDialogOpen(true); + }} + > + + {t("profiles.addProfile", { + ns: "views/settings", + defaultValue: "Add Profile...", + })} + +
+
+ + + + + + {t("profiles.newProfile", { + ns: "views/settings", + defaultValue: "New Profile", + })} + + +
+ { + setNewProfileName(e.target.value); + setNameError(validateName(e.target.value)); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddSubmit(); + } + }} + autoFocus + /> + {nameError && ( +

{nameError}

+ )} +
+ + + + +
+
+ + { + if (!open) setDeleteConfirmProfile(null); + }} + > + + + + {t("profiles.deleteSection", { + ns: "views/settings", + defaultValue: "Delete Section Overrides", + })} + + + {t("profiles.deleteSectionConfirm", { + ns: "views/settings", + defaultValue: + "Remove {{profile}}'s overrides for {{section}} on {{camera}}?", + profile: deleteConfirmProfile, + section: sectionKey, + camera: cameraName, + })} + + + + + {t("button.cancel", { ns: "common" })} + + + {t("button.delete", { ns: "common", defaultValue: "Delete" })} + + + + + + ); +} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 00b9fdf68..7835ed1c3 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -1571,6 +1571,7 @@ export default function Settings() { onSectionStatusChange={handleSectionStatusChange} pendingDataBySection={pendingDataBySection} onPendingDataChange={handlePendingDataChange} + profileState={profileState} /> ); })()} diff --git a/web/src/views/settings/SingleSectionPage.tsx b/web/src/views/settings/SingleSectionPage.tsx index c1027aa18..5dd0feb14 100644 --- a/web/src/views/settings/SingleSectionPage.tsx +++ b/web/src/views/settings/SingleSectionPage.tsx @@ -1,16 +1,22 @@ 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 } from "@/utils/configUtil"; +import { + getSectionConfig, + PROFILE_ELIGIBLE_SECTIONS, +} 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; @@ -58,6 +64,7 @@ export function SingleSectionPage({ onSectionStatusChange, pendingDataBySection, onPendingDataChange, + profileState, }: SingleSectionPageProps) { const sectionNamespace = level === "camera" ? "config/cameras" : "config/global"; @@ -67,6 +74,7 @@ export function SingleSectionPage({ "common", ]); const { getLocaleDocUrl } = useDocDomain(); + const { data: config } = useSWR("config"); const [sectionStatus, setSectionStatus] = useState({ hasChanges: false, isOverridden: false, @@ -80,6 +88,20 @@ 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; + const currentEditingProfile = profileKey + ? (profileState?.editingProfile[profileKey] ?? null) + : null; + const handleSectionStatusChange = useCallback( (status: SectionStatus) => { setSectionStatus(status); @@ -126,6 +148,36 @@ 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 && ( @@ -162,6 +214,7 @@ export function SingleSectionPage({ onPendingDataChange={onPendingDataChange} requiresRestart={requiresRestart} onStatusChange={handleSectionStatusChange} + profileName={currentEditingProfile ?? undefined} />
);