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")
}
/>