Add confirmation dialog before deleting review items (#12950)

This commit is contained in:
Josh Hawkins 2024-08-11 07:25:09 -05:00 committed by Nicolas Mowen
parent d3259c4782
commit 9832831c5e
5 changed files with 263 additions and 116 deletions

View File

@ -6,7 +6,7 @@ import { getIconForLabel } from "@/utils/iconUtil";
import { isDesktop, isIOS, isSafari } from "react-device-detect";
import useSWR from "swr";
import TimeAgo from "../dynamic/TimeAgo";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import useImageLoaded from "@/hooks/use-image-loaded";
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
import { FaCompactDisc } from "react-icons/fa";
@ -18,9 +18,20 @@ import {
ContextMenuItem,
ContextMenuTrigger,
} from "../ui/context-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog";
import { Drawer, DrawerContent } from "../ui/drawer";
import axios from "axios";
import { toast } from "sonner";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
type ReviewCardProps = {
event: ReviewSegment;
@ -46,6 +57,8 @@ export default function ReviewCard({
);
const [optionsOpen, setOptionsOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const bypassDialogRef = useRef(false);
const onMarkAsReviewed = useCallback(async () => {
await axios.post(`reviews/viewed`, { ids: [event.id] });
@ -92,6 +105,18 @@ export default function ReviewCard({
setOptionsOpen(false);
}, [event]);
useKeyboardListener(["Shift"], (_, modifiers) => {
bypassDialogRef.current = modifiers.shift;
});
const handleDelete = useCallback(() => {
if (bypassDialogRef.current) {
onDelete();
} else {
setDeleteDialogOpen(true);
}
}, [bypassDialogRef, onDelete]);
const content = (
<div
className="relative flex w-full cursor-pointer flex-col gap-1.5"
@ -158,71 +183,129 @@ export default function ReviewCard({
if (isDesktop) {
return (
<ContextMenu>
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={onExport}
>
<FaCompactDisc className="text-secondary-foreground" />
<div className="text-primary">Export</div>
</div>
</ContextMenuItem>
{!event.has_been_reviewed && (
<>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Are you sure you want to delete all recorded video associated with
this review item?
<br />
<br />
Hold the <em>Shift</em> key to bypass this dialog in the future.
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setOptionsOpen(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction className="bg-destructive" onClick={onDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ContextMenu key={event.id}>
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={onMarkAsReviewed}
onClick={onExport}
>
<FaCircleCheck className="text-secondary-foreground" />
<div className="text-primary">Mark as reviewed</div>
<FaCompactDisc className="text-secondary-foreground" />
<div className="text-primary">Export</div>
</div>
</ContextMenuItem>
)}
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={onDelete}
>
<HiTrash className="text-secondary-foreground" />
<div className="text-primary">Delete</div>
</div>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{!event.has_been_reviewed && (
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={onMarkAsReviewed}
>
<FaCircleCheck className="text-secondary-foreground" />
<div className="text-primary">Mark as reviewed</div>
</div>
</ContextMenuItem>
)}
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={handleDelete}
>
<HiTrash className="text-secondary-foreground" />
<div className="text-primary">
{bypassDialogRef.current ? "Delete Now" : "Delete"}
</div>
</div>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</>
);
}
return (
<Drawer open={optionsOpen} onOpenChange={setOptionsOpen}>
{content}
<DrawerContent>
<div
className="flex w-full items-center justify-start gap-2 p-2"
onClick={onExport}
>
<FaCompactDisc className="text-secondary-foreground" />
<div className="text-primary">Export</div>
</div>
{!event.has_been_reviewed && (
<>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Are you sure you want to delete all recorded video associated with
this review item?
<br />
<br />
Hold the <em>Shift</em> key to bypass this dialog in the future.
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setOptionsOpen(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction className="bg-destructive" onClick={onDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Drawer open={optionsOpen} onOpenChange={setOptionsOpen}>
{content}
<DrawerContent>
<div
className="flex w-full items-center justify-start gap-2 p-2"
onClick={onMarkAsReviewed}
onClick={onExport}
>
<FaCircleCheck className="text-secondary-foreground" />
<div className="text-primary">Mark as reviewed</div>
<FaCompactDisc className="text-secondary-foreground" />
<div className="text-primary">Export</div>
</div>
)}
<div
className="flex w-full items-center justify-start gap-2 p-2"
onClick={onDelete}
>
<HiTrash className="text-secondary-foreground" />
<div className="text-primary">Delete</div>
</div>
</DrawerContent>
</Drawer>
{!event.has_been_reviewed && (
<div
className="flex w-full items-center justify-start gap-2 p-2"
onClick={onMarkAsReviewed}
>
<FaCircleCheck className="text-secondary-foreground" />
<div className="text-primary">Mark as reviewed</div>
</div>
)}
<div
className="flex w-full items-center justify-start gap-2 p-2"
onClick={handleDelete}
>
<HiTrash className="text-secondary-foreground" />
<div className="text-primary">
{bypassDialogRef.current ? "Delete Now" : "Delete"}
</div>
</div>
</DrawerContent>
</Drawer>
</>
);
}

View File

@ -1,10 +1,21 @@
import { FaCircleCheck } from "react-icons/fa6";
import { useCallback } from "react";
import { useCallback, useState } from "react";
import axios from "axios";
import { Button } from "../ui/button";
import { isDesktop } from "react-device-detect";
import { FaCompactDisc } from "react-icons/fa";
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";
type ReviewActionGroupProps = {
selectedReviews: string[];
@ -34,49 +45,94 @@ export default function ReviewActionGroup({
pullLatestData();
}, [selectedReviews, setSelectedReviews, 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 (
<div className="absolute inset-x-2 inset-y-0 flex items-center justify-between gap-2 bg-background py-2 md:left-auto">
<div className="mx-1 flex items-center justify-center text-sm text-muted-foreground">
<div className="p-1">{`${selectedReviews.length} selected`}</div>
<div className="p-1">{"|"}</div>
<div
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
onClick={onClearSelected}
>
Unselect
<>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Are you sure you want to delete all recorded video associated with
the selected review items?
<br />
<br />
Hold the <em>Shift</em> key to bypass this dialog in the future.
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction className="bg-destructive" onClick={onDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="absolute inset-x-2 inset-y-0 flex items-center justify-between gap-2 bg-background py-2 md:left-auto">
<div className="mx-1 flex items-center justify-center text-sm text-muted-foreground">
<div className="p-1">{`${selectedReviews.length} selected`}</div>
<div className="p-1">{"|"}</div>
<div
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
onClick={onClearSelected}
>
Unselect
</div>
</div>
</div>
<div className="flex items-center gap-1 md:gap-2">
{selectedReviews.length == 1 && (
<div className="flex items-center gap-1 md:gap-2">
{selectedReviews.length == 1 && (
<Button
className="flex items-center gap-2 p-2"
size="sm"
onClick={() => {
onExport(selectedReviews[0]);
onClearSelected();
}}
>
<FaCompactDisc className="text-secondary-foreground" />
{isDesktop && <div className="text-primary">Export</div>}
</Button>
)}
<Button
className="flex items-center gap-2 p-2"
size="sm"
onClick={() => {
onExport(selectedReviews[0]);
onClearSelected();
}}
onClick={onMarkAsReviewed}
>
<FaCompactDisc className="text-secondary-foreground" />
{isDesktop && <div className="text-primary">Export</div>}
<FaCircleCheck className="text-secondary-foreground" />
{isDesktop && <div className="text-primary">Mark as reviewed</div>}
</Button>
)}
<Button
className="flex items-center gap-2 p-2"
size="sm"
onClick={onMarkAsReviewed}
>
<FaCircleCheck className="text-secondary-foreground" />
{isDesktop && <div className="text-primary">Mark as reviewed</div>}
</Button>
<Button
className="flex items-center gap-2 p-2"
size="sm"
onClick={onDelete}
>
<HiTrash className="text-secondary-foreground" />
{isDesktop && <div className="text-primary">Delete</div>}
</Button>
<Button
className="flex items-center gap-2 p-2"
size="sm"
onClick={handleDelete}
>
<HiTrash className="text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{bypassDialog ? "Delete Now" : "Delete"}
</div>
)}
</Button>
</div>
</div>
</div>
</>
);
}

View File

@ -141,7 +141,7 @@ export default function VideoControls({
}, [volume, muted]);
const onKeyboardShortcut = useCallback(
(key: string, modifiers: KeyModifiers) => {
(key: string | null, modifiers: KeyModifiers) => {
if (!modifiers.down) {
return;
}

View File

@ -4,11 +4,12 @@ export type KeyModifiers = {
down: boolean;
repeat: boolean;
ctrl: boolean;
shift: boolean;
};
export default function useKeyboardListener(
keys: string[],
listener: (key: string, modifiers: KeyModifiers) => void,
listener: (key: string | null, modifiers: KeyModifiers) => void,
) {
const keyDownListener = useCallback(
(e: KeyboardEvent) => {
@ -16,13 +17,18 @@ export default function useKeyboardListener(
return;
}
const modifiers = {
down: true,
repeat: e.repeat,
ctrl: e.ctrlKey || e.metaKey,
shift: e.shiftKey,
};
if (keys.includes(e.key)) {
e.preventDefault();
listener(e.key, {
down: true,
repeat: e.repeat,
ctrl: e.ctrlKey || e.metaKey,
});
listener(e.key, modifiers);
} else if (e.key === "Shift" || e.key === "Control" || e.key === "Meta") {
listener(null, modifiers);
}
},
[keys, listener],
@ -34,9 +40,18 @@ export default function useKeyboardListener(
return;
}
const modifiers = {
down: false,
repeat: false,
ctrl: false,
shift: false,
};
if (keys.includes(e.key)) {
e.preventDefault();
listener(e.key, { down: false, repeat: false, ctrl: false });
listener(e.key, modifiers);
} else if (e.key === "Shift" || e.key === "Control" || e.key === "Meta") {
listener(null, modifiers);
}
},
[keys, listener],

View File

@ -31,9 +31,7 @@ import { cn } from "@/lib/utils";
import { LivePlayerError, LivePlayerMode } from "@/types/live";
import { FaCompress, FaExpand } from "react-icons/fa";
import { useResizeObserver } from "@/hooks/resize-observer";
import useKeyboardListener, {
KeyModifiers,
} from "@/hooks/use-keyboard-listener";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
type LiveDashboardViewProps = {
cameras: CameraConfig[];
@ -250,22 +248,17 @@ export default function LiveDashboardView({
[setPreferredLiveModes],
);
const onKeyboardShortcut = useCallback(
(key: string, modifiers: KeyModifiers) => {
if (!modifiers.down) {
return;
}
useKeyboardListener(["f"], (key, modifiers) => {
if (!modifiers.down) {
return;
}
switch (key) {
case "f":
toggleFullscreen();
break;
}
},
[toggleFullscreen],
);
useKeyboardListener(["f"], onKeyboardShortcut);
switch (key) {
case "f":
toggleFullscreen();
break;
}
});
return (
<div