From 63d8c3662a6fdf3437ba2f04d9f2fbe9a7c539b9 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 29 Feb 2024 19:53:29 -0700 Subject: [PATCH] Add multiselect and batch actions to review items (#10161) * Implement context menu for batch operations and implement apis * reduce preview calculations on rerenders * Add button to mark above items as reviewed * Use context menu for mark as reviewed * Cleanup --- frigate/http.py | 32 ++++++ .../components/filter/ReviewActionGroup.tsx | 72 ++++++++++++++ .../player/PreviewThumbnailPlayer.tsx | 97 ++++++++++++++----- web/src/pages/Events.tsx | 4 +- web/src/views/events/EventView.tsx | 84 +++++++++++++--- 5 files changed, 249 insertions(+), 40 deletions(-) create mode 100644 web/src/components/filter/ReviewActionGroup.tsx diff --git a/frigate/http.py b/frigate/http.py index 9a224a5ed..7f4bb058f 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -2464,6 +2464,24 @@ def set_reviewed(id): ) +@bp.route("/reviews//viewed", methods=("POST",)) +def set_multiple_reviewed(ids: str): + list_of_ids = ids.split(",") + + if not list_of_ids or len(list_of_ids) == 0: + return make_response( + jsonify({"success": False, "message": "Not a valid list of ids"}), 404 + ) + + ReviewSegment.update(has_been_reviewed=True).where( + ReviewSegment.id << list_of_ids + ).execute() + + return make_response( + jsonify({"success": True, "message": "Reviewed multiple items"}), 200 + ) + + @bp.route("/review//viewed", methods=("DELETE",)) def set_not_reviewed(id): try: @@ -2481,6 +2499,20 @@ def set_not_reviewed(id): ) +@bp.route("/reviews/", methods=("DELETE",)) +def delete_reviews(ids: str): + list_of_ids = ids.split(",") + + if not list_of_ids or len(list_of_ids) == 0: + return make_response( + jsonify({"success": False, "message": "Not a valid list of ids"}), 404 + ) + + ReviewSegment.delete().where(ReviewSegment.id << list_of_ids).execute() + + return make_response(jsonify({"success": True, "message": "Delete reviews"}), 200) + + @bp.route("/review//preview.gif") def review_preview(id: str): try: diff --git a/web/src/components/filter/ReviewActionGroup.tsx b/web/src/components/filter/ReviewActionGroup.tsx new file mode 100644 index 000000000..15eddfb28 --- /dev/null +++ b/web/src/components/filter/ReviewActionGroup.tsx @@ -0,0 +1,72 @@ +import { LuCheckSquare, LuTrash, LuX } from "react-icons/lu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../ui/tooltip"; +import { useCallback } from "react"; +import axios from "axios"; + +type ReviewActionGroupProps = { + selectedReviews: string[]; + setSelectedReviews: (ids: string[]) => void; + pullLatestData: () => void; +}; +export default function ReviewActionGroup({ + selectedReviews, + setSelectedReviews, + pullLatestData, +}: ReviewActionGroupProps) { + const onClearSelected = useCallback(() => { + setSelectedReviews([]); + }, [setSelectedReviews]); + + const onMarkAsReviewed = useCallback(async () => { + const idList = selectedReviews.join(","); + await axios.post(`reviews/${idList}/viewed`); + setSelectedReviews([]); + pullLatestData(); + }, [selectedReviews, setSelectedReviews, pullLatestData]); + + const onDelete = useCallback(async () => { + const idList = selectedReviews.join(","); + await axios.delete(`reviews/${idList}`); + setSelectedReviews([]); + pullLatestData(); + }, [selectedReviews, setSelectedReviews, pullLatestData]); + + return ( +
+ + + +
+ +
+
+ Unselect All +
+
+ + +
+ +
+
+ Mark Selected As Reviewed +
+
|
+ + +
+ +
+
+ Delete Selected +
+
+
+
+ ); +} diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 0c9af903f..c7fb8fbed 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -1,4 +1,10 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useApiHost } from "@/api"; import { isCurrentHour } from "@/utils/dateUtil"; import { ReviewSegment } from "@/types/review"; @@ -16,7 +22,8 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "../ui/context-menu"; -import { LuCheckSquare, LuFileUp, LuTrash } from "react-icons/lu"; +import { LuCheckCheck, LuCheckSquare, LuFileUp, LuTrash } from "react-icons/lu"; +import { RiCheckboxMultipleLine } from "react-icons/ri"; import axios from "axios"; import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import useImageLoaded from "@/hooks/use-image-loaded"; @@ -25,11 +32,11 @@ import { useSwipeable } from "react-swipeable"; type PreviewPlayerProps = { review: ReviewSegment; - relevantPreview?: Preview; - autoPlayback?: boolean; - setReviewed?: (reviewId: string) => void; - onClick?: (reviewId: string) => void; + allPreviews?: Preview[]; onTimeUpdate?: (time: number | undefined) => void; + setReviewed: (reviewId: string) => void; + markAboveReviewed: () => void; + onClick: (reviewId: string, ctrl: boolean) => void; }; type Preview = { @@ -42,8 +49,9 @@ type Preview = { export default function PreviewThumbnailPlayer({ review, - relevantPreview, + allPreviews, setReviewed, + markAboveReviewed, onClick, onTimeUpdate, }: PreviewPlayerProps) { @@ -57,11 +65,14 @@ export default function PreviewThumbnailPlayer({ // interaction - const handleOnClick = useCallback(() => { - if (onClick && !ignoreClick) { - onClick(review.id); - } - }, [ignoreClick, review, onClick]); + const handleOnClick = useCallback( + (e: React.MouseEvent) => { + if (!ignoreClick) { + onClick(review.id, e.metaKey); + } + }, + [ignoreClick, review, onClick], + ); const swipeHandlers = useSwipeable({ onSwipedLeft: () => (setReviewed ? setReviewed(review.id) : null), @@ -69,14 +80,24 @@ export default function PreviewThumbnailPlayer({ preventScrollOnSwipe: true, }); - const handleSetReviewed = useCallback(() => { - if (setReviewed) { - setReviewed(review.id); - } - }, [review, setReviewed]); + const handleSetReviewed = useCallback( + () => setReviewed(review.id), + [review, setReviewed], + ); // playback + const relevantPreview = useMemo( + () => + Object.values(allPreviews || []).find( + (preview) => + preview.camera == review.camera && + preview.start < review.start_time && + preview.end > review.end_time, + ), + [allPreviews], + ); + const playingBack = useMemo(() => playback, [playback]); const onPlayback = useCallback( @@ -186,7 +207,12 @@ export default function PreviewThumbnailPlayer({ )} - + onClick(review.id, true)} + setReviewed={handleSetReviewed} + markAboveReviewed={markAboveReviewed} + /> ); } @@ -557,11 +583,15 @@ function InProgressPreview({ type PreviewContextItemsProps = { review: ReviewSegment; - setReviewed?: () => void; + onSelect: () => void; + setReviewed: () => void; + markAboveReviewed: () => void; }; function PreviewContextItems({ review, + onSelect, setReviewed, + markAboveReviewed, }: PreviewContextItemsProps) { const exportReview = useCallback(() => { axios.post( @@ -570,27 +600,46 @@ function PreviewContextItems({ ); }, [review]); + const deleteReview = useCallback(() => { + axios.delete(`reviews/${review.id}`); + }, [review]); + return ( + {isMobile && ( + +
+ Select + +
+
+ )} + +
+ Mark Above as Reviewed + +
+
+ {!review.has_been_reviewed && ( (setReviewed ? setReviewed() : null)}>
Mark As Reviewed - +
)} - exportReview()}> +
Export - +
- +
Delete - +
diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 87b9f53db..37462b061 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -9,7 +9,7 @@ import { useCallback, useMemo, useState } from "react"; import useSWR from "swr"; import useSWRInfinite from "swr/infinite"; -const API_LIMIT = 250; +const API_LIMIT = 100; export default function Events() { // recordings viewer @@ -221,7 +221,7 @@ export default function Events() { setSeverity={setSeverity} loadNextPage={onLoadNextPage} markItemAsReviewed={markItemAsReviewed} - onSelectReview={setSelectedReviewId} + onOpenReview={setSelectedReviewId} pullLatestData={reloadData} updateFilter={onUpdateFilter} /> diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 8d7a12966..866639b67 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -1,5 +1,6 @@ import Logo from "@/components/Logo"; import NewReviewData from "@/components/dynamic/NewReviewData"; +import ReviewActionGroup from "@/components/filter/ReviewActionGroup"; import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup"; import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer"; import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; @@ -9,6 +10,7 @@ import { useEventUtils } from "@/hooks/use-event-utils"; import { FrigateConfig } from "@/types/frigateConfig"; import { Preview } from "@/types/preview"; import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review"; +import axios from "axios"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isDesktop, isMobile } from "react-device-detect"; import { LuFolderCheck } from "react-icons/lu"; @@ -26,7 +28,7 @@ type EventViewProps = { setSeverity: (severity: ReviewSeverity) => void; loadNextPage: () => void; markItemAsReviewed: (reviewId: string) => void; - onSelectReview: (reviewId: string) => void; + onOpenReview: (reviewId: string) => void; pullLatestData: () => void; updateFilter: (filter: ReviewFilter) => void; }; @@ -41,7 +43,7 @@ export default function EventView({ setSeverity, loadNextPage, markItemAsReviewed, - onSelectReview, + onOpenReview, pullLatestData, updateFilter, }: EventViewProps) { @@ -108,7 +110,7 @@ export default function EventView({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [contentRef.current?.scrollHeight, severity]); - // review interaction + // timeline interaction const pagingObserver = useRef(); const lastReviewRef = useCallback( @@ -191,6 +193,59 @@ export default function EventView({ const [previewTime, setPreviewTime] = useState(); + // review interaction + + const [selectedReviews, setSelectedReviews] = useState([]); + const onSelectReview = useCallback( + (reviewId: string, ctrl: boolean) => { + if (selectedReviews.length > 0 || ctrl) { + const index = selectedReviews.indexOf(reviewId); + + if (index != -1) { + if (selectedReviews.length == 1) { + setSelectedReviews([]); + } else { + const copy = [ + ...selectedReviews.slice(0, index), + ...selectedReviews.slice(index + 1), + ]; + setSelectedReviews(copy); + } + } else { + const copy = [...selectedReviews]; + copy.push(reviewId); + setSelectedReviews(copy); + } + } else { + onOpenReview(reviewId); + } + }, + [selectedReviews, setSelectedReviews], + ); + + const markScrolledItemsAsReviewed = useCallback(async () => { + if (!currentItems) { + return; + } + + const scrolled: string[] = []; + + currentItems.find((value) => { + if (value.start_time > minimapBounds.end) { + scrolled.push(value.id); + return false; + } else { + return true; + } + }); + + const idList = scrolled.join(","); + + await axios.post(`reviews/${idList}/viewed`); + setSelectedReviews([]); + pullLatestData(); + }, [currentItems, minimapBounds]); + if (!config) { return ; } @@ -236,6 +291,13 @@ export default function EventView({ + {selectedReviews.length > 0 && ( + + )}
@@ -260,20 +322,13 @@ export default function EventView({ )}
{currentItems ? ( currentItems.map((value, segIdx) => { const lastRow = segIdx == reviewItems[severity].length - 1; - const relevantPreview = Object.values( - relevantPreviews || [], - ).find( - (preview) => - preview.camera == value.camera && - preview.start < value.start_time && - preview.end > value.end_time, - ); + const selected = selectedReviews.includes(value.id); return (