From 5f42caad03bd17f462836b721c7db7f7241d3b08 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 2 Dec 2024 12:12:55 -0600 Subject: [PATCH] Explore bulk actions (#15307) * use id instead of index for object details and scrolling * long press package and hook * fix long press in review * search action group * multi select in explore * add bulk deletion to backend api * clean up * mimic behavior of review * don't open dialog on left click when mutli selecting * context menu on container ref * revert long press code * clean up --- frigate/api/defs/events_body.py | 6 +- frigate/api/event.py | 59 +++-- web/package-lock.json | 10 + web/package.json | 1 + web/src/components/card/SearchThumbnail.tsx | 18 +- .../components/filter/SearchActionGroup.tsx | 132 ++++++++++ web/src/hooks/use-press.ts | 54 ++++ web/src/views/explore/ExploreView.tsx | 10 +- web/src/views/search/SearchView.tsx | 235 ++++++++++++++---- 9 files changed, 447 insertions(+), 78 deletions(-) create mode 100644 web/src/components/filter/SearchActionGroup.tsx create mode 100644 web/src/hooks/use-press.ts diff --git a/frigate/api/defs/events_body.py b/frigate/api/defs/events_body.py index db2b4060ba..1c8576f029 100644 --- a/frigate/api/defs/events_body.py +++ b/frigate/api/defs/events_body.py @@ -1,4 +1,4 @@ -from typing import Optional, Union +from typing import List, Optional, Union from pydantic import BaseModel, Field @@ -27,5 +27,9 @@ class EventsEndBody(BaseModel): end_time: Optional[float] = None +class EventsDeleteBody(BaseModel): + event_ids: List[str] = Field(title="The event IDs to delete") + + class SubmitPlusBody(BaseModel): include_annotation: int = Field(default=1) diff --git a/frigate/api/event.py b/frigate/api/event.py index 3b38ff0720..fafa28272c 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -16,6 +16,7 @@ from frigate.api.defs.events_body import ( EventsCreateBody, + EventsDeleteBody, EventsDescriptionBody, EventsEndBody, EventsSubLabelBody, @@ -1036,34 +1037,64 @@ def regenerate_description( ) -@router.delete("/events/{event_id}") -def delete_event(request: Request, event_id: str): +def delete_single_event(event_id: str, request: Request) -> dict: try: event = Event.get(Event.id == event_id) except DoesNotExist: - return JSONResponse( - content=({"success": False, "message": "Event " + event_id + " not found"}), - status_code=404, - ) + return {"success": False, "message": f"Event {event_id} not found"} media_name = f"{event.camera}-{event.id}" if event.has_snapshot: - media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg") - media.unlink(missing_ok=True) - media = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png") - media.unlink(missing_ok=True) + snapshot_paths = [ + Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg"), + Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png"), + ] + for media in snapshot_paths: + media.unlink(missing_ok=True) event.delete_instance() Timeline.delete().where(Timeline.source_id == event_id).execute() + # If semantic search is enabled, update the index if request.app.frigate_config.semantic_search.enabled: context: EmbeddingsContext = request.app.embeddings context.db.delete_embeddings_thumbnail(event_ids=[event_id]) context.db.delete_embeddings_description(event_ids=[event_id]) - return JSONResponse( - content=({"success": True, "message": "Event " + event_id + " deleted"}), - status_code=200, - ) + + return {"success": True, "message": f"Event {event_id} deleted"} + + +@router.delete("/events/{event_id}") +def delete_event(request: Request, event_id: str): + result = delete_single_event(event_id, request) + status_code = 200 if result["success"] else 404 + return JSONResponse(content=result, status_code=status_code) + + +@router.delete("/events/") +def delete_events(request: Request, body: EventsDeleteBody): + if not body.event_ids: + return JSONResponse( + content=({"success": False, "message": "No event IDs provided."}), + status_code=404, + ) + + deleted_events = [] + not_found_events = [] + + for event_id in body.event_ids: + result = delete_single_event(event_id, request) + if result["success"]: + deleted_events.append(event_id) + else: + not_found_events.append(event_id) + + response = { + "success": True, + "deleted_events": deleted_events, + "not_found_events": not_found_events, + } + return JSONResponse(content=response, status_code=200) @router.post("/events/{camera_name}/{label}/create") diff --git a/web/package-lock.json b/web/package-lock.json index a0971c3616..7ce6345af2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -72,6 +72,7 @@ "tailwind-merge": "^2.4.0", "tailwind-scrollbar": "^3.1.0", "tailwindcss-animate": "^1.0.7", + "use-long-press": "^3.2.0", "vaul": "^0.9.1", "vite-plugin-monaco-editor": "^1.1.0", "zod": "^3.23.8" @@ -8709,6 +8710,15 @@ "scheduler": ">=0.19.0" } }, + "node_modules/use-long-press": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-3.2.0.tgz", + "integrity": "sha512-uq5o2qFR1VRjHn8Of7Fl344/AGvgk7C5Mcb4aSb1ZRVp6PkgdXJJLdRrlSTJQVkkQcDuqFbFc3mDX4COg7mRTA==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/use-sidecar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", diff --git a/web/package.json b/web/package.json index 73b2ed3091..d76e6ad100 100644 --- a/web/package.json +++ b/web/package.json @@ -78,6 +78,7 @@ "tailwind-merge": "^2.4.0", "tailwind-scrollbar": "^3.1.0", "tailwindcss-animate": "^1.0.7", + "use-long-press": "^3.2.0", "vaul": "^0.9.1", "vite-plugin-monaco-editor": "^1.1.0", "zod": "^3.23.8" diff --git a/web/src/components/card/SearchThumbnail.tsx b/web/src/components/card/SearchThumbnail.tsx index e966324008..7dfa7b5833 100644 --- a/web/src/components/card/SearchThumbnail.tsx +++ b/web/src/components/card/SearchThumbnail.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from "react"; +import { useMemo } from "react"; import { useApiHost } from "@/api"; import { getIconForLabel } from "@/utils/iconUtil"; import useSWR from "swr"; @@ -12,10 +12,11 @@ import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { SearchResult } from "@/types/search"; import { cn } from "@/lib/utils"; import { TooltipPortal } from "@radix-ui/react-tooltip"; +import useContextMenu from "@/hooks/use-contextmenu"; type SearchThumbnailProps = { searchResult: SearchResult; - onClick: (searchResult: SearchResult) => void; + onClick: (searchResult: SearchResult, ctrl: boolean, detail: boolean) => void; }; export default function SearchThumbnail({ @@ -28,9 +29,9 @@ export default function SearchThumbnail({ // interactions - const handleOnClick = useCallback(() => { - onClick(searchResult); - }, [searchResult, onClick]); + useContextMenu(imgRef, () => { + onClick(searchResult, true, false); + }); const objectLabel = useMemo(() => { if ( @@ -45,7 +46,10 @@ export default function SearchThumbnail({ }, [config, searchResult]); return ( -
+
onClick(searchResult, false, true)} + > onClick(searchResult)} + onClick={() => onClick(searchResult, false, true)} > {getIconForLabel(objectLabel, "size-3 text-white")} {Math.round( diff --git a/web/src/components/filter/SearchActionGroup.tsx b/web/src/components/filter/SearchActionGroup.tsx new file mode 100644 index 0000000000..aac03ad1c3 --- /dev/null +++ b/web/src/components/filter/SearchActionGroup.tsx @@ -0,0 +1,132 @@ +import { useCallback, useState } from "react"; +import axios from "axios"; +import { Button, buttonVariants } from "../ui/button"; +import { isDesktop } from "react-device-detect"; +import { HiTrash } from "react-icons/hi"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import useKeyboardListener from "@/hooks/use-keyboard-listener"; +import { toast } from "sonner"; + +type SearchActionGroupProps = { + selectedObjects: string[]; + setSelectedObjects: (ids: string[]) => void; + pullLatestData: () => void; +}; +export default function SearchActionGroup({ + selectedObjects, + setSelectedObjects, + pullLatestData, +}: SearchActionGroupProps) { + const onClearSelected = useCallback(() => { + setSelectedObjects([]); + }, [setSelectedObjects]); + + const onDelete = useCallback(async () => { + await axios + .delete(`events/`, { + data: { event_ids: selectedObjects }, + }) + .then((resp) => { + if (resp.status == 200) { + toast.success("Tracked objects deleted successfully.", { + position: "top-center", + }); + setSelectedObjects([]); + pullLatestData(); + } + }) + .catch(() => { + toast.error("Failed to delete tracked objects.", { + position: "top-center", + }); + }); + }, [selectedObjects, setSelectedObjects, pullLatestData]); + + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [bypassDialog, setBypassDialog] = useState(false); + + useKeyboardListener(["Shift"], (_, modifiers) => { + setBypassDialog(modifiers.shift); + }); + + const handleDelete = useCallback(() => { + if (bypassDialog) { + onDelete(); + } else { + setDeleteDialogOpen(true); + } + }, [bypassDialog, onDelete]); + + return ( + <> + setDeleteDialogOpen(!deleteDialogOpen)} + > + + + Confirm Delete + + + Deleting these {selectedObjects.length} tracked objects removes the + snapshot, any saved embeddings, and any associated object lifecycle + entries. Recorded footage of these tracked objects in History view + will NOT be deleted. +
+
+ Are you sure you want to proceed? +
+
+ Hold the Shift key to bypass this dialog in the future. +
+ + Cancel + + Delete + + +
+
+ +
+
+
{`${selectedObjects.length} selected`}
+
{"|"}
+
+ Unselect +
+
+
+ +
+
+ + ); +} diff --git a/web/src/hooks/use-press.ts b/web/src/hooks/use-press.ts new file mode 100644 index 0000000000..6e97ce11b1 --- /dev/null +++ b/web/src/hooks/use-press.ts @@ -0,0 +1,54 @@ +// https://gist.github.com/cpojer/641bf305e6185006ea453e7631b80f95 + +import { useCallback, useState } from "react"; +import { + LongPressCallbackMeta, + LongPressReactEvents, + useLongPress, +} from "use-long-press"; + +export default function usePress( + options: Omit[1], "onCancel" | "onStart"> & { + onLongPress: NonNullable[0]>; + onPress: (event: LongPressReactEvents) => void; + }, +) { + const { onLongPress, onPress, ...actualOptions } = options; + const [hasLongPress, setHasLongPress] = useState(false); + + const onCancel = useCallback(() => { + if (hasLongPress) { + setHasLongPress(false); + } + }, [hasLongPress]); + + const bind = useLongPress( + useCallback( + ( + event: LongPressReactEvents, + meta: LongPressCallbackMeta, + ) => { + setHasLongPress(true); + onLongPress(event, meta); + }, + [onLongPress], + ), + { + ...actualOptions, + onCancel, + onStart: onCancel, + }, + ); + + return useCallback( + () => ({ + ...bind(), + onClick: (event: LongPressReactEvents) => { + if (!hasLongPress) { + onPress(event); + } + }, + }), + [bind, hasLongPress, onPress], + ); +} diff --git a/web/src/views/explore/ExploreView.tsx b/web/src/views/explore/ExploreView.tsx index ea9c3cbeff..0ec8254167 100644 --- a/web/src/views/explore/ExploreView.tsx +++ b/web/src/views/explore/ExploreView.tsx @@ -26,7 +26,7 @@ type ExploreViewProps = { searchDetail: SearchResult | undefined; setSearchDetail: (search: SearchResult | undefined) => void; setSimilaritySearch: (search: SearchResult) => void; - onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void; + onSelectSearch: (item: SearchResult, ctrl: boolean, page?: SearchTab) => void; }; export default function ExploreView({ @@ -125,7 +125,7 @@ type ThumbnailRowType = { setSearchDetail: (search: SearchResult | undefined) => void; mutate: () => void; setSimilaritySearch: (search: SearchResult) => void; - onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void; + onSelectSearch: (item: SearchResult, ctrl: boolean, page?: SearchTab) => void; }; function ThumbnailRow({ @@ -205,7 +205,7 @@ type ExploreThumbnailImageProps = { setSearchDetail: (search: SearchResult | undefined) => void; mutate: () => void; setSimilaritySearch: (search: SearchResult) => void; - onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void; + onSelectSearch: (item: SearchResult, ctrl: boolean, page?: SearchTab) => void; }; function ExploreThumbnailImage({ event, @@ -225,11 +225,11 @@ function ExploreThumbnailImage({ }; const handleShowObjectLifecycle = () => { - onSelectSearch(event, 0, "object lifecycle"); + onSelectSearch(event, false, "object lifecycle"); }; const handleShowSnapshot = () => { - onSelectSearch(event, 0, "snapshot"); + onSelectSearch(event, false, "snapshot"); }; return ( diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index 378b313e03..be430f1347 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -30,6 +30,7 @@ import { } from "@/components/ui/tooltip"; import Chip from "@/components/indicators/Chip"; import { TooltipPortal } from "@radix-ui/react-tooltip"; +import SearchActionGroup from "@/components/filter/SearchActionGroup"; type SearchViewProps = { search: string; @@ -181,20 +182,53 @@ export default function SearchView({ // search interaction - const [selectedIndex, setSelectedIndex] = useState(null); + const [selectedObjects, setSelectedObjects] = useState([]); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const onSelectSearch = useCallback( - (item: SearchResult, index: number, page: SearchTab = "details") => { - setPage(page); - setSearchDetail(item); - setSelectedIndex(index); + (item: SearchResult, ctrl: boolean, page: SearchTab = "details") => { + if (selectedObjects.length > 1 || ctrl) { + const index = selectedObjects.indexOf(item.id); + + if (index != -1) { + if (selectedObjects.length == 1) { + setSelectedObjects([]); + } else { + const copy = [ + ...selectedObjects.slice(0, index), + ...selectedObjects.slice(index + 1), + ]; + setSelectedObjects(copy); + } + } else { + const copy = [...selectedObjects]; + copy.push(item.id); + setSelectedObjects(copy); + } + } else { + setPage(page); + setSearchDetail(item); + } }, - [], + [selectedObjects], ); + const onSelectAllObjects = useCallback(() => { + if (!uniqueResults || uniqueResults.length == 0) { + return; + } + + if (selectedObjects.length < uniqueResults.length) { + setSelectedObjects(uniqueResults.map((value) => value.id)); + } else { + setSelectedObjects([]); + } + }, [uniqueResults, selectedObjects]); + useEffect(() => { - setSelectedIndex(0); + setSelectedObjects([]); + // unselect items when search term or filter changes + // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchTerm, searchFilter]); // confidence score @@ -243,23 +277,44 @@ export default function SearchView({ } switch (key) { + case "a": + if (modifiers.ctrl) { + onSelectAllObjects(); + } + break; case "ArrowLeft": - setSelectedIndex((prevIndex) => { + if (uniqueResults.length > 0) { + const currentIndex = searchDetail + ? uniqueResults.findIndex( + (result) => result.id === searchDetail.id, + ) + : -1; + const newIndex = - prevIndex === null + currentIndex === -1 ? uniqueResults.length - 1 - : (prevIndex - 1 + uniqueResults.length) % uniqueResults.length; + : (currentIndex - 1 + uniqueResults.length) % + uniqueResults.length; + setSearchDetail(uniqueResults[newIndex]); - return newIndex; - }); + } break; + case "ArrowRight": - setSelectedIndex((prevIndex) => { + if (uniqueResults.length > 0) { + const currentIndex = searchDetail + ? uniqueResults.findIndex( + (result) => result.id === searchDetail.id, + ) + : -1; + const newIndex = - prevIndex === null ? 0 : (prevIndex + 1) % uniqueResults.length; + currentIndex === -1 + ? 0 + : (currentIndex + 1) % uniqueResults.length; + setSearchDetail(uniqueResults[newIndex]); - return newIndex; - }); + } break; case "PageDown": contentRef.current?.scrollBy({ @@ -275,32 +330,80 @@ export default function SearchView({ break; } }, - [uniqueResults, inputFocused], + [uniqueResults, inputFocused, onSelectAllObjects, searchDetail], ); useKeyboardListener( - ["ArrowLeft", "ArrowRight", "PageDown", "PageUp"], + ["a", "ArrowLeft", "ArrowRight", "PageDown", "PageUp"], onKeyboardShortcut, !inputFocused, ); // scroll into view + const [prevSearchDetail, setPrevSearchDetail] = useState< + SearchResult | undefined + >(); + + // keep track of previous ref to outline thumbnail when dialog closes + const prevSearchDetailRef = useRef(); + useEffect(() => { - if ( - selectedIndex !== null && - uniqueResults && - itemRefs.current?.[selectedIndex] - ) { - scrollIntoView(itemRefs.current[selectedIndex], { - block: "center", - behavior: "smooth", - scrollMode: "if-needed", - }); + if (searchDetail === undefined && prevSearchDetailRef.current) { + setPrevSearchDetail(prevSearchDetailRef.current); } - // we only want to scroll when the index changes + prevSearchDetailRef.current = searchDetail; + }, [searchDetail]); + + useEffect(() => { + if (uniqueResults && itemRefs.current && prevSearchDetail) { + const selectedIndex = uniqueResults.findIndex( + (result) => result.id === prevSearchDetail.id, + ); + + const parent = itemRefs.current[selectedIndex]; + + if (selectedIndex !== -1 && parent) { + const target = parent.querySelector(".review-item-ring"); + if (target) { + scrollIntoView(target, { + block: "center", + behavior: "smooth", + scrollMode: "if-needed", + }); + target.classList.add(`outline-selected`); + target.classList.remove("outline-transparent"); + + setTimeout(() => { + target.classList.remove(`outline-selected`); + target.classList.add("outline-transparent"); + }, 3000); + } + } + } + // we only want to scroll when the dialog closes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [prevSearchDetail]); + + useEffect(() => { + if (uniqueResults && itemRefs.current && searchDetail) { + const selectedIndex = uniqueResults.findIndex( + (result) => result.id === searchDetail.id, + ); + + const parent = itemRefs.current[selectedIndex]; + + if (selectedIndex !== -1 && parent) { + scrollIntoView(parent, { + block: "center", + behavior: "smooth", + scrollMode: "if-needed", + }); + } + } + // we only want to scroll when changing the detail pane // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedIndex]); + }, [searchDetail]); // observer for loading more @@ -369,22 +472,39 @@ export default function SearchView({ {hasExistingSearch && (
- - - + {selectedObjects.length == 0 ? ( + <> + + + + + ) : ( +
+ +
+ )}
)} @@ -412,14 +532,14 @@ export default function SearchView({
{uniqueResults && uniqueResults.map((value, index) => { - const selected = selectedIndex === index; + const selected = selectedObjects.includes(value.id); return (
(itemRefs.current[index] = item)} data-start={value.start_time} - className="review-item relative flex flex-col rounded-lg" + className="relative flex flex-col rounded-lg" >
onSelectSearch(value, index)} + onClick={( + value: SearchResult, + ctrl: boolean, + detail: boolean, + ) => { + if (detail && selectedObjects.length == 0) { + setSearchDetail(value); + } else { + onSelectSearch( + value, + ctrl || selectedObjects.length > 0, + ); + } + }} /> {(searchTerm || searchFilter?.search_type?.includes("similarity")) && ( @@ -469,10 +602,10 @@ export default function SearchView({ }} refreshResults={refresh} showObjectLifecycle={() => - onSelectSearch(value, index, "object lifecycle") + onSelectSearch(value, false, "object lifecycle") } showSnapshot={() => - onSelectSearch(value, index, "snapshot") + onSelectSearch(value, false, "snapshot") } />