diff --git a/web/src/components/settings/ProfileSectionDropdown.tsx b/web/src/components/settings/ProfileSectionDropdown.tsx index 5a456d6ab..10ad0d437 100644 --- a/web/src/components/settings/ProfileSectionDropdown.tsx +++ b/web/src/components/settings/ProfileSectionDropdown.tsx @@ -65,13 +65,11 @@ export function ProfileSectionDropdown({ if (!/^[a-z0-9_]+$/.test(name)) { return t("profiles.nameInvalid", { ns: "views/settings", - defaultValue: "Only lowercase letters, numbers, and underscores", }); } if (allProfileNames.includes(name)) { return t("profiles.nameDuplicate", { ns: "views/settings", - defaultValue: "Profile already exists", }); } return null; @@ -132,10 +130,7 @@ export function ProfileSectionDropdown({ {editingProfile} > ) : ( - t("profiles.baseConfig", { - ns: "views/settings", - defaultValue: "Base Config", - }) + t("profiles.baseConfig", { ns: "views/settings" }) )} @@ -147,10 +142,7 @@ export function ProfileSectionDropdown({ )} - {t("profiles.baseConfig", { - ns: "views/settings", - defaultValue: "Base Config", - })} + {t("profiles.baseConfig", { ns: "views/settings" })} @@ -180,10 +172,7 @@ export function ProfileSectionDropdown({ {profile} {!hasData && ( - {t("profiles.noOverrides", { - ns: "views/settings", - defaultValue: "no overrides", - })} + {t("profiles.noOverrides", { ns: "views/settings" })} )} @@ -211,10 +200,7 @@ export function ProfileSectionDropdown({ }} > - {t("profiles.addProfile", { - ns: "views/settings", - defaultValue: "Add Profile...", - })} + {t("profiles.addProfile", { ns: "views/settings" })} @@ -223,17 +209,13 @@ export function ProfileSectionDropdown({ - {t("profiles.newProfile", { - ns: "views/settings", - defaultValue: "New Profile", - })} + {t("profiles.newProfile", { ns: "views/settings" })} { @@ -261,7 +243,7 @@ export function ProfileSectionDropdown({ onClick={handleAddSubmit} disabled={!newProfileName.trim() || !!nameError} > - {t("button.create", { ns: "common", defaultValue: "Create" })} + {t("button.create", { ns: "common" })} @@ -276,16 +258,11 @@ export function ProfileSectionDropdown({ - {t("profiles.deleteSection", { - ns: "views/settings", - defaultValue: "Delete Section Overrides", - })} + {t("profiles.deleteSection", { ns: "views/settings" })} {t("profiles.deleteSectionConfirm", { ns: "views/settings", - defaultValue: - "Remove {{profile}}'s overrides for {{section}} on {{camera}}?", profile: deleteConfirmProfile, section: sectionKey, camera: cameraName, @@ -300,7 +277,7 @@ export function ProfileSectionDropdown({ className="bg-destructive text-white hover:bg-destructive/90" onClick={handleDeleteConfirm} > - {t("button.delete", { ns: "common", defaultValue: "Delete" })} + {t("button.delete", { ns: "common" })} diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx index 1c5168953..46c1632f8 100644 --- a/web/src/views/settings/CameraManagementView.tsx +++ b/web/src/views/settings/CameraManagementView.tsx @@ -26,13 +26,25 @@ import axios from "axios"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator"; +import type { ProfileState } from "@/types/profile"; +import { getProfileColor } from "@/utils/profileColors"; +import { cn } from "@/lib/utils"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; type CameraManagementViewProps = { setUnsavedChanges: React.Dispatch>; + profileState?: ProfileState; }; export default function CameraManagementView({ setUnsavedChanges, + profileState, }: CameraManagementViewProps) { const { t } = useTranslation(["views/settings"]); @@ -200,6 +212,17 @@ export default function CameraManagementView({ )} )} + + {profileState && + profileState.allProfileNames.length > 0 && + enabledCameras.length > 0 && ( + + )} > ) : ( @@ -364,3 +387,202 @@ function CameraConfigEnableSwitch({ ); } + +type ProfileCameraEnableSectionProps = { + profileState: ProfileState; + cameras: string[]; + config: FrigateConfig | undefined; + onConfigChanged: () => Promise; +}; + +function ProfileCameraEnableSection({ + profileState, + cameras, + config, + onConfigChanged, +}: ProfileCameraEnableSectionProps) { + const { t } = useTranslation(["views/settings", "common"]); + const [selectedProfile, setSelectedProfile] = useState( + profileState.allProfileNames[0] ?? "", + ); + const [savingCamera, setSavingCamera] = useState(null); + // Optimistic local state: the parsed config API doesn't reflect profile + // enabled changes until Frigate restarts, so we track saved values locally. + const [localOverrides, setLocalOverrides] = useState< + Record> + >({}); + + const handleEnabledChange = useCallback( + async (camera: string, value: string) => { + setSavingCamera(camera); + try { + const enabledValue = + value === "enabled" ? true : value === "disabled" ? false : null; + const configData = + enabledValue === null + ? { + cameras: { + [camera]: { + profiles: { [selectedProfile]: { enabled: "" } }, + }, + }, + } + : { + cameras: { + [camera]: { + profiles: { [selectedProfile]: { enabled: enabledValue } }, + }, + }, + }; + + await axios.put("config/set", { config_data: configData }); + await onConfigChanged(); + + setLocalOverrides((prev) => ({ + ...prev, + [selectedProfile]: { + ...prev[selectedProfile], + [camera]: value, + }, + })); + + toast.success(t("toast.save.success", { ns: "common" }), { + position: "top-center", + }); + } catch { + toast.error(t("toast.save.error.title", { ns: "common" }), { + position: "top-center", + }); + } finally { + setSavingCamera(null); + } + }, + [selectedProfile, onConfigChanged, t], + ); + + const getEnabledState = useCallback( + (camera: string): string => { + // Check optimistic local state first + const localValue = localOverrides[selectedProfile]?.[camera]; + if (localValue) return localValue; + + const profileData = + config?.cameras?.[camera]?.profiles?.[selectedProfile]; + if (!profileData || profileData.enabled === undefined) return "inherit"; + return profileData.enabled ? "enabled" : "disabled"; + }, + [config, selectedProfile, localOverrides], + ); + + const profileColor = selectedProfile + ? getProfileColor(selectedProfile, profileState.allProfileNames) + : null; + + if (!selectedProfile) return null; + + return ( + + + + + {t("cameraManagement.profiles.selectLabel", { + ns: "views/settings", + })} + + + {t("cameraManagement.profiles.description", { + ns: "views/settings", + })} + + + + + + + {profileColor && ( + + )} + + + + + {profileState.allProfileNames.map((profile) => { + const color = getProfileColor( + profile, + profileState.allProfileNames, + ); + return ( + + + + {profile} + + + ); + })} + + + + + {cameras.map((camera) => { + const state = getEnabledState(camera); + const isSaving = savingCamera === camera; + + return ( + + + {isSaving ? ( + + ) : ( + handleEnabledChange(camera, v)} + > + + + + + + {t("cameraManagement.profiles.inherit", { + ns: "views/settings", + })} + + + {t("cameraManagement.profiles.enabled", { + ns: "views/settings", + })} + + + {t("cameraManagement.profiles.disabled", { + ns: "views/settings", + })} + + + + )} + + ); + })} + + + + + ); +}
+ {t("cameraManagement.profiles.description", { + ns: "views/settings", + })} +