mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-13 11:57:36 +03:00
add profile section dropdown and wire into camera settings pages
This commit is contained in:
parent
d5dc77daa4
commit
94dbabd0ef
310
web/src/components/settings/ProfileSectionDropdown.tsx
Normal file
310
web/src/components/settings/ProfileSectionDropdown.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1571,6 +1571,7 @@ export default function Settings() {
|
||||
onSectionStatusChange={handleSectionStatusChange}
|
||||
pendingDataBySection={pendingDataBySection}
|
||||
onPendingDataChange={handlePendingDataChange}
|
||||
profileState={profileState}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user