mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-27 10:38:21 +03:00
implement profile friendly names and improve profile UI
- Add ProfileDefinitionConfig type and profiles field to FrigateConfig - Use ProfilesApiResponse type with friendly_name support throughout - Replace Record<string, unknown> with proper JsonObject/JsonValue types - Add profile creation form matching zone pattern (Zod + NameAndIdFields) - Add pencil icon for renaming profile friendly names in ProfilesView - Move Profiles menu item to first under Camera Configuration - Add activity indicators on save/rename/delete buttons - Display friendly names in CameraManagementView profile selector - Fix duplicate colored dots in management profile dropdown - Fix i18n namespace for overridden base config tooltips - Move profile override deletion from dropdown trash icon to footer button with confirmation dialog, matching Reset to Global pattern - Remove Add Profile from section header dropdown to prevent saving camera overrides before top-level profile definition exists - Clean up newProfiles state after API profile deletion - Refresh profiles SWR cache after saving profile definitions
This commit is contained in:
parent
0b3c6ed22e
commit
39500b20a0
@ -1464,12 +1464,23 @@
|
|||||||
"baseConfig": "Base Config",
|
"baseConfig": "Base Config",
|
||||||
"addProfile": "Add Profile",
|
"addProfile": "Add Profile",
|
||||||
"newProfile": "New Profile",
|
"newProfile": "New Profile",
|
||||||
"profileNamePlaceholder": "e.g., armed, away, night",
|
"profileNamePlaceholder": "e.g., Armed, Away, Night Mode",
|
||||||
|
"friendlyNameLabel": "Profile Name",
|
||||||
|
"profileIdLabel": "Profile ID",
|
||||||
|
"profileIdDescription": "Internal identifier used in config and automations",
|
||||||
"nameInvalid": "Only lowercase letters, numbers, and underscores allowed",
|
"nameInvalid": "Only lowercase letters, numbers, and underscores allowed",
|
||||||
"nameDuplicate": "A profile with this name already exists",
|
"nameDuplicate": "A profile with this name already exists",
|
||||||
|
"error": {
|
||||||
|
"mustBeAtLeastTwoCharacters": "Must be at least 2 characters",
|
||||||
|
"mustNotContainPeriod": "Must not contain periods",
|
||||||
|
"alreadyExists": "A profile with this ID already exists"
|
||||||
|
},
|
||||||
|
"renameProfile": "Rename Profile",
|
||||||
|
"renameSuccess": "Profile renamed to '{{profile}}'",
|
||||||
"deleteProfile": "Delete Profile",
|
"deleteProfile": "Delete Profile",
|
||||||
"deleteProfileConfirm": "Delete profile \"{{profile}}\" from all cameras? This cannot be undone.",
|
"deleteProfileConfirm": "Delete profile \"{{profile}}\" from all cameras? This cannot be undone.",
|
||||||
"deleteSuccess": "Profile '{{profile}}' deleted",
|
"deleteSuccess": "Profile '{{profile}}' deleted",
|
||||||
|
"removeOverride": "Remove Profile Override",
|
||||||
"deleteSection": "Delete Section Overrides",
|
"deleteSection": "Delete Section Overrides",
|
||||||
"deleteSectionConfirm": "Remove the {{section}} overrides for profile {{profile}} on {{camera}}?",
|
"deleteSectionConfirm": "Remove the {{section}} overrides for profile {{profile}} on {{camera}}?",
|
||||||
"deleteSectionSuccess": "Removed {{section}} overrides for {{profile}}",
|
"deleteSectionSuccess": "Removed {{section}} overrides for {{profile}}",
|
||||||
|
|||||||
@ -136,7 +136,7 @@ export interface BaseSectionProps {
|
|||||||
hasValidationErrors: boolean;
|
hasValidationErrors: boolean;
|
||||||
}) => void;
|
}) => void;
|
||||||
/** Pending form data keyed by "sectionKey" or "cameraName::sectionKey" */
|
/** Pending form data keyed by "sectionKey" or "cameraName::sectionKey" */
|
||||||
pendingDataBySection?: Record<string, unknown>;
|
pendingDataBySection?: Record<string, ConfigSectionData>;
|
||||||
/** Callback to update pending data for a section */
|
/** Callback to update pending data for a section */
|
||||||
onPendingDataChange?: (
|
onPendingDataChange?: (
|
||||||
sectionKey: string,
|
sectionKey: string,
|
||||||
@ -145,8 +145,12 @@ export interface BaseSectionProps {
|
|||||||
) => void;
|
) => void;
|
||||||
/** When set, editing this profile's overrides instead of the base config */
|
/** When set, editing this profile's overrides instead of the base config */
|
||||||
profileName?: string;
|
profileName?: string;
|
||||||
|
/** Display name for the profile (friendly name) */
|
||||||
|
profileFriendlyName?: string;
|
||||||
/** Border color class for profile override badge (e.g., "border-amber-500") */
|
/** Border color class for profile override badge (e.g., "border-amber-500") */
|
||||||
profileBorderColor?: string;
|
profileBorderColor?: string;
|
||||||
|
/** Callback to delete the current profile's overrides for this section */
|
||||||
|
onDeleteProfileSection?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateSectionOptions {
|
export interface CreateSectionOptions {
|
||||||
@ -178,7 +182,9 @@ export function ConfigSection({
|
|||||||
pendingDataBySection,
|
pendingDataBySection,
|
||||||
onPendingDataChange,
|
onPendingDataChange,
|
||||||
profileName,
|
profileName,
|
||||||
|
profileFriendlyName,
|
||||||
profileBorderColor,
|
profileBorderColor,
|
||||||
|
onDeleteProfileSection,
|
||||||
}: ConfigSectionProps) {
|
}: ConfigSectionProps) {
|
||||||
// For replay level, treat as camera-level config access
|
// For replay level, treat as camera-level config access
|
||||||
const effectiveLevel = level === "replay" ? "camera" : level;
|
const effectiveLevel = level === "replay" ? "camera" : level;
|
||||||
@ -243,6 +249,8 @@ export function ConfigSection({
|
|||||||
const [extraHasChanges, setExtraHasChanges] = useState(false);
|
const [extraHasChanges, setExtraHasChanges] = useState(false);
|
||||||
const [formKey, setFormKey] = useState(0);
|
const [formKey, setFormKey] = useState(0);
|
||||||
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
|
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
|
||||||
|
const [isDeleteProfileDialogOpen, setIsDeleteProfileDialogOpen] =
|
||||||
|
useState(false);
|
||||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||||
const isResettingRef = useRef(false);
|
const isResettingRef = useRef(false);
|
||||||
const isInitializingRef = useRef(true);
|
const isInitializingRef = useRef(true);
|
||||||
@ -932,6 +940,23 @@ export function ConfigSection({
|
|||||||
})}
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{profileName &&
|
||||||
|
profileOverridesSection &&
|
||||||
|
!hasChanges &&
|
||||||
|
!skipSave &&
|
||||||
|
onDeleteProfileSection && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsDeleteProfileDialogOpen(true)}
|
||||||
|
variant="outline"
|
||||||
|
disabled={isSaving || disabled}
|
||||||
|
className="flex flex-1 gap-2"
|
||||||
|
>
|
||||||
|
{t("profiles.removeOverride", {
|
||||||
|
ns: "views/settings",
|
||||||
|
defaultValue: "Remove Profile Override",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
@ -1003,6 +1028,47 @@ export function ConfigSection({
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={isDeleteProfileDialogOpen}
|
||||||
|
onOpenChange={setIsDeleteProfileDialogOpen}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t("profiles.deleteSection", { ns: "views/settings" })}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("profiles.deleteSectionConfirm", {
|
||||||
|
ns: "views/settings",
|
||||||
|
profile: profileFriendlyName ?? profileName,
|
||||||
|
section: t(`${sectionPath}.label`, {
|
||||||
|
ns:
|
||||||
|
effectiveLevel === "camera"
|
||||||
|
? "config/cameras"
|
||||||
|
: "config/global",
|
||||||
|
defaultValue: sectionPath,
|
||||||
|
}),
|
||||||
|
camera: cameraName ?? "",
|
||||||
|
})}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{t("button.cancel", { ns: "common" })}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-white hover:bg-destructive/90"
|
||||||
|
onClick={() => {
|
||||||
|
onDeleteProfileSection?.();
|
||||||
|
setIsDeleteProfileDialogOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("button.delete", { ns: "common" })}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1028,7 +1094,7 @@ export function ConfigSection({
|
|||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{overrideSource === "profile"
|
{overrideSource === "profile"
|
||||||
? t("button.overriddenBaseConfig", {
|
? t("button.overriddenBaseConfig", {
|
||||||
ns: "common",
|
ns: "views/settings",
|
||||||
defaultValue: "Overridden (Base Config)",
|
defaultValue: "Overridden (Base Config)",
|
||||||
})
|
})
|
||||||
: t("button.overriddenGlobal", {
|
: t("button.overriddenGlobal", {
|
||||||
@ -1040,8 +1106,8 @@ export function ConfigSection({
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{overrideSource === "profile"
|
{overrideSource === "profile"
|
||||||
? t("button.overriddenBaseConfigTooltip", {
|
? t("button.overriddenBaseConfigTooltip", {
|
||||||
ns: "common",
|
ns: "views/settings",
|
||||||
profile: profileName,
|
profile: profileFriendlyName ?? profileName,
|
||||||
})
|
})
|
||||||
: t("button.overriddenGlobalTooltip", {
|
: t("button.overriddenGlobalTooltip", {
|
||||||
ns: "views/settings",
|
ns: "views/settings",
|
||||||
@ -1099,7 +1165,7 @@ export function ConfigSection({
|
|||||||
>
|
>
|
||||||
{overrideSource === "profile"
|
{overrideSource === "profile"
|
||||||
? t("button.overriddenBaseConfig", {
|
? t("button.overriddenBaseConfig", {
|
||||||
ns: "common",
|
ns: "views/settings",
|
||||||
defaultValue: "Overridden (Base Config)",
|
defaultValue: "Overridden (Base Config)",
|
||||||
})
|
})
|
||||||
: t("button.overriddenGlobal", {
|
: t("button.overriddenGlobal", {
|
||||||
@ -1111,8 +1177,8 @@ export function ConfigSection({
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{overrideSource === "profile"
|
{overrideSource === "profile"
|
||||||
? t("button.overriddenBaseConfigTooltip", {
|
? t("button.overriddenBaseConfigTooltip", {
|
||||||
ns: "common",
|
ns: "views/settings",
|
||||||
profile: profileName,
|
profile: profileFriendlyName ?? profileName,
|
||||||
})
|
})
|
||||||
: t("button.overriddenGlobalTooltip", {
|
: t("button.overriddenGlobalTooltip", {
|
||||||
ns: "views/settings",
|
ns: "views/settings",
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import { useCallback, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Check, ChevronDown, Plus, Trash2 } from "lucide-react";
|
import { Check, ChevronDown } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { getProfileColor } from "@/utils/profileColors";
|
import { getProfileColor } from "@/utils/profileColors";
|
||||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@ -11,281 +9,100 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} 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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
|
|
||||||
type ProfileSectionDropdownProps = {
|
type ProfileSectionDropdownProps = {
|
||||||
cameraName: string;
|
|
||||||
sectionKey: string;
|
|
||||||
allProfileNames: string[];
|
allProfileNames: string[];
|
||||||
|
profileFriendlyNames: Map<string, string>;
|
||||||
editingProfile: string | null;
|
editingProfile: string | null;
|
||||||
hasProfileData: (profileName: string) => boolean;
|
hasProfileData: (profileName: string) => boolean;
|
||||||
onSelectProfile: (profileName: string | null) => void;
|
onSelectProfile: (profileName: string | null) => void;
|
||||||
onAddProfile: (name: string) => void;
|
|
||||||
onDeleteProfileSection: (profileName: string) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProfileSectionDropdown({
|
export function ProfileSectionDropdown({
|
||||||
cameraName,
|
|
||||||
sectionKey,
|
|
||||||
allProfileNames,
|
allProfileNames,
|
||||||
|
profileFriendlyNames,
|
||||||
editingProfile,
|
editingProfile,
|
||||||
hasProfileData,
|
hasProfileData,
|
||||||
onSelectProfile,
|
onSelectProfile,
|
||||||
onAddProfile,
|
|
||||||
onDeleteProfileSection,
|
|
||||||
}: ProfileSectionDropdownProps) {
|
}: ProfileSectionDropdownProps) {
|
||||||
const { t } = useTranslation(["views/settings", "common"]);
|
const { t } = useTranslation(["views/settings"]);
|
||||||
const friendlyCameraName = useCameraFriendlyName(cameraName);
|
|
||||||
const friendlySectionName = t(`configForm.sections.${sectionKey}`, {
|
|
||||||
ns: "views/settings",
|
|
||||||
defaultValue: sectionKey,
|
|
||||||
});
|
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (allProfileNames.includes(name)) {
|
|
||||||
return t("profiles.nameDuplicate", {
|
|
||||||
ns: "views/settings",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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
|
const activeColor = editingProfile
|
||||||
? getProfileColor(editingProfile, allProfileNames)
|
? getProfileColor(editingProfile, allProfileNames)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const editingFriendlyName = editingProfile
|
||||||
|
? (profileFriendlyNames.get(editingProfile) ?? editingProfile)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenuTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<Button variant="outline" className="h-9 gap-2 font-normal">
|
||||||
<Button variant="outline" className="h-9 gap-2 font-normal">
|
{editingProfile ? (
|
||||||
{editingProfile ? (
|
<>
|
||||||
<>
|
<span
|
||||||
<span
|
className={cn(
|
||||||
className={cn(
|
"h-2 w-2 shrink-0 rounded-full",
|
||||||
"h-2 w-2 shrink-0 rounded-full",
|
activeColor?.dot,
|
||||||
activeColor?.dot,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{editingProfile}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
t("profiles.baseConfig", { ns: "views/settings" })
|
|
||||||
)}
|
|
||||||
<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" })}
|
|
||||||
</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-start justify-between gap-2"
|
|
||||||
onClick={() => onSelectProfile(profile)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<div className="flex w-full flex-row items-center justify-start 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>
|
|
||||||
</div>
|
|
||||||
{!hasData && (
|
|
||||||
<span className="ml-[22px] text-xs text-muted-foreground">
|
|
||||||
{t("profiles.noOverrides", { ns: "views/settings" })}
|
|
||||||
</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>
|
/>
|
||||||
);
|
{editingFriendlyName}
|
||||||
})}
|
</>
|
||||||
|
) : (
|
||||||
<DropdownMenuSeparator />
|
t("profiles.baseConfig", { ns: "views/settings" })
|
||||||
<DropdownMenuItem
|
)}
|
||||||
onClick={() => {
|
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||||
setNewProfileName("");
|
</Button>
|
||||||
setNameError(null);
|
</DropdownMenuTrigger>
|
||||||
setAddDialogOpen(true);
|
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||||
}}
|
<DropdownMenuItem onClick={() => onSelectProfile(null)}>
|
||||||
>
|
<div className="flex w-full items-center gap-2">
|
||||||
<Plus className="mr-2 h-3.5 w-3.5" />
|
{editingProfile === null && (
|
||||||
{t("profiles.addProfile", { ns: "views/settings" })}
|
<Check className="h-3.5 w-3.5 shrink-0" />
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
|
|
||||||
<DialogContent className="sm:max-w-[360px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{t("profiles.newProfile", { ns: "views/settings" })}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-2 py-2">
|
|
||||||
<Input
|
|
||||||
placeholder={t("profiles.profileNamePlaceholder", {
|
|
||||||
ns: "views/settings",
|
|
||||||
})}
|
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
|
<span className={editingProfile === null ? "" : "pl-[22px]"}>
|
||||||
|
{t("profiles.baseConfig", { ns: "views/settings" })}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
</DropdownMenuItem>
|
||||||
<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" })}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<AlertDialog
|
{allProfileNames.length > 0 && <DropdownMenuSeparator />}
|
||||||
open={!!deleteConfirmProfile}
|
|
||||||
onOpenChange={(open) => {
|
{allProfileNames.map((profile) => {
|
||||||
if (!open) setDeleteConfirmProfile(null);
|
const color = getProfileColor(profile, allProfileNames);
|
||||||
}}
|
const hasData = hasProfileData(profile);
|
||||||
>
|
const isActive = editingProfile === profile;
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
return (
|
||||||
<AlertDialogTitle>
|
<DropdownMenuItem
|
||||||
{t("profiles.deleteSection", { ns: "views/settings" })}
|
key={profile}
|
||||||
</AlertDialogTitle>
|
className="group flex items-start justify-between gap-2"
|
||||||
<AlertDialogDescription>
|
onClick={() => onSelectProfile(profile)}
|
||||||
{t("profiles.deleteSectionConfirm", {
|
|
||||||
ns: "views/settings",
|
|
||||||
profile: deleteConfirmProfile,
|
|
||||||
section: friendlySectionName,
|
|
||||||
camera: friendlyCameraName,
|
|
||||||
})}
|
|
||||||
</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" })}
|
<div className="flex flex-col items-center gap-2">
|
||||||
</AlertDialogAction>
|
<div className="flex w-full flex-row items-center justify-start gap-2">
|
||||||
</AlertDialogFooter>
|
{isActive && <Check className="h-3.5 w-3.5 shrink-0" />}
|
||||||
</AlertDialogContent>
|
<span
|
||||||
</AlertDialog>
|
className={cn(
|
||||||
</>
|
"h-2 w-2 shrink-0 rounded-full",
|
||||||
|
color.dot,
|
||||||
|
!isActive && "ml-[22px]",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span>{profileFriendlyNames.get(profile) ?? profile}</span>
|
||||||
|
</div>
|
||||||
|
{!hasData && (
|
||||||
|
<span className="ml-[22px] text-xs text-muted-foreground">
|
||||||
|
{t("profiles.noOverrides", { ns: "views/settings" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,11 @@ import useOptimisticState from "@/hooks/use-optimistic-state";
|
|||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
import { FaVideo } from "react-icons/fa";
|
import { FaVideo } from "react-icons/fa";
|
||||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||||
import type { ConfigSectionData } from "@/types/configForm";
|
import type {
|
||||||
|
ConfigSectionData,
|
||||||
|
JsonObject,
|
||||||
|
JsonValue,
|
||||||
|
} from "@/types/configForm";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||||
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
|
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
|
||||||
@ -92,7 +96,7 @@ import {
|
|||||||
prepareSectionSavePayload,
|
prepareSectionSavePayload,
|
||||||
PROFILE_ELIGIBLE_SECTIONS,
|
PROFILE_ELIGIBLE_SECTIONS,
|
||||||
} from "@/utils/configUtil";
|
} from "@/utils/configUtil";
|
||||||
import type { ProfileState } from "@/types/profile";
|
import type { ProfileState, ProfilesApiResponse } from "@/types/profile";
|
||||||
import { getProfileColor } from "@/utils/profileColors";
|
import { getProfileColor } from "@/utils/profileColors";
|
||||||
import { ProfileSectionDropdown } from "@/components/settings/ProfileSectionDropdown";
|
import { ProfileSectionDropdown } from "@/components/settings/ProfileSectionDropdown";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@ -186,15 +190,15 @@ const parsePendingDataKey = (pendingDataKey: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const flattenOverrides = (
|
const flattenOverrides = (
|
||||||
value: unknown,
|
value: JsonValue | undefined,
|
||||||
path: string[] = [],
|
path: string[] = [],
|
||||||
): Array<{ path: string; value: unknown }> => {
|
): Array<{ path: string; value: JsonValue }> => {
|
||||||
if (value === undefined) return [];
|
if (value === undefined) return [];
|
||||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||||
return [{ path: path.join("."), value }];
|
return [{ path: path.join("."), value }];
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = Object.entries(value as Record<string, unknown>);
|
const entries = Object.entries(value);
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return [{ path: path.join("."), value: {} }];
|
return [{ path: path.join("."), value: {} }];
|
||||||
}
|
}
|
||||||
@ -316,10 +320,7 @@ const CameraTimestampStyleSettingsPage = createSectionPage(
|
|||||||
const settingsGroups = [
|
const settingsGroups = [
|
||||||
{
|
{
|
||||||
label: "general",
|
label: "general",
|
||||||
items: [
|
items: [{ key: "uiSettings", component: UiSettingsView }],
|
||||||
{ key: "uiSettings", component: UiSettingsView },
|
|
||||||
{ key: "profiles", component: ProfilesView },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "globalConfig",
|
label: "globalConfig",
|
||||||
@ -345,6 +346,7 @@ const settingsGroups = [
|
|||||||
{
|
{
|
||||||
label: "cameras",
|
label: "cameras",
|
||||||
items: [
|
items: [
|
||||||
|
{ key: "profiles", component: ProfilesView },
|
||||||
{ key: "cameraManagement", component: CameraManagementView },
|
{ key: "cameraManagement", component: CameraManagementView },
|
||||||
{ key: "cameraDetect", component: CameraDetectSettingsPage },
|
{ key: "cameraDetect", component: CameraDetectSettingsPage },
|
||||||
{ key: "cameraObjects", component: CameraObjectsSettingsPage },
|
{ key: "cameraObjects", component: CameraObjectsSettingsPage },
|
||||||
@ -635,10 +637,7 @@ export default function Settings() {
|
|||||||
>({});
|
>({});
|
||||||
|
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const { data: profilesData } = useSWR<{
|
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
|
||||||
profiles: string[];
|
|
||||||
active_profile: string | null;
|
|
||||||
}>("profiles");
|
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
@ -655,7 +654,7 @@ export default function Settings() {
|
|||||||
|
|
||||||
// Store pending form data keyed by "sectionKey" or "cameraName::sectionKey"
|
// Store pending form data keyed by "sectionKey" or "cameraName::sectionKey"
|
||||||
const [pendingDataBySection, setPendingDataBySection] = useState<
|
const [pendingDataBySection, setPendingDataBySection] = useState<
|
||||||
Record<string, unknown>
|
Record<string, ConfigSectionData>
|
||||||
>({});
|
>({});
|
||||||
|
|
||||||
// Profile editing state
|
// Profile editing state
|
||||||
@ -666,15 +665,29 @@ export default function Settings() {
|
|||||||
const [profilesUIEnabled, setProfilesUIEnabled] = useState(false);
|
const [profilesUIEnabled, setProfilesUIEnabled] = useState(false);
|
||||||
|
|
||||||
const allProfileNames = useMemo(() => {
|
const allProfileNames = useMemo(() => {
|
||||||
if (!config) return [];
|
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
Object.values(config.cameras).forEach((cam) => {
|
if (config?.profiles) {
|
||||||
Object.keys(cam.profiles ?? {}).forEach((p) => names.add(p));
|
Object.keys(config.profiles).forEach((p) => names.add(p));
|
||||||
});
|
}
|
||||||
newProfiles.forEach((p) => names.add(p));
|
newProfiles.forEach((p) => names.add(p));
|
||||||
return [...names].sort();
|
return [...names].sort();
|
||||||
}, [config, newProfiles]);
|
}, [config, newProfiles]);
|
||||||
|
|
||||||
|
const profileFriendlyNames = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
if (profilesData?.profiles) {
|
||||||
|
profilesData.profiles.forEach((p) => map.set(p.name, p.friendly_name));
|
||||||
|
}
|
||||||
|
// Include pending (unsaved) profile definitions
|
||||||
|
for (const [key, data] of Object.entries(pendingDataBySection)) {
|
||||||
|
if (key.startsWith("__profile_def__.") && data?.friendly_name) {
|
||||||
|
const id = key.slice("__profile_def__.".length);
|
||||||
|
map.set(id, String(data.friendly_name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [profilesData, pendingDataBySection]);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const cameras = useMemo(() => {
|
const cameras = useMemo(() => {
|
||||||
@ -756,7 +769,9 @@ export default function Settings() {
|
|||||||
items.push({
|
items.push({
|
||||||
scope,
|
scope,
|
||||||
cameraName,
|
cameraName,
|
||||||
profileName: isProfile ? profileName : undefined,
|
profileName: isProfile
|
||||||
|
? (profileFriendlyNames.get(profileName!) ?? profileName)
|
||||||
|
: undefined,
|
||||||
fieldPath,
|
fieldPath,
|
||||||
value,
|
value,
|
||||||
});
|
});
|
||||||
@ -773,7 +788,7 @@ export default function Settings() {
|
|||||||
if (cameraCompare !== 0) return cameraCompare;
|
if (cameraCompare !== 0) return cameraCompare;
|
||||||
return left.fieldPath.localeCompare(right.fieldPath);
|
return left.fieldPath.localeCompare(right.fieldPath);
|
||||||
});
|
});
|
||||||
}, [config, fullSchema, pendingDataBySection]);
|
}, [config, fullSchema, pendingDataBySection, profileFriendlyNames]);
|
||||||
|
|
||||||
// Map a pendingDataKey to SettingsType menu key for clearing section status
|
// Map a pendingDataKey to SettingsType menu key for clearing section status
|
||||||
const pendingKeyToMenuKey = useCallback(
|
const pendingKeyToMenuKey = useCallback(
|
||||||
@ -827,6 +842,28 @@ export default function Settings() {
|
|||||||
|
|
||||||
for (const key of pendingKeys) {
|
for (const key of pendingKeys) {
|
||||||
const pendingData = pendingDataBySection[key];
|
const pendingData = pendingDataBySection[key];
|
||||||
|
|
||||||
|
// Handle top-level profile definition saves
|
||||||
|
if (key.startsWith("__profile_def__.")) {
|
||||||
|
const profileId = key.replace("__profile_def__.", "");
|
||||||
|
try {
|
||||||
|
const configData = { profiles: { [profileId]: pendingData } };
|
||||||
|
await axios.put("config/set", {
|
||||||
|
requires_restart: 0,
|
||||||
|
config_data: configData,
|
||||||
|
});
|
||||||
|
setPendingDataBySection((prev) => {
|
||||||
|
const { [key]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
savedKeys.push(key);
|
||||||
|
successCount++;
|
||||||
|
} catch {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = prepareSectionSavePayload({
|
const payload = prepareSectionSavePayload({
|
||||||
pendingDataKey: key,
|
pendingDataKey: key,
|
||||||
@ -876,6 +913,11 @@ export default function Settings() {
|
|||||||
// Refresh config from server once
|
// Refresh config from server once
|
||||||
await mutate("config");
|
await mutate("config");
|
||||||
|
|
||||||
|
// If any profile definitions were saved, refresh profiles data too
|
||||||
|
if (savedKeys.some((key) => key.startsWith("__profile_def__."))) {
|
||||||
|
await mutate("profiles");
|
||||||
|
}
|
||||||
|
|
||||||
// Clear hasChanges in sidebar for all successfully saved sections
|
// Clear hasChanges in sidebar for all successfully saved sections
|
||||||
if (savedKeys.length > 0) {
|
if (savedKeys.length > 0) {
|
||||||
setSectionStatusByKey((prev) => {
|
setSectionStatusByKey((prev) => {
|
||||||
@ -954,13 +996,10 @@ export default function Settings() {
|
|||||||
setUnsavedChanges(false);
|
setUnsavedChanges(false);
|
||||||
setEditingProfile({});
|
setEditingProfile({});
|
||||||
|
|
||||||
// Clear new profiles that don't exist in saved config
|
// Clear new profiles that now exist in top-level config
|
||||||
if (config) {
|
if (config) {
|
||||||
const savedNames = new Set<string>();
|
const savedNames = new Set<string>(Object.keys(config.profiles ?? {}));
|
||||||
Object.values(config.cameras).forEach((cam) => {
|
setNewProfiles((prev) => prev.filter((p) => !savedNames.has(p)));
|
||||||
Object.keys(cam.profiles ?? {}).forEach((p) => savedNames.add(p));
|
|
||||||
});
|
|
||||||
setNewProfiles((prev) => prev.filter((p) => savedNames.has(p)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSectionStatusByKey((prev) => {
|
setSectionStatusByKey((prev) => {
|
||||||
@ -1062,8 +1101,14 @@ export default function Settings() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddProfile = useCallback((name: string) => {
|
const handleAddProfile = useCallback((id: string, friendlyName: string) => {
|
||||||
setNewProfiles((prev) => (prev.includes(name) ? prev : [...prev, name]));
|
setNewProfiles((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
||||||
|
|
||||||
|
// Stage the top-level profile definition for saving
|
||||||
|
setPendingDataBySection((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[`__profile_def__.${id}`]: { friendly_name: friendlyName },
|
||||||
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRemoveNewProfile = useCallback((name: string) => {
|
const handleRemoveNewProfile = useCallback((name: string) => {
|
||||||
@ -1120,14 +1165,14 @@ export default function Settings() {
|
|||||||
ns: "views/settings",
|
ns: "views/settings",
|
||||||
defaultValue: section,
|
defaultValue: section,
|
||||||
}),
|
}),
|
||||||
profile,
|
profile: profileFriendlyNames.get(profile) ?? profile,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t("toast.save.error.title", { ns: "common" }));
|
toast.error(t("toast.save.error.title", { ns: "common" }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleSelectProfile, t],
|
[handleSelectProfile, profileFriendlyNames, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const profileState: ProfileState = useMemo(
|
const profileState: ProfileState = useMemo(
|
||||||
@ -1135,6 +1180,7 @@ export default function Settings() {
|
|||||||
editingProfile,
|
editingProfile,
|
||||||
newProfiles,
|
newProfiles,
|
||||||
allProfileNames,
|
allProfileNames,
|
||||||
|
profileFriendlyNames,
|
||||||
onSelectProfile: handleSelectProfile,
|
onSelectProfile: handleSelectProfile,
|
||||||
onAddProfile: handleAddProfile,
|
onAddProfile: handleAddProfile,
|
||||||
onRemoveNewProfile: handleRemoveNewProfile,
|
onRemoveNewProfile: handleRemoveNewProfile,
|
||||||
@ -1144,6 +1190,7 @@ export default function Settings() {
|
|||||||
editingProfile,
|
editingProfile,
|
||||||
newProfiles,
|
newProfiles,
|
||||||
allProfileNames,
|
allProfileNames,
|
||||||
|
profileFriendlyNames,
|
||||||
handleSelectProfile,
|
handleSelectProfile,
|
||||||
handleAddProfile,
|
handleAddProfile,
|
||||||
handleRemoveNewProfile,
|
handleRemoveNewProfile,
|
||||||
@ -1207,7 +1254,7 @@ export default function Settings() {
|
|||||||
|
|
||||||
// Build a targeted delete payload that only removes mask-related
|
// Build a targeted delete payload that only removes mask-related
|
||||||
// sub-keys, not the entire motion/objects sections
|
// sub-keys, not the entire motion/objects sections
|
||||||
const deletePayload: Record<string, unknown> = {};
|
const deletePayload: JsonObject = {};
|
||||||
|
|
||||||
if (profileData.zones !== undefined) {
|
if (profileData.zones !== undefined) {
|
||||||
deletePayload.zones = "";
|
deletePayload.zones = "";
|
||||||
@ -1218,12 +1265,12 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (profileData.objects) {
|
if (profileData.objects) {
|
||||||
const objDelete: Record<string, unknown> = {};
|
const objDelete: JsonObject = {};
|
||||||
if (profileData.objects.mask !== undefined) {
|
if (profileData.objects.mask !== undefined) {
|
||||||
objDelete.mask = "";
|
objDelete.mask = "";
|
||||||
}
|
}
|
||||||
if (profileData.objects.filters) {
|
if (profileData.objects.filters) {
|
||||||
const filtersDelete: Record<string, unknown> = {};
|
const filtersDelete: JsonObject = {};
|
||||||
for (const [filterName, filterVal] of Object.entries(
|
for (const [filterName, filterVal] of Object.entries(
|
||||||
profileData.objects.filters,
|
profileData.objects.filters,
|
||||||
)) {
|
)) {
|
||||||
@ -1262,7 +1309,7 @@ export default function Settings() {
|
|||||||
section: t("configForm.sections.masksAndZones", {
|
section: t("configForm.sections.masksAndZones", {
|
||||||
ns: "views/settings",
|
ns: "views/settings",
|
||||||
}),
|
}),
|
||||||
profile: profileName,
|
profile: profileFriendlyNames.get(profileName) ?? profileName,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@ -1282,6 +1329,7 @@ export default function Settings() {
|
|||||||
config,
|
config,
|
||||||
handleSelectProfile,
|
handleSelectProfile,
|
||||||
handleDeleteProfileSection,
|
handleDeleteProfileSection,
|
||||||
|
profileFriendlyNames,
|
||||||
t,
|
t,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -1490,7 +1538,8 @@ export default function Settings() {
|
|||||||
setContentMobileOpen(true);
|
setContentMobileOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{profilesData.active_profile}
|
{profileFriendlyNames.get(profilesData.active_profile) ??
|
||||||
|
profilesData.active_profile}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -1607,9 +1656,8 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
{showProfileDropdown && currentSectionKey && (
|
{showProfileDropdown && currentSectionKey && (
|
||||||
<ProfileSectionDropdown
|
<ProfileSectionDropdown
|
||||||
cameraName={selectedCamera}
|
|
||||||
sectionKey={currentSectionKey}
|
|
||||||
allProfileNames={allProfileNames}
|
allProfileNames={allProfileNames}
|
||||||
|
profileFriendlyNames={profileFriendlyNames}
|
||||||
editingProfile={headerEditingProfile}
|
editingProfile={headerEditingProfile}
|
||||||
hasProfileData={headerHasProfileData}
|
hasProfileData={headerHasProfileData}
|
||||||
onSelectProfile={(profile) =>
|
onSelectProfile={(profile) =>
|
||||||
@ -1619,10 +1667,6 @@ export default function Settings() {
|
|||||||
profile,
|
profile,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onAddProfile={handleAddProfile}
|
|
||||||
onDeleteProfileSection={
|
|
||||||
handleDeleteProfileForCurrentSection
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<CameraSelectButton
|
<CameraSelectButton
|
||||||
@ -1653,6 +1697,9 @@ export default function Settings() {
|
|||||||
pendingDataBySection={pendingDataBySection}
|
pendingDataBySection={pendingDataBySection}
|
||||||
onPendingDataChange={handlePendingDataChange}
|
onPendingDataChange={handlePendingDataChange}
|
||||||
profileState={profileState}
|
profileState={profileState}
|
||||||
|
onDeleteProfileSection={
|
||||||
|
handleDeleteProfileForCurrentSection
|
||||||
|
}
|
||||||
profilesUIEnabled={profilesUIEnabled}
|
profilesUIEnabled={profilesUIEnabled}
|
||||||
setProfilesUIEnabled={setProfilesUIEnabled}
|
setProfilesUIEnabled={setProfilesUIEnabled}
|
||||||
/>
|
/>
|
||||||
@ -1771,9 +1818,8 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
{showProfileDropdown && currentSectionKey && (
|
{showProfileDropdown && currentSectionKey && (
|
||||||
<ProfileSectionDropdown
|
<ProfileSectionDropdown
|
||||||
cameraName={selectedCamera}
|
|
||||||
sectionKey={currentSectionKey}
|
|
||||||
allProfileNames={allProfileNames}
|
allProfileNames={allProfileNames}
|
||||||
|
profileFriendlyNames={profileFriendlyNames}
|
||||||
editingProfile={headerEditingProfile}
|
editingProfile={headerEditingProfile}
|
||||||
hasProfileData={headerHasProfileData}
|
hasProfileData={headerHasProfileData}
|
||||||
onSelectProfile={(profile) =>
|
onSelectProfile={(profile) =>
|
||||||
@ -1783,8 +1829,6 @@ export default function Settings() {
|
|||||||
profile,
|
profile,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onAddProfile={handleAddProfile}
|
|
||||||
onDeleteProfileSection={handleDeleteProfileForCurrentSection}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<CameraSelectButton
|
<CameraSelectButton
|
||||||
@ -1903,6 +1947,7 @@ export default function Settings() {
|
|||||||
pendingDataBySection={pendingDataBySection}
|
pendingDataBySection={pendingDataBySection}
|
||||||
onPendingDataChange={handlePendingDataChange}
|
onPendingDataChange={handlePendingDataChange}
|
||||||
profileState={profileState}
|
profileState={profileState}
|
||||||
|
onDeleteProfileSection={handleDeleteProfileForCurrentSection}
|
||||||
profilesUIEnabled={profilesUIEnabled}
|
profilesUIEnabled={profilesUIEnabled}
|
||||||
setProfilesUIEnabled={setProfilesUIEnabled}
|
setProfilesUIEnabled={setProfilesUIEnabled}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export type ConfigFormContext = {
|
|||||||
extraHasChanges?: boolean;
|
extraHasChanges?: boolean;
|
||||||
setExtraHasChanges?: (hasChanges: boolean) => void;
|
setExtraHasChanges?: (hasChanges: boolean) => void;
|
||||||
formData?: JsonObject;
|
formData?: JsonObject;
|
||||||
pendingDataBySection?: Record<string, unknown>;
|
pendingDataBySection?: Record<string, ConfigSectionData>;
|
||||||
onPendingDataChange?: (
|
onPendingDataChange?: (
|
||||||
sectionKey: string,
|
sectionKey: string,
|
||||||
cameraName: string | undefined,
|
cameraName: string | undefined,
|
||||||
|
|||||||
@ -334,6 +334,10 @@ export type CameraProfileConfig = {
|
|||||||
zones?: Partial<CameraConfig["zones"]>;
|
zones?: Partial<CameraConfig["zones"]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProfileDefinitionConfig = {
|
||||||
|
friendly_name: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CameraGroupConfig = {
|
export type CameraGroupConfig = {
|
||||||
cameras: string[];
|
cameras: string[];
|
||||||
icon: IconName;
|
icon: IconName;
|
||||||
@ -488,6 +492,8 @@ export interface FrigateConfig {
|
|||||||
|
|
||||||
camera_groups: { [groupName: string]: CameraGroupConfig };
|
camera_groups: { [groupName: string]: CameraGroupConfig };
|
||||||
|
|
||||||
|
profiles: { [profileName: string]: ProfileDefinitionConfig };
|
||||||
|
|
||||||
lpr: {
|
lpr: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,16 +6,27 @@ export type ProfileColor = {
|
|||||||
bgMuted: string;
|
bgMuted: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProfileInfo = {
|
||||||
|
name: string;
|
||||||
|
friendly_name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProfilesApiResponse = {
|
||||||
|
profiles: ProfileInfo[];
|
||||||
|
active_profile: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProfileState = {
|
export type ProfileState = {
|
||||||
editingProfile: Record<string, string | null>;
|
editingProfile: Record<string, string | null>;
|
||||||
newProfiles: string[];
|
newProfiles: string[];
|
||||||
allProfileNames: string[];
|
allProfileNames: string[];
|
||||||
|
profileFriendlyNames: Map<string, string>;
|
||||||
onSelectProfile: (
|
onSelectProfile: (
|
||||||
camera: string,
|
camera: string,
|
||||||
section: string,
|
section: string,
|
||||||
profile: string | null,
|
profile: string | null,
|
||||||
) => void;
|
) => void;
|
||||||
onAddProfile: (name: string) => void;
|
onAddProfile: (id: string, friendlyName: string) => void;
|
||||||
onRemoveNewProfile: (name: string) => void;
|
onRemoveNewProfile: (name: string) => void;
|
||||||
onDeleteProfileSection: (
|
onDeleteProfileSection: (
|
||||||
camera: string,
|
camera: string,
|
||||||
|
|||||||
@ -374,7 +374,7 @@ export function requiresRestartForFieldPath(
|
|||||||
|
|
||||||
export interface SectionSavePayload {
|
export interface SectionSavePayload {
|
||||||
basePath: string;
|
basePath: string;
|
||||||
sanitizedOverrides: Record<string, unknown>;
|
sanitizedOverrides: JsonObject;
|
||||||
updateTopic: string | undefined;
|
updateTopic: string | undefined;
|
||||||
needsRestart: boolean;
|
needsRestart: boolean;
|
||||||
pendingDataKey: string;
|
pendingDataKey: string;
|
||||||
@ -561,7 +561,7 @@ export function prepareSectionSavePayload(opts: {
|
|||||||
if (
|
if (
|
||||||
!sanitizedOverrides ||
|
!sanitizedOverrides ||
|
||||||
typeof sanitizedOverrides !== "object" ||
|
typeof sanitizedOverrides !== "object" ||
|
||||||
Object.keys(sanitizedOverrides as Record<string, unknown>).length === 0
|
Object.keys(sanitizedOverrides as JsonObject).length === 0
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -597,7 +597,7 @@ export function prepareSectionSavePayload(opts: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
basePath,
|
basePath,
|
||||||
sanitizedOverrides: sanitizedOverrides as Record<string, unknown>,
|
sanitizedOverrides: sanitizedOverrides as JsonObject,
|
||||||
updateTopic,
|
updateTopic,
|
||||||
needsRestart,
|
needsRestart,
|
||||||
pendingDataKey,
|
pendingDataKey,
|
||||||
|
|||||||
@ -474,10 +474,6 @@ function ProfileCameraEnableSection({
|
|||||||
[config, selectedProfile, localOverrides],
|
[config, selectedProfile, localOverrides],
|
||||||
);
|
);
|
||||||
|
|
||||||
const profileColor = selectedProfile
|
|
||||||
? getProfileColor(selectedProfile, profileState.allProfileNames)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!selectedProfile) return null;
|
if (!selectedProfile) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -502,17 +498,7 @@ function ProfileCameraEnableSection({
|
|||||||
<div className={`${CONTROL_COLUMN_CLASS_NAME} space-y-4`}>
|
<div className={`${CONTROL_COLUMN_CLASS_NAME} space-y-4`}>
|
||||||
<Select value={selectedProfile} onValueChange={setSelectedProfile}>
|
<Select value={selectedProfile} onValueChange={setSelectedProfile}>
|
||||||
<SelectTrigger className="w-full max-w-[200px]">
|
<SelectTrigger className="w-full max-w-[200px]">
|
||||||
<div className="flex items-center gap-2">
|
<SelectValue />
|
||||||
{profileColor && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"h-2 w-2 shrink-0 rounded-full",
|
|
||||||
profileColor.dot,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<SelectValue />
|
|
||||||
</div>
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{profileState.allProfileNames.map((profile) => {
|
{profileState.allProfileNames.map((profile) => {
|
||||||
@ -529,7 +515,8 @@ function ProfileCameraEnableSection({
|
|||||||
color.dot,
|
color.dot,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{profile}
|
{profileState.profileFriendlyNames.get(profile) ??
|
||||||
|
profile}
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useForm, FormProvider } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Trash2 } from "lucide-react";
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import { LuChevronDown, LuChevronRight, LuPlus } from "react-icons/lu";
|
import { LuChevronDown, LuChevronRight, LuPlus } from "react-icons/lu";
|
||||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import type { ProfileState } from "@/types/profile";
|
import type { JsonObject } from "@/types/configForm";
|
||||||
|
import type { ProfileState, ProfilesApiResponse } from "@/types/profile";
|
||||||
import { getProfileColor } from "@/utils/profileColors";
|
import { getProfileColor } from "@/utils/profileColors";
|
||||||
import { PROFILE_ELIGIBLE_SECTIONS } from "@/utils/configUtil";
|
import { PROFILE_ELIGIBLE_SECTIONS } from "@/utils/configUtil";
|
||||||
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
||||||
@ -15,6 +19,7 @@ import Heading from "@/components/ui/heading";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import NameAndIdFields from "@/components/input/NameAndIdFields";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -48,11 +53,6 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
|
||||||
type ProfilesApiResponse = {
|
|
||||||
profiles: string[];
|
|
||||||
active_profile: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProfilesViewProps = {
|
type ProfilesViewProps = {
|
||||||
setUnsavedChanges?: React.Dispatch<React.SetStateAction<boolean>>;
|
setUnsavedChanges?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
profileState?: ProfileState;
|
profileState?: ProfileState;
|
||||||
@ -74,12 +74,55 @@ export default function ProfilesView({
|
|||||||
const [activating, setActivating] = useState(false);
|
const [activating, setActivating] = useState(false);
|
||||||
const [deleteProfile, setDeleteProfile] = useState<string | null>(null);
|
const [deleteProfile, setDeleteProfile] = useState<string | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [renameProfile, setRenameProfile] = useState<string | null>(null);
|
||||||
|
const [renameValue, setRenameValue] = useState("");
|
||||||
|
const [renaming, setRenaming] = useState(false);
|
||||||
const [expandedProfiles, setExpandedProfiles] = useState<Set<string>>(
|
const [expandedProfiles, setExpandedProfiles] = useState<Set<string>>(
|
||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||||
const [newProfileName, setNewProfileName] = useState("");
|
|
||||||
const [nameError, setNameError] = useState<string | null>(null);
|
const allProfileNames = useMemo(
|
||||||
|
() => profileState?.allProfileNames ?? [],
|
||||||
|
[profileState?.allProfileNames],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addProfileSchema = useMemo(
|
||||||
|
() =>
|
||||||
|
z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(2, {
|
||||||
|
message: t("profiles.error.mustBeAtLeastTwoCharacters", {
|
||||||
|
ns: "views/settings",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.refine((value) => !value.includes("."), {
|
||||||
|
message: t("profiles.error.mustNotContainPeriod", {
|
||||||
|
ns: "views/settings",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.refine((value) => !allProfileNames.includes(value), {
|
||||||
|
message: t("profiles.error.alreadyExists", {
|
||||||
|
ns: "views/settings",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
friendly_name: z.string().min(2, {
|
||||||
|
message: t("profiles.error.mustBeAtLeastTwoCharacters", {
|
||||||
|
ns: "views/settings",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
[t, allProfileNames],
|
||||||
|
);
|
||||||
|
|
||||||
|
type AddProfileForm = z.infer<typeof addProfileSchema>;
|
||||||
|
const addForm = useForm<AddProfileForm>({
|
||||||
|
resolver: zodResolver(addProfileSchema),
|
||||||
|
defaultValues: { friendly_name: "", name: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const profileFriendlyNames = profileState?.profileFriendlyNames;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t("documentTitle.profiles", {
|
document.title = t("documentTitle.profiles", {
|
||||||
@ -87,10 +130,6 @@ export default function ProfilesView({
|
|||||||
});
|
});
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
const allProfileNames = useMemo(
|
|
||||||
() => profileState?.allProfileNames ?? [],
|
|
||||||
[profileState?.allProfileNames],
|
|
||||||
);
|
|
||||||
const activeProfile = profilesData?.active_profile ?? null;
|
const activeProfile = profilesData?.active_profile ?? null;
|
||||||
|
|
||||||
// Build overview data: for each profile, which cameras have which sections
|
// Build overview data: for each profile, which cameras have which sections
|
||||||
@ -126,34 +165,18 @@ export default function ProfilesView({
|
|||||||
return data;
|
return data;
|
||||||
}, [config, allProfileNames]);
|
}, [config, allProfileNames]);
|
||||||
|
|
||||||
const validateName = useCallback(
|
const handleAddSubmit = useCallback(
|
||||||
(name: string): string | null => {
|
(data: AddProfileForm) => {
|
||||||
if (!name.trim()) return null;
|
const id = data.name.trim();
|
||||||
if (!/^[a-z0-9_]+$/.test(name)) {
|
const friendlyName = data.friendly_name.trim();
|
||||||
return t("profiles.nameInvalid", { ns: "views/settings" });
|
if (!id || !friendlyName) return;
|
||||||
}
|
profileState?.onAddProfile(id, friendlyName);
|
||||||
if (allProfileNames.includes(name)) {
|
setAddDialogOpen(false);
|
||||||
return t("profiles.nameDuplicate", { ns: "views/settings" });
|
addForm.reset();
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
},
|
||||||
[allProfileNames, t],
|
[profileState, addForm],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddSubmit = useCallback(() => {
|
|
||||||
const name = newProfileName.trim();
|
|
||||||
if (!name) return;
|
|
||||||
const error = validateName(name);
|
|
||||||
if (error) {
|
|
||||||
setNameError(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
profileState?.onAddProfile(name);
|
|
||||||
setAddDialogOpen(false);
|
|
||||||
setNewProfileName("");
|
|
||||||
setNameError(null);
|
|
||||||
}, [newProfileName, validateName, profileState]);
|
|
||||||
|
|
||||||
const handleActivateProfile = useCallback(
|
const handleActivateProfile = useCallback(
|
||||||
async (profile: string | null) => {
|
async (profile: string | null) => {
|
||||||
setActivating(true);
|
setActivating(true);
|
||||||
@ -166,7 +189,7 @@ export default function ProfilesView({
|
|||||||
profile
|
profile
|
||||||
? t("profiles.activated", {
|
? t("profiles.activated", {
|
||||||
ns: "views/settings",
|
ns: "views/settings",
|
||||||
profile,
|
profile: profileFriendlyNames?.get(profile) ?? profile,
|
||||||
})
|
})
|
||||||
: t("profiles.deactivated", { ns: "views/settings" }),
|
: t("profiles.deactivated", { ns: "views/settings" }),
|
||||||
{ position: "top-center" },
|
{ position: "top-center" },
|
||||||
@ -184,7 +207,7 @@ export default function ProfilesView({
|
|||||||
setActivating(false);
|
setActivating(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateProfiles, t],
|
[updateProfiles, profileFriendlyNames, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteProfile = useCallback(async () => {
|
const handleDeleteProfile = useCallback(async () => {
|
||||||
@ -206,30 +229,38 @@ export default function ProfilesView({
|
|||||||
await axios.put("profile/set", { profile: null });
|
await axios.put("profile/set", { profile: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the profile from all cameras via config/set
|
// Remove the profile from all cameras and the top-level definition
|
||||||
const configData: Record<string, unknown> = {};
|
const cameraData: JsonObject = {};
|
||||||
for (const camera of Object.keys(config.cameras)) {
|
for (const camera of Object.keys(config.cameras)) {
|
||||||
if (config.cameras[camera]?.profiles?.[deleteProfile]) {
|
if (config.cameras[camera]?.profiles?.[deleteProfile]) {
|
||||||
configData[camera] = {
|
cameraData[camera] = {
|
||||||
profiles: { [deleteProfile]: "" },
|
profiles: { [deleteProfile]: "" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(configData).length > 0) {
|
const configData: JsonObject = {
|
||||||
await axios.put("config/set", {
|
profiles: { [deleteProfile]: "" },
|
||||||
requires_restart: 0,
|
};
|
||||||
config_data: { cameras: configData },
|
if (Object.keys(cameraData).length > 0) {
|
||||||
});
|
configData.cameras = cameraData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await axios.put("config/set", {
|
||||||
|
requires_restart: 0,
|
||||||
|
config_data: configData,
|
||||||
|
});
|
||||||
|
|
||||||
await updateConfig();
|
await updateConfig();
|
||||||
await updateProfiles();
|
await updateProfiles();
|
||||||
|
|
||||||
|
// Also clean up local newProfiles state if this profile was in it
|
||||||
|
profileState?.onRemoveNewProfile(deleteProfile);
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
t("profiles.deleteSuccess", {
|
t("profiles.deleteSuccess", {
|
||||||
ns: "views/settings",
|
ns: "views/settings",
|
||||||
profile: deleteProfile,
|
profile: profileFriendlyNames?.get(deleteProfile) ?? deleteProfile,
|
||||||
}),
|
}),
|
||||||
{ position: "top-center" },
|
{ position: "top-center" },
|
||||||
);
|
);
|
||||||
@ -251,6 +282,7 @@ export default function ProfilesView({
|
|||||||
activeProfile,
|
activeProfile,
|
||||||
config,
|
config,
|
||||||
profileState,
|
profileState,
|
||||||
|
profileFriendlyNames,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
updateProfiles,
|
updateProfiles,
|
||||||
t,
|
t,
|
||||||
@ -268,6 +300,40 @@ export default function ProfilesView({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleRename = useCallback(async () => {
|
||||||
|
if (!renameProfile || !renameValue.trim()) return;
|
||||||
|
|
||||||
|
setRenaming(true);
|
||||||
|
try {
|
||||||
|
await axios.put("config/set", {
|
||||||
|
requires_restart: 0,
|
||||||
|
config_data: {
|
||||||
|
profiles: {
|
||||||
|
[renameProfile]: { friendly_name: renameValue.trim() },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateConfig();
|
||||||
|
await updateProfiles();
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
t("profiles.renameSuccess", {
|
||||||
|
ns: "views/settings",
|
||||||
|
profile: renameValue.trim(),
|
||||||
|
}),
|
||||||
|
{ position: "top-center" },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setRenaming(false);
|
||||||
|
setRenameProfile(null);
|
||||||
|
}
|
||||||
|
}, [renameProfile, renameValue, updateConfig, updateProfiles, t]);
|
||||||
|
|
||||||
if (!config || !profilesData) {
|
if (!config || !profilesData) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -336,7 +402,7 @@ export default function ProfilesView({
|
|||||||
color.dot,
|
color.dot,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{profile}
|
{profileFriendlyNames?.get(profile) ?? profile}
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
);
|
);
|
||||||
@ -403,7 +469,23 @@ export default function ProfilesView({
|
|||||||
color.dot,
|
color.dot,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">{profile}</span>
|
<span className="font-medium">
|
||||||
|
{profileFriendlyNames?.get(profile) ?? profile}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-6 text-muted-foreground hover:text-primary"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setRenameProfile(profile);
|
||||||
|
setRenameValue(
|
||||||
|
profileFriendlyNames?.get(profile) ?? profile,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3" />
|
||||||
|
</Button>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@ -484,51 +566,60 @@ export default function ProfilesView({
|
|||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
setAddDialogOpen(open);
|
setAddDialogOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setNewProfileName("");
|
addForm.reset();
|
||||||
setNameError(null);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-[360px]">
|
<DialogContent className="sm:max-w-[400px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{t("profiles.newProfile", { ns: "views/settings" })}
|
{t("profiles.newProfile", { ns: "views/settings" })}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-2 py-2">
|
<FormProvider {...addForm}>
|
||||||
<Input
|
<form
|
||||||
placeholder={t("profiles.profileNamePlaceholder", {
|
onSubmit={addForm.handleSubmit(handleAddSubmit)}
|
||||||
ns: "views/settings",
|
className="space-y-4 py-2"
|
||||||
})}
|
|
||||||
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.add", { ns: "common" })}
|
<NameAndIdFields<AddProfileForm>
|
||||||
</Button>
|
control={addForm.control}
|
||||||
</DialogFooter>
|
type="profile"
|
||||||
|
nameField="friendly_name"
|
||||||
|
idField="name"
|
||||||
|
nameLabel={t("profiles.friendlyNameLabel", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
idLabel={t("profiles.profileIdLabel", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
idDescription={t("profiles.profileIdDescription", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
placeholderName={t("profiles.profileNamePlaceholder", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setAddDialogOpen(false)}
|
||||||
|
>
|
||||||
|
{t("button.cancel", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="select"
|
||||||
|
disabled={
|
||||||
|
!addForm.watch("friendly_name").trim() ||
|
||||||
|
!addForm.watch("name").trim()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("button.add", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
@ -547,7 +638,9 @@ export default function ProfilesView({
|
|||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
{t("profiles.deleteProfileConfirm", {
|
{t("profiles.deleteProfileConfirm", {
|
||||||
ns: "views/settings",
|
ns: "views/settings",
|
||||||
profile: deleteProfile,
|
profile: deleteProfile
|
||||||
|
? (profileFriendlyNames?.get(deleteProfile) ?? deleteProfile)
|
||||||
|
: "",
|
||||||
})}
|
})}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
@ -560,11 +653,54 @@ export default function ProfilesView({
|
|||||||
onClick={handleDeleteProfile}
|
onClick={handleDeleteProfile}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
>
|
>
|
||||||
|
{deleting && <ActivityIndicator className="mr-2 size-4" />}
|
||||||
{t("button.delete", { ns: "common" })}
|
{t("button.delete", { ns: "common" })}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Rename Profile Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!renameProfile}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setRenameProfile(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-[400px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("profiles.renameProfile", { ns: "views/settings" })}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<Input
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
placeholder={t("profiles.profileNamePlaceholder", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setRenameProfile(null)}
|
||||||
|
disabled={renaming}
|
||||||
|
>
|
||||||
|
{t("button.cancel", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="select"
|
||||||
|
onClick={handleRename}
|
||||||
|
disabled={renaming || !renameValue.trim()}
|
||||||
|
>
|
||||||
|
{renaming && <ActivityIndicator className="mr-2 size-4" />}
|
||||||
|
{t("button.save", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,13 +28,15 @@ export type SettingsPageProps = {
|
|||||||
level: "global" | "camera",
|
level: "global" | "camera",
|
||||||
status: SectionStatus,
|
status: SectionStatus,
|
||||||
) => void;
|
) => void;
|
||||||
pendingDataBySection?: Record<string, unknown>;
|
pendingDataBySection?: Record<string, ConfigSectionData>;
|
||||||
onPendingDataChange?: (
|
onPendingDataChange?: (
|
||||||
sectionKey: string,
|
sectionKey: string,
|
||||||
cameraName: string | undefined,
|
cameraName: string | undefined,
|
||||||
data: ConfigSectionData | null,
|
data: ConfigSectionData | null,
|
||||||
) => void;
|
) => void;
|
||||||
profileState?: ProfileState;
|
profileState?: ProfileState;
|
||||||
|
/** Callback to delete the current profile's overrides for the current section */
|
||||||
|
onDeleteProfileSection?: (profileName: string) => void;
|
||||||
profilesUIEnabled?: boolean;
|
profilesUIEnabled?: boolean;
|
||||||
setProfilesUIEnabled?: React.Dispatch<React.SetStateAction<boolean>>;
|
setProfilesUIEnabled?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
};
|
};
|
||||||
@ -70,6 +72,7 @@ export function SingleSectionPage({
|
|||||||
pendingDataBySection,
|
pendingDataBySection,
|
||||||
onPendingDataChange,
|
onPendingDataChange,
|
||||||
profileState,
|
profileState,
|
||||||
|
onDeleteProfileSection,
|
||||||
}: SingleSectionPageProps) {
|
}: SingleSectionPageProps) {
|
||||||
const sectionNamespace =
|
const sectionNamespace =
|
||||||
level === "camera" ? "config/cameras" : "config/global";
|
level === "camera" ? "config/cameras" : "config/global";
|
||||||
@ -104,6 +107,12 @@ export function SingleSectionPage({
|
|||||||
[currentEditingProfile, profileState?.allProfileNames],
|
[currentEditingProfile, profileState?.allProfileNames],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDeleteProfileSection = useCallback(() => {
|
||||||
|
if (currentEditingProfile && onDeleteProfileSection) {
|
||||||
|
onDeleteProfileSection(currentEditingProfile);
|
||||||
|
}
|
||||||
|
}, [currentEditingProfile, onDeleteProfileSection]);
|
||||||
|
|
||||||
const handleSectionStatusChange = useCallback(
|
const handleSectionStatusChange = useCallback(
|
||||||
(status: SectionStatus) => {
|
(status: SectionStatus) => {
|
||||||
setSectionStatus(status);
|
setSectionStatus(status);
|
||||||
@ -179,8 +188,12 @@ export function SingleSectionPage({
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{sectionStatus.overrideSource === "profile"
|
{sectionStatus.overrideSource === "profile"
|
||||||
? t("button.overriddenBaseConfigTooltip", {
|
? t("button.overriddenBaseConfigTooltip", {
|
||||||
ns: "common",
|
ns: "views/settings",
|
||||||
profile: currentEditingProfile,
|
profile: currentEditingProfile
|
||||||
|
? (profileState?.profileFriendlyNames.get(
|
||||||
|
currentEditingProfile,
|
||||||
|
) ?? currentEditingProfile)
|
||||||
|
: "",
|
||||||
})
|
})
|
||||||
: t("button.overriddenGlobalTooltip", {
|
: t("button.overriddenGlobalTooltip", {
|
||||||
ns: "views/settings",
|
ns: "views/settings",
|
||||||
@ -212,7 +225,16 @@ export function SingleSectionPage({
|
|||||||
requiresRestart={requiresRestart}
|
requiresRestart={requiresRestart}
|
||||||
onStatusChange={handleSectionStatusChange}
|
onStatusChange={handleSectionStatusChange}
|
||||||
profileName={currentEditingProfile ?? undefined}
|
profileName={currentEditingProfile ?? undefined}
|
||||||
|
profileFriendlyName={
|
||||||
|
currentEditingProfile
|
||||||
|
? (profileState?.profileFriendlyNames.get(currentEditingProfile) ??
|
||||||
|
currentEditingProfile)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
profileBorderColor={profileColor?.border}
|
profileBorderColor={profileColor?.border}
|
||||||
|
onDeleteProfileSection={
|
||||||
|
currentEditingProfile ? handleDeleteProfileSection : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user