import { useCallback, useEffect, useMemo, useState } from "react"; 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 axios from "axios"; import { toast } from "sonner"; import { Pencil, Trash2 } from "lucide-react"; import { LuChevronDown, LuChevronRight, LuPlus } from "react-icons/lu"; import type { FrigateConfig } from "@/types/frigateConfig"; import type { JsonObject } from "@/types/configForm"; import type { ProfileState, ProfilesApiResponse } from "@/types/profile"; import { getProfileColor } from "@/utils/profileColors"; import { PROFILE_ELIGIBLE_SECTIONS } from "@/utils/configUtil"; import { resolveCameraName } from "@/hooks/use-camera-friendly-name"; import { cn } from "@/lib/utils"; import Heading from "@/components/ui/heading"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import NameAndIdFields from "@/components/input/NameAndIdFields"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; 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 { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import ActivityIndicator from "@/components/indicators/activity-indicator"; type ProfilesViewProps = { setUnsavedChanges?: React.Dispatch>; profileState?: ProfileState; profilesUIEnabled?: boolean; setProfilesUIEnabled?: React.Dispatch>; }; export default function ProfilesView({ profileState, profilesUIEnabled, setProfilesUIEnabled, }: ProfilesViewProps) { const { t } = useTranslation(["views/settings", "common"]); const { data: config, mutate: updateConfig } = useSWR("config"); const { data: profilesData, mutate: updateProfiles } = useSWR("profiles"); const [activating, setActivating] = useState(false); const [deleteProfile, setDeleteProfile] = useState(null); const [deleting, setDeleting] = useState(false); const [renameProfile, setRenameProfile] = useState(null); const [renameValue, setRenameValue] = useState(""); const [renaming, setRenaming] = useState(false); const [expandedProfiles, setExpandedProfiles] = useState>( new Set(), ); const [addDialogOpen, setAddDialogOpen] = useState(false); 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; const addForm = useForm({ resolver: zodResolver(addProfileSchema), defaultValues: { friendly_name: "", name: "" }, }); const profileFriendlyNames = profileState?.profileFriendlyNames; useEffect(() => { document.title = t("documentTitle.profiles", { ns: "views/settings", }); }, [t]); const activeProfile = profilesData?.active_profile ?? null; // Build overview data: for each profile, which cameras have which sections const profileOverviewData = useMemo(() => { if (!config || allProfileNames.length === 0) return {}; const data: Record> = {}; const cameras = Object.keys(config.cameras).sort(); for (const profile of allProfileNames) { data[profile] = {}; for (const camera of cameras) { const profileData = config.cameras[camera]?.profiles?.[profile]; if (!profileData) continue; const sections: string[] = []; for (const section of PROFILE_ELIGIBLE_SECTIONS) { if ( profileData[section as keyof typeof profileData] !== undefined && profileData[section as keyof typeof profileData] !== null ) { sections.push(section); } } if (profileData.enabled !== undefined && profileData.enabled !== null) { sections.push("enabled"); } if (sections.length > 0) { data[profile][camera] = sections; } } } return data; }, [config, allProfileNames]); const [addingProfile, setAddingProfile] = useState(false); const handleAddSubmit = useCallback( async (data: AddProfileForm) => { const id = data.name.trim(); const friendlyName = data.friendly_name.trim(); if (!id || !friendlyName) return; setAddingProfile(true); try { await axios.put("config/set", { requires_restart: 0, config_data: { profiles: { [id]: { friendly_name: friendlyName } }, }, }); await updateConfig(); await updateProfiles(); toast.success( t("profiles.createSuccess", { ns: "views/settings", profile: friendlyName, }), { position: "top-center" }, ); setAddDialogOpen(false); addForm.reset(); } catch { toast.error(t("toast.save.error.noMessage", { ns: "common" }), { position: "top-center", }); } finally { setAddingProfile(false); } }, [updateConfig, updateProfiles, addForm, t], ); const handleActivateProfile = useCallback( async (profile: string | null) => { setActivating(true); try { await axios.put("profile/set", { profile: profile || null, }); await updateProfiles(); toast.success( profile ? t("profiles.activated", { ns: "views/settings", profile: profileFriendlyNames?.get(profile) ?? profile, }) : t("profiles.deactivated", { ns: "views/settings" }), { position: "top-center" }, ); } catch (err) { const message = axios.isAxiosError(err) && err.response?.data?.message ? String(err.response.data.message) : undefined; toast.error( message || t("profiles.activateFailed", { ns: "views/settings" }), { position: "top-center" }, ); } finally { setActivating(false); } }, [updateProfiles, profileFriendlyNames, t], ); const handleDeleteProfile = useCallback(async () => { if (!deleteProfile || !config) return; setDeleting(true); try { // If this profile is active, deactivate it first if (activeProfile === deleteProfile) { await axios.put("profile/set", { profile: null }); } // Remove the profile from all cameras and the top-level definition const cameraData: JsonObject = {}; for (const camera of Object.keys(config.cameras)) { if (config.cameras[camera]?.profiles?.[deleteProfile]) { cameraData[camera] = { profiles: { [deleteProfile]: "" }, }; } } const configData: JsonObject = { profiles: { [deleteProfile]: "" }, }; if (Object.keys(cameraData).length > 0) { configData.cameras = cameraData; } await axios.put("config/set", { requires_restart: 0, config_data: configData, }); await updateConfig(); await updateProfiles(); toast.success( t("profiles.deleteSuccess", { ns: "views/settings", profile: profileFriendlyNames?.get(deleteProfile) ?? deleteProfile, }), { position: "top-center" }, ); } catch (err) { const errorMessage = axios.isAxiosError(err) && err.response?.data?.message ? String(err.response.data.message) : undefined; toast.error( errorMessage || t("toast.save.error.noMessage", { ns: "common" }), { position: "top-center" }, ); } finally { setDeleting(false); setDeleteProfile(null); } }, [ deleteProfile, activeProfile, config, profileFriendlyNames, updateConfig, updateProfiles, t, ]); const toggleExpanded = useCallback((profile: string) => { setExpandedProfiles((prev) => { const next = new Set(prev); if (next.has(profile)) { next.delete(profile); } else { next.add(profile); } return next; }); }, []); 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) { return null; } const hasProfiles = allProfileNames.length > 0; return (
{t("profiles.title", { ns: "views/settings" })}
{t("profiles.disabledDescription", { ns: "views/settings" })}
{/* Enable Profiles Toggle — shown only when no profiles exist */} {!hasProfiles && setProfilesUIEnabled && (
)} {profilesUIEnabled && !hasProfiles && (

{t("profiles.enabledDescription", { ns: "views/settings" })}

)} {/* Active Profile + Add Profile bar */} {(hasProfiles || profilesUIEnabled) && (
{hasProfiles && (
{t("profiles.activeProfile", { ns: "views/settings" })} {activating && }
)}
)} {/* Profile List */} {!hasProfiles ? ( profilesUIEnabled ? (

{t("profiles.noProfiles", { ns: "views/settings" })}

) : (
) ) : (
{allProfileNames.map((profile) => { const color = getProfileColor(profile, allProfileNames); const isActive = activeProfile === profile; const cameraData = profileOverviewData[profile] ?? {}; const cameras = Object.keys(cameraData).sort(); const isExpanded = expandedProfiles.has(profile); return ( toggleExpanded(profile)} >
{isExpanded ? ( ) : ( )} {profileFriendlyNames?.get(profile) ?? profile} {isActive && ( {t("profiles.active", { ns: "views/settings" })} )}
{cameras.length > 0 ? t("profiles.cameraCount", { ns: "views/settings", count: cameras.length, }) : t("profiles.noOverrides", { ns: "views/settings", })}
{cameras.length > 0 ? (
{cameras.map((camera) => { const sections = cameraData[camera]; return (
{resolveCameraName(config, camera)} {sections .map((section) => t(`configForm.sections.${section}`, { ns: "views/settings", defaultValue: section, }), ) .join(", ")}
); })}
) : (
{t("profiles.noOverrides", { ns: "views/settings" })}
)}
); })}
)} {/* Add Profile Dialog */} { setAddDialogOpen(open); if (!open) { addForm.reset(); } }} > {t("profiles.newProfile", { ns: "views/settings" })}
control={addForm.control} 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", })} />
{/* Delete Profile Confirmation */} { if (!open) setDeleteProfile(null); }} > {t("profiles.deleteProfile", { ns: "views/settings" })} {t("profiles.deleteProfileConfirm", { ns: "views/settings", profile: deleteProfile ? (profileFriendlyNames?.get(deleteProfile) ?? deleteProfile) : "", })} {t("button.cancel", { ns: "common" })} { e.preventDefault(); handleDeleteProfile(); }} disabled={deleting} > {deleting && } {t("button.delete", { ns: "common" })} {/* Rename Profile Dialog */} { if (!open) setRenameProfile(null); }} > {t("profiles.renameProfile", { ns: "views/settings" })}
setRenameValue(e.target.value)} placeholder={t("profiles.profileNamePlaceholder", { ns: "views/settings", })} />
); }