diff --git a/web/src/components/config-form/section-configs/review.ts b/web/src/components/config-form/section-configs/review.ts index ddea33f9d..0d6d6b37e 100644 --- a/web/src/components/config-form/section-configs/review.ts +++ b/web/src/components/config-form/section-configs/review.ts @@ -9,16 +9,22 @@ const review: SectionConfigOverrides = { "enabled_in_config", "alerts.labels", "alerts.enabled_in_config", - "alerts.required_zones", "detections.labels", "detections.enabled_in_config", - "detections.required_zones", "genai.enabled_in_config", ], advancedFields: [], uiSchema: { alerts: { - "ui:before": { render: "CameraReviewSettingsView" }, + "ui:before": { render: "CameraReviewStatusToggles" }, + required_zones: { + "ui:widget": "hidden", + }, + }, + detections: { + required_zones: { + "ui:widget": "hidden", + }, }, genai: { additional_concerns: { diff --git a/web/src/components/config-form/sectionExtras/CameraReviewClassification.tsx b/web/src/components/config-form/sectionExtras/CameraReviewClassification.tsx new file mode 100644 index 000000000..3f2146d88 --- /dev/null +++ b/web/src/components/config-form/sectionExtras/CameraReviewClassification.tsx @@ -0,0 +1,368 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import { Trans, useTranslation } from "react-i18next"; +import cloneDeep from "lodash/cloneDeep"; +import get from "lodash/get"; +import set from "lodash/set"; +import { LuExternalLink } from "react-icons/lu"; +import { MdCircle } from "react-icons/md"; +import Heading from "@/components/ui/heading"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { useDocDomain } from "@/hooks/use-doc-domain"; +import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; +import { resolveZoneName } from "@/hooks/use-zone-friendly-name"; +import { getTranslatedLabel } from "@/utils/i18n"; +import { formatList } from "@/utils/stringUtil"; +import type { ConfigSectionData, JsonObject } from "@/types/configForm"; +import type { SectionRendererProps } from "./registry"; + +const EMPTY_ZONES: string[] = []; + +function getRequiredZones( + formData: JsonObject | undefined, + path: string, +): string[] { + const value = get(formData, path); + return Array.isArray(value) ? (value as string[]) : EMPTY_ZONES; +} + +export default function CameraReviewClassification({ + formContext, + selectedCamera, +}: SectionRendererProps) { + const { t } = useTranslation(["views/settings", "common"]); + const { getLocaleDocUrl } = useDocDomain(); + const cameraName = formContext?.cameraName ?? selectedCamera; + const fullFormData = formContext?.formData as JsonObject | undefined; + const cameraConfig = formContext?.fullCameraConfig; + + const alertsZones = useMemo( + () => getRequiredZones(fullFormData, "alerts.required_zones"), + [fullFormData], + ); + const detectionsZones = useMemo( + () => getRequiredZones(fullFormData, "detections.required_zones"), + [fullFormData], + ); + + const [selectDetections, setSelectDetections] = useState( + detectionsZones.length > 0, + ); + const previousCameraRef = useRef(cameraName); + const isSynced = formContext?.hasChanges === false; + + useEffect(() => { + const cameraChanged = previousCameraRef.current !== cameraName; + if (cameraChanged) { + previousCameraRef.current = cameraName; + } + + if (cameraChanged || isSynced) { + setSelectDetections(detectionsZones.length > 0); + } + }, [cameraName, detectionsZones.length, isSynced]); + + const zones = useMemo(() => { + if (!cameraConfig) { + return undefined; + } + return Object.entries(cameraConfig.zones).map(([name, zoneData]) => { + const zone = + zoneData as (typeof cameraConfig.zones)[keyof typeof cameraConfig.zones]; + return { + camera: cameraConfig.name, + name, + friendly_name: cameraConfig.zones[name].friendly_name, + objects: zone.objects, + color: zone.color, + }; + }); + }, [cameraConfig]); + + const alertsLabels = useMemo(() => { + return cameraConfig?.review.alerts.labels + ? formatList( + cameraConfig.review.alerts.labels.map((label: string) => + getTranslatedLabel( + label, + cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object", + ), + ), + ) + : ""; + }, [cameraConfig]); + + const detectionsLabels = useMemo(() => { + return cameraConfig?.review.detections.labels + ? formatList( + cameraConfig.review.detections.labels.map((label: string) => + getTranslatedLabel( + label, + cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object", + ), + ), + ) + : ""; + }, [cameraConfig]); + + const selectCameraName = useCameraFriendlyName(cameraName); + + const getZoneName = useCallback( + (zoneId: string, camId?: string) => + resolveZoneName(formContext?.fullConfig, zoneId, camId), + [formContext?.fullConfig], + ); + + const updateFormData = useCallback( + (path: string, nextValue: string[]) => { + if (!formContext?.onFormDataChange || !fullFormData) { + return; + } + const nextData = cloneDeep(fullFormData) as JsonObject; + set(nextData, path, nextValue); + formContext.onFormDataChange(nextData as ConfigSectionData); + }, + [formContext, fullFormData], + ); + + const handleZoneToggle = useCallback( + (path: string, zoneName: string) => { + const currentZones = getRequiredZones(fullFormData, path); + const nextZones = currentZones.includes(zoneName) + ? currentZones.filter((value) => value !== zoneName) + : [...currentZones, zoneName]; + updateFormData(path, nextZones); + }, + [fullFormData, updateFormData], + ); + + const handleDetectionsToggle = useCallback( + (checked: boolean | string) => { + const isChecked = checked === true; + if (!isChecked) { + updateFormData("detections.required_zones", []); + } + setSelectDetections(isChecked); + }, + [updateFormData], + ); + + if (!cameraName || formContext?.level !== "camera") { + return null; + } + + return ( +
+ + + cameraReview.reviewClassification.title + + + +
+
+

+ + cameraReview.reviewClassification.desc + +

+
+ + {t("readTheDocumentation", { ns: "common" })} + + +
+
+
+ +
0 && "grid items-start gap-5 md:grid-cols-2", + )} + > +
+ {zones && zones.length > 0 ? ( + <> +
+ +
+ + cameraReview.reviewClassification.selectAlertsZones + +
+
+
+ {zones.map((zone) => ( +
+ + handleZoneToggle("alerts.required_zones", zone.name) + } + /> + +
+ ))} +
+ + ) : ( +
+ + cameraReview.reviewClassification.noDefinedZones + +
+ )} + +
+ {alertsZones.length > 0 + ? t("cameraReview.reviewClassification.zoneObjectAlertsTips", { + alertsLabels, + zone: formatList( + alertsZones.map((zone) => getZoneName(zone, cameraName)), + ), + cameraName: selectCameraName, + }) + : t("cameraReview.reviewClassification.objectAlertsTips", { + alertsLabels, + cameraName: selectCameraName, + })} +
+
+ +
+ {zones && zones.length > 0 && ( + <> +
+ + {selectDetections && ( +
+ + cameraReview.reviewClassification.selectDetectionsZones + +
+ )} +
+ + {selectDetections && ( +
+ {zones.map((zone) => ( +
+ + handleZoneToggle( + "detections.required_zones", + zone.name, + ) + } + /> + +
+ ))} +
+ )} + +
+ +
+ +
+
+ + )} + +
+ {detectionsZones.length > 0 ? ( + !selectDetections ? ( + + getZoneName(zone, cameraName), + ), + ), + cameraName: selectCameraName, + }} + ns="views/settings" + /> + ) : ( + + getZoneName(zone, cameraName), + ), + ), + cameraName: selectCameraName, + }} + ns="views/settings" + /> + ) + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/web/src/components/config-form/sectionExtras/CameraReviewStatusToggles.tsx b/web/src/components/config-form/sectionExtras/CameraReviewStatusToggles.tsx new file mode 100644 index 000000000..a6d02253b --- /dev/null +++ b/web/src/components/config-form/sectionExtras/CameraReviewStatusToggles.tsx @@ -0,0 +1,164 @@ +import { useMemo } from "react"; +import useSWR from "swr"; +import { Trans } from "react-i18next"; +import Heading from "@/components/ui/heading"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { + useAlertsState, + useDetectionsState, + useObjectDescriptionState, + useReviewDescriptionState, +} from "@/api/ws"; +import type { SectionRendererProps } from "./registry"; +import CameraReviewClassification from "./CameraReviewClassification"; + +export default function CameraReviewStatusToggles({ + selectedCamera, + formContext, +}: SectionRendererProps) { + const { data: config } = useSWR("config"); + const cameraId = selectedCamera ?? ""; + + const cameraConfig = useMemo(() => { + if (config && selectedCamera) { + return config.cameras[selectedCamera]; + } + }, [config, selectedCamera]); + + const { payload: alertsState, send: sendAlerts } = useAlertsState(cameraId); + const { payload: detectionsState, send: sendDetections } = + useDetectionsState(cameraId); + + const { payload: objDescState, send: sendObjDesc } = + useObjectDescriptionState(cameraId); + const { payload: revDescState, send: sendRevDesc } = + useReviewDescriptionState(cameraId); + + if (!selectedCamera || !cameraConfig) { + return null; + } + + return ( +
+ + cameraReview.title + + +
+
+ { + sendAlerts(isChecked ? "ON" : "OFF"); + }} + /> +
+ +
+
+
+
+ { + sendDetections(isChecked ? "ON" : "OFF"); + }} + /> +
+ +
+
+
+ cameraReview.review.desc +
+
+
+ + {cameraConfig?.objects?.genai?.enabled_in_config && ( + <> + + + + + cameraReview.object_descriptions.title + + + +
+
+ { + sendObjDesc(isChecked ? "ON" : "OFF"); + }} + /> +
+ +
+
+
+ + cameraReview.object_descriptions.desc + +
+
+ + )} + + {cameraConfig?.review?.genai?.enabled_in_config && ( + <> + + + + + cameraReview.review_descriptions.title + + + +
+
+ { + sendRevDesc(isChecked ? "ON" : "OFF"); + }} + /> +
+ +
+
+
+ + cameraReview.review_descriptions.desc + +
+
+ + )} + + +
+ ); +} diff --git a/web/src/components/config-form/sectionExtras/registry.ts b/web/src/components/config-form/sectionExtras/registry.ts index 908577019..6057cd066 100644 --- a/web/src/components/config-form/sectionExtras/registry.ts +++ b/web/src/components/config-form/sectionExtras/registry.ts @@ -1,12 +1,13 @@ -import { createElement } from "react"; import type { ComponentType } from "react"; import SemanticSearchReindex from "./SemanticSearchReindex.tsx"; -import CameraReviewSettingsView from "@/views/settings/CameraReviewSettingsView.tsx"; +import CameraReviewStatusToggles from "./CameraReviewStatusToggles"; +import type { ConfigFormContext } from "@/types/configForm"; // Props that will be injected into all section renderers export type SectionRendererProps = { selectedCamera?: string; setUnsavedChanges?: (hasChanges: boolean) => void; + formContext?: ConfigFormContext; [key: string]: unknown; // Allow additional props from uiSchema }; @@ -17,20 +18,6 @@ export type SectionRenderers = Record< Record >; -const CameraReviewSettingsRenderer: RendererComponent = ({ - selectedCamera, - setUnsavedChanges, -}) => { - if (!selectedCamera) { - return null; - } - - return createElement(CameraReviewSettingsView, { - selectedCamera, - setUnsavedChanges, - }); -}; - // Section renderers registry // Used to register custom renderer components for specific config sections. // Maps a section key (e.g., `semantic_search`) to a mapping of renderer @@ -55,7 +42,7 @@ export const sectionRenderers: SectionRenderers = { SemanticSearchReindex, }, review: { - CameraReviewSettingsView: CameraReviewSettingsRenderer, + CameraReviewStatusToggles, }, }; diff --git a/web/src/components/config-form/theme/templates/FieldTemplate.tsx b/web/src/components/config-form/theme/templates/FieldTemplate.tsx index fc77b4d97..c651a2cda 100644 --- a/web/src/components/config-form/theme/templates/FieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/FieldTemplate.tsx @@ -324,7 +324,9 @@ export function FieldTemplate(props: FieldTemplateProps) { const renderers = formContext?.renderers; const RenderComponent = renderers?.[renderKey]; if (RenderComponent) { - return ; + return ( + + ); } } diff --git a/web/src/types/configForm.ts b/web/src/types/configForm.ts index ca4ea25d4..02e718060 100644 --- a/web/src/types/configForm.ts +++ b/web/src/types/configForm.ts @@ -18,6 +18,9 @@ export type ConfigFormContext = { cameraName?: string; globalValue?: JsonValue; cameraValue?: JsonValue; + hasChanges?: boolean; + formData?: JsonObject; + onFormDataChange?: (data: ConfigSectionData) => void; fullCameraConfig?: CameraConfig; fullConfig?: FrigateConfig; i18nNamespace?: string;