diff --git a/web/public/locales/en/components/dialog.json b/web/public/locales/en/components/dialog.json index 3630d68e09..b73e0ca3f9 100644 --- a/web/public/locales/en/components/dialog.json +++ b/web/public/locales/en/components/dialog.json @@ -70,6 +70,13 @@ "selectFromTimeline": "Select from Timeline", "cameraSelection": "Cameras", "cameraSelectionHelp": "Cameras with tracked objects in this time range are pre-selected", + "searchOrSelectGroup": "Search, or select a camera group...", + "selectAll": "Select all cameras", + "clearSelection": "Clear selection", + "selectWithActivity": "Cameras with tracked objects", + "selectGroup": "Select group", + "noMatchingCameras": "No cameras match your search", + "selectedCount": "{{selected}} / {{total}} selected", "checkingActivity": "Checking camera activity...", "noCameras": "No cameras available", "detectionCount_one": "1 tracked object", diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index 3419f199f9..f4a367dfda 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -39,6 +39,16 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { + Command, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "../ui/command"; +import { IconRenderer } from "../icons/IconPicker"; +import * as LuIcons from "react-icons/lu"; import { isDesktop, isMobile } from "react-device-detect"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import SaveExportOverlay from "./SaveExportOverlay"; @@ -376,6 +386,9 @@ export function ExportContent({ const [newCaseName, setNewCaseName] = useState(""); const [newCaseDescription, setNewCaseDescription] = useState(""); const [isStartingBatchExport, setIsStartingBatchExport] = useState(false); + const [cameraSearch, setCameraSearch] = useState(""); + const [cameraMenuOpen, setCameraMenuOpen] = useState(false); + const cameraMenuRef = useRef(null); const multiRangeKey = useMemo(() => { if (activeTab !== "multi" || !range) { return undefined; @@ -577,6 +590,75 @@ export function ExportContent({ ); }, []); + const availableCameraIds = useMemo( + () => cameraActivities.map((activity) => activity.camera), + [cameraActivities], + ); + + const activeCameraIds = useMemo( + () => + cameraActivities + .filter((activity) => activity.hasDetections) + .map((activity) => activity.camera), + [cameraActivities], + ); + + const cameraGroups = useMemo( + () => + Object.entries(config?.camera_groups ?? {}) + .map(([name, group]) => ({ + name, + icon: group.icon, + order: group.order, + cameras: group.cameras.filter((cameraId) => + availableCameraIds.includes(cameraId), + ), + })) + .filter((group) => group.cameras.length > 0) + .sort((a, b) => a.order - b.order), + [config?.camera_groups, availableCameraIds], + ); + + // Filter the rendered camera cards by the search query + const filteredCameraActivities = useMemo(() => { + const query = cameraSearch.trim().toLowerCase(); + if (!query) { + return cameraActivities; + } + return cameraActivities.filter((activity) => { + const friendlyName = resolveCameraName(config, activity.camera); + return ( + activity.camera.toLowerCase().includes(query) || + friendlyName.toLowerCase().includes(query) + ); + }); + }, [cameraActivities, cameraSearch, config]); + + // Group/all/activity selection replaces the current selection + const applyCameraSelection = useCallback((cameraIds: string[]) => { + setHasManualCameraSelection(true); + setSelectedCameraIds(cameraIds); + setCameraMenuOpen(false); + }, []); + + // Close the dropdown when focus leaves the camera selection control entirely + const handleCameraInputBlur = useCallback((event: React.FocusEvent) => { + if ( + cameraMenuRef.current && + !cameraMenuRef.current.contains(event.relatedTarget as Node) + ) { + setCameraMenuOpen(false); + } + }, []); + + // Reset the search and dropdown when leaving the multi-camera tab + useEffect(() => { + if (activeTab !== "multi") { + setCameraSearch(""); + setCameraMenuOpen(false); + } + }, [activeTab]); + const startBatchExport = useCallback(async () => { if (isStartingBatchExport) { return; @@ -802,7 +884,7 @@ export function ExportContent({ {isAdmin && (
-