add profile section dropdown and wire into camera settings pages

This commit is contained in:
Josh Hawkins 2026-03-09 15:20:50 -05:00
parent d5dc77daa4
commit 94dbabd0ef
3 changed files with 365 additions and 1 deletions

View File

@ -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<string | null>(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 (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 text-xs font-normal"
>
{editingProfile ? (
<>
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
activeColor?.dot,
)}
/>
{editingProfile}
</>
) : (
t("profiles.baseConfig", {
ns: "views/settings",
defaultValue: "Base Config",
})
)}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]">
<DropdownMenuItem onClick={() => onSelectProfile(null)}>
<div className="flex w-full items-center gap-2">
{editingProfile === null && (
<Check className="h-3.5 w-3.5 shrink-0" />
)}
<span className={editingProfile === null ? "" : "pl-[22px]"}>
{t("profiles.baseConfig", {
ns: "views/settings",
defaultValue: "Base Config",
})}
</span>
</div>
</DropdownMenuItem>
{allProfileNames.length > 0 && <DropdownMenuSeparator />}
{allProfileNames.map((profile) => {
const color = getProfileColor(profile, allProfileNames);
const hasData = hasProfileData(profile);
const isActive = editingProfile === profile;
return (
<DropdownMenuItem
key={profile}
className="group flex items-center justify-between gap-2"
onClick={() => onSelectProfile(profile)}
>
<div className="flex items-center gap-2">
{isActive && <Check className="h-3.5 w-3.5 shrink-0" />}
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
color.dot,
!isActive && "ml-[22px]",
)}
/>
<span>{profile}</span>
{!hasData && (
<span className="text-xs text-muted-foreground">
{t("profiles.noOverrides", {
ns: "views/settings",
defaultValue: "no overrides",
})}
</span>
)}
</div>
{hasData && (
<button
className="invisible rounded p-0.5 text-muted-foreground group-hover:visible hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
setDeleteConfirmProfile(profile);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setNewProfileName("");
setNameError(null);
setAddDialogOpen(true);
}}
>
<Plus className="mr-2 h-3.5 w-3.5" />
{t("profiles.addProfile", {
ns: "views/settings",
defaultValue: "Add Profile...",
})}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent className="sm:max-w-[360px]">
<DialogHeader>
<DialogTitle>
{t("profiles.newProfile", {
ns: "views/settings",
defaultValue: "New Profile",
})}
</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Input
placeholder={t("profiles.profileNamePlaceholder", {
ns: "views/settings",
defaultValue: "e.g., armed, away, night",
})}
value={newProfileName}
onChange={(e) => {
setNewProfileName(e.target.value);
setNameError(validateName(e.target.value));
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddSubmit();
}
}}
autoFocus
/>
{nameError && (
<p className="text-xs text-destructive">{nameError}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddDialogOpen(false)}>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
onClick={handleAddSubmit}
disabled={!newProfileName.trim() || !!nameError}
>
{t("button.create", { ns: "common", defaultValue: "Create" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={!!deleteConfirmProfile}
onOpenChange={(open) => {
if (!open) setDeleteConfirmProfile(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("profiles.deleteSection", {
ns: "views/settings",
defaultValue: "Delete Section Overrides",
})}
</AlertDialogTitle>
<AlertDialogDescription>
{t("profiles.deleteSectionConfirm", {
ns: "views/settings",
defaultValue:
"Remove {{profile}}'s overrides for {{section}} on {{camera}}?",
profile: deleteConfirmProfile,
section: sectionKey,
camera: cameraName,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
onClick={handleDeleteConfirm}
>
{t("button.delete", { ns: "common", defaultValue: "Delete" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@ -1571,6 +1571,7 @@ export default function Settings() {
onSectionStatusChange={handleSectionStatusChange}
pendingDataBySection={pendingDataBySection}
onPendingDataChange={handlePendingDataChange}
profileState={profileState}
/>
);
})()}

View File

@ -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<FrigateConfig>("config");
const [sectionStatus, setSectionStatus] = useState<SectionStatus>({
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({
</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 && (
@ -162,6 +214,7 @@ export function SingleSectionPage({
onPendingDataChange={onPendingDataChange}
requiresRestart={requiresRestart}
onStatusChange={handleSectionStatusChange}
profileName={currentEditingProfile ?? undefined}
/>
</div>
);