From 3621f93d40de6faf53f553ccccd39be4a7a3ad37 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 23 Feb 2024 17:26:26 -0700 Subject: [PATCH] Review items right click menu (#10002) * Add custom context menu for review items * Only show mark as reviewed when it has not been reviewed * Fix float comparison --- web/package-lock.json | 29 +++ web/package.json | 1 + .../player/PreviewThumbnailPlayer.tsx | 187 +++++++++++------ web/src/components/ui/context-menu.tsx | 198 ++++++++++++++++++ web/src/pages/Export.tsx | 9 +- 5 files changed, 356 insertions(+), 68 deletions(-) create mode 100644 web/src/components/ui/context-menu.tsx diff --git a/web/package-lock.json b/web/package-lock.json index aa997a927..9b84a97b5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,6 +12,7 @@ "@hookform/resolvers": "^3.3.2", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-aspect-ratio": "^1.0.3", + "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", @@ -1185,6 +1186,34 @@ } } }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.1.5.tgz", + "integrity": "sha512-R5XaDj06Xul1KGb+WP8qiOh7tKJNz2durpLBXAGZjSVtctcRFCuEvy2gtMwRJGePwQQE5nV77gs4FwRi8T+r2g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-menu": "2.0.6", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz", diff --git a/web/package.json b/web/package.json index 61ef26429..b64f39a58 100644 --- a/web/package.json +++ b/web/package.json @@ -17,6 +17,7 @@ "@hookform/resolvers": "^3.3.2", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-aspect-ratio": "^1.0.3", + "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index bdde45697..f8a690555 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -11,6 +11,15 @@ import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { isMobile, isSafari } from "react-device-detect"; import Chip from "../Chip"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "../ui/context-menu"; +import { LuCheckSquare, LuFileUp, LuTrash } from "react-icons/lu"; +import axios from "axios"; type PreviewPlayerProps = { review: ReviewSegment; @@ -86,66 +95,72 @@ export default function PreviewThumbnailPlayer({ ); return ( -
onPlayback(true)} - onMouseLeave={isMobile ? undefined : () => onPlayback(false)} - onClick={onClick} - > - {playingBack ? ( - - ) : ( - - )} - {(review.severity == "alert" || review.severity == "detection") && ( - - {review.data.objects.map((object) => { - return getIconForLabel(object, "w-3 h-3 text-white"); - })} - {review.data.audio.map((audio) => { - return getIconForLabel(audio, "w-3 h-3 text-white"); - })} - {review.data.sub_labels?.map((sub) => { - return getIconForSubLabel(sub, "w-3 h-3 text-white"); - })} - - )} - {!playingBack && ( -
- - {config && - formatUnixTimestampToDateTime(review.start_time, { - strftime_fmt: - config.ui.time_format == "24hour" - ? "%b %-d, %H:%M" - : "%b %-d, %I:%M %p", + + onPlayback(true)} + onMouseLeave={isMobile ? undefined : () => onPlayback(false)} + onClick={onClick} + > + {playingBack ? ( + + ) : ( + + )} + {(review.severity == "alert" || review.severity == "detection") && ( + + {review.data.objects.map((object) => { + return getIconForLabel(object, "w-3 h-3 text-white"); })} -
- )} -
-
- {playingBack && ( - - )} - {!playingBack && review.has_been_reviewed && ( -
- )} -
+ {review.data.audio.map((audio) => { + return getIconForLabel(audio, "w-3 h-3 text-white"); + })} + {review.data.sub_labels?.map((sub) => { + return getIconForSubLabel(sub, "w-3 h-3 text-white"); + })} + + )} + {!playingBack && ( +
+ + {config && + formatUnixTimestampToDateTime(review.start_time, { + strftime_fmt: + config.ui.time_format == "24hour" + ? "%b %-d, %H:%M" + : "%b %-d, %I:%M %p", + })} +
+ )} +
+
+ {playingBack && ( + + )} + {!playingBack && review.has_been_reviewed && ( +
+ )} + + + ); } @@ -291,9 +306,9 @@ function InProgressPreview({ }: InProgressPreviewProps) { const apiHost = useApiHost(); const { data: previewFrames } = useSWR( - `preview/${review.camera}/start/${Math.floor( - review.start_time - ) - 4}/end/${Math.ceil(review.end_time) + 4}/frames` + `preview/${review.camera}/start/${Math.floor(review.start_time) - 4}/end/${ + Math.ceil(review.end_time) + 4 + }/frames` ); const [key, setKey] = useState(0); @@ -315,7 +330,7 @@ function InProgressPreview({ setProgress((key / (previewFrames.length - 1)) * 100); } - if (setReviewed && key == previewFrames.length / 2) { + if (setReviewed && key == Math.floor(previewFrames.length / 2)) { setReviewed(); } @@ -343,3 +358,49 @@ function InProgressPreview({
); } + +type PreviewContextItemsProps = { + review: ReviewSegment; + setReviewed?: () => void; +}; +function PreviewContextItems({ + review, + setReviewed, +}: PreviewContextItemsProps) { + const exportReview = useCallback(() => { + console.log( + "trying to export to " + + `export/${review.camera}/start/${review.start_time}/end/${review.end_time}` + ); + axios.post( + `export/${review.camera}/start/${review.start_time}/end/${review.end_time}`, + { playback: "realtime" } + ); + }, [review]); + + return ( + + {!review.has_been_reviewed && ( + (setReviewed ? setReviewed() : null)}> +
+ Mark As Reviewed + +
+
+ )} + exportReview()}> +
+ Export + +
+
+ + +
+ Delete + +
+
+
+ ); +} diff --git a/web/src/components/ui/context-menu.tsx b/web/src/components/ui/context-menu.tsx new file mode 100644 index 000000000..3e5299917 --- /dev/null +++ b/web/src/components/ui/context-menu.tsx @@ -0,0 +1,198 @@ +import * as React from "react" +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ContextMenu = ContextMenuPrimitive.Root + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger + +const ContextMenuGroup = ContextMenuPrimitive.Group + +const ContextMenuPortal = ContextMenuPrimitive.Portal + +const ContextMenuSub = ContextMenuPrimitive.Sub + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuCheckboxItem.displayName = + ContextMenuPrimitive.CheckboxItem.displayName + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName + +const ContextMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +ContextMenuShortcut.displayName = "ContextMenuShortcut" + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/web/src/pages/Export.tsx b/web/src/pages/Export.tsx index a2ca53a9c..878e1a8aa 100644 --- a/web/src/pages/Export.tsx +++ b/web/src/pages/Export.tsx @@ -151,8 +151,7 @@ function Export() { }, [deleteClip]); return ( - <> - Export +
-
+
@@ -292,7 +291,7 @@ function Export() {
{exports && ( - + Exports {Object.values(exports).map((item) => ( )}
- +
); }