import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import useSWR from "swr"; import axios from "axios"; import { toast } from "sonner"; import { Trash2 } from "lucide-react"; import { LuChevronDown, LuChevronRight, LuPlus } from "react-icons/lu"; import type { FrigateConfig } from "@/types/frigateConfig"; import type { ProfileState } 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 { 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 ProfilesApiResponse = { profiles: string[]; active_profile: string | null; }; 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 [expandedProfiles, setExpandedProfiles] = useState>( new Set(), ); const [addDialogOpen, setAddDialogOpen] = useState(false); const [newProfileName, setNewProfileName] = useState(""); const [nameError, setNameError] = useState(null); useEffect(() => { document.title = t("documentTitle.profiles", { ns: "views/settings", }); }, [t]); const allProfileNames = useMemo( () => profileState?.allProfileNames ?? [], [profileState?.allProfileNames], ); 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 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; } profileState?.onAddProfile(name); setAddDialogOpen(false); setNewProfileName(""); setNameError(null); }, [newProfileName, validateName, profileState]); 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, }) : 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, t], ); const handleDeleteProfile = useCallback(async () => { if (!deleteProfile || !config) return; // If this is an unsaved (new) profile, just remove it from local state const isNewProfile = profileState?.newProfiles.includes(deleteProfile); if (isNewProfile) { profileState?.onRemoveNewProfile(deleteProfile); setDeleteProfile(null); 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 via config/set const configData: Record = {}; for (const camera of Object.keys(config.cameras)) { if (config.cameras[camera]?.profiles?.[deleteProfile]) { configData[camera] = { profiles: { [deleteProfile]: "" }, }; } } if (Object.keys(configData).length > 0) { await axios.put("config/set", { requires_restart: 0, config_data: { cameras: configData }, }); } await updateConfig(); await updateProfiles(); toast.success( t("profiles.deleteSuccess", { ns: "views/settings", profile: 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, profileState, 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; }); }, []); 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 ? ( ) : ( )} {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) { setNewProfileName(""); setNameError(null); } }} > {t("profiles.newProfile", { ns: "views/settings" })}
{ setNewProfileName(e.target.value); setNameError(validateName(e.target.value)); }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddSubmit(); } }} autoFocus /> {nameError && (

{nameError}

)}
{/* Delete Profile Confirmation */} { if (!open) setDeleteProfile(null); }} > {t("profiles.deleteProfile", { ns: "views/settings" })} {t("profiles.deleteProfileConfirm", { ns: "views/settings", profile: deleteProfile, })} {t("button.cancel", { ns: "common" })} {t("button.delete", { ns: "common" })}
); }