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..79282bd7d 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"; @@ -17,6 +23,7 @@ import { ContextMenuTrigger, } from "../ui/context-menu"; import { 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"; @@ -28,8 +35,8 @@ type PreviewPlayerProps = { relevantPreview?: Preview; autoPlayback?: boolean; setReviewed?: (reviewId: string) => void; - onClick?: (reviewId: string) => void; onTimeUpdate?: (time: number | undefined) => void; + onClick: (reviewId: string, ctrl: boolean) => void; }; type Preview = { @@ -57,11 +64,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), @@ -186,7 +196,11 @@ export default function PreviewThumbnailPlayer({ )} - + onClick(review.id, true)} + setReviewed={handleSetReviewed} + /> ); } @@ -557,10 +571,12 @@ function InProgressPreview({ type PreviewContextItemsProps = { review: ReviewSegment; + onSelect: () => void; setReviewed?: () => void; }; function PreviewContextItems({ review, + onSelect, setReviewed, }: PreviewContextItemsProps) { const exportReview = useCallback(() => { @@ -570,27 +586,42 @@ function PreviewContextItems({ ); }, [review]); + const deleteReview = useCallback(() => { + axios.delete(`reviews/${review.id}`); + }, [review]); + return ( + {isMobile && ( + <> + +
+ Select + +
+
+ + + )} {!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..2860e515e 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"; @@ -26,7 +27,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 +42,7 @@ export default function EventView({ setSeverity, loadNextPage, markItemAsReviewed, - onSelectReview, + onOpenReview, pullLatestData, updateFilter, }: EventViewProps) { @@ -110,6 +111,36 @@ export default function EventView({ // 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], + ); + + // timeline interaction + const pagingObserver = useRef(); const lastReviewRef = useCallback( (node: HTMLElement | null) => { @@ -236,6 +267,13 @@ export default function EventView({ + {selectedReviews.length > 0 && ( + + )}
@@ -260,12 +298,13 @@ export default function EventView({ )}
{currentItems ? ( currentItems.map((value, segIdx) => { const lastRow = segIdx == reviewItems[severity].length - 1; + const selected = selectedReviews.includes(value.id); const relevantPreview = Object.values( relevantPreviews || [], ).find( @@ -284,7 +323,7 @@ export default function EventView({ alignStartDateToTimeline(value.start_time) - segmentDuration } - className="outline outline-offset-1 outline-0 rounded-lg shadow-none transition-all duration-500 my-1 md:my-0" + className={`outline outline-offset-1 rounded-lg shadow-none transition-all my-1 md:my-0 ${selected ? `outline-4 shadow-[0_0_6px_1px] outline-severity_${value.severity} shadow-severity_${value.severity}` : "outline-0 duration-500"}`} >