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 { isDesktop, isIOS, isSafari } from "react-device-detect";
import useSWR from "swr"; import useSWR from "swr";
import TimeAgo from "../dynamic/TimeAgo"; 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 useImageLoaded from "@/hooks/use-image-loaded";
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator"; import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
import { FaCompactDisc } from "react-icons/fa"; import { FaCompactDisc } from "react-icons/fa";
@ -18,9 +18,20 @@ import {
ContextMenuItem, ContextMenuItem,
ContextMenuTrigger, ContextMenuTrigger,
} from "../ui/context-menu"; } from "../ui/context-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog";
import { Drawer, DrawerContent } from "../ui/drawer"; import { Drawer, DrawerContent } from "../ui/drawer";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
type ReviewCardProps = { type ReviewCardProps = {
event: ReviewSegment; event: ReviewSegment;
@ -46,6 +57,8 @@ export default function ReviewCard({
); );
const [optionsOpen, setOptionsOpen] = useState(false); const [optionsOpen, setOptionsOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const bypassDialogRef = useRef(false);
const onMarkAsReviewed = useCallback(async () => { const onMarkAsReviewed = useCallback(async () => {
await axios.post(`reviews/viewed`, { ids: [event.id] }); await axios.post(`reviews/viewed`, { ids: [event.id] });
@ -92,6 +105,18 @@ export default function ReviewCard({
setOptionsOpen(false); setOptionsOpen(false);
}, [event]); }, [event]);
useKeyboardListener(["Shift"], (_, modifiers) => {
bypassDialogRef.current = modifiers.shift;
});
const handleDelete = useCallback(() => {
if (bypassDialogRef.current) {
onDelete();
} else {
setDeleteDialogOpen(true);
}
}, [bypassDialogRef, onDelete]);
const content = ( const content = (
<div <div
className="relative flex w-full cursor-pointer flex-col gap-1.5" className="relative flex w-full cursor-pointer flex-col gap-1.5"
@ -158,7 +183,33 @@ export default function ReviewCard({
if (isDesktop) { if (isDesktop) {
return ( return (
<ContextMenu> <>
<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> <ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
<ContextMenuItem> <ContextMenuItem>
@ -184,18 +235,47 @@ export default function ReviewCard({
<ContextMenuItem> <ContextMenuItem>
<div <div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2" className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={onDelete} onClick={handleDelete}
> >
<HiTrash className="text-secondary-foreground" /> <HiTrash className="text-secondary-foreground" />
<div className="text-primary">Delete</div> <div className="text-primary">
{bypassDialogRef.current ? "Delete Now" : "Delete"}
</div>
</div> </div>
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
</>
); );
} }
return ( return (
<>
<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}> <Drawer open={optionsOpen} onOpenChange={setOptionsOpen}>
{content} {content}
<DrawerContent> <DrawerContent>
@ -217,12 +297,15 @@ export default function ReviewCard({
)} )}
<div <div
className="flex w-full items-center justify-start gap-2 p-2" className="flex w-full items-center justify-start gap-2 p-2"
onClick={onDelete} onClick={handleDelete}
> >
<HiTrash className="text-secondary-foreground" /> <HiTrash className="text-secondary-foreground" />
<div className="text-primary">Delete</div> <div className="text-primary">
{bypassDialogRef.current ? "Delete Now" : "Delete"}
</div>
</div> </div>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
</>
); );
} }

View File

@ -1,10 +1,21 @@
import { FaCircleCheck } from "react-icons/fa6"; import { FaCircleCheck } from "react-icons/fa6";
import { useCallback } from "react"; import { useCallback, useState } from "react";
import axios from "axios"; import axios from "axios";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import { FaCompactDisc } from "react-icons/fa"; import { FaCompactDisc } from "react-icons/fa";
import { HiTrash } from "react-icons/hi"; 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 = { type ReviewActionGroupProps = {
selectedReviews: string[]; selectedReviews: string[];
@ -34,7 +45,47 @@ export default function ReviewActionGroup({
pullLatestData(); pullLatestData();
}, [selectedReviews, setSelectedReviews, 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 ( return (
<>
<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="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="mx-1 flex items-center justify-center text-sm text-muted-foreground">
<div className="p-1">{`${selectedReviews.length} selected`}</div> <div className="p-1">{`${selectedReviews.length} selected`}</div>
@ -71,12 +122,17 @@ export default function ReviewActionGroup({
<Button <Button
className="flex items-center gap-2 p-2" className="flex items-center gap-2 p-2"
size="sm" size="sm"
onClick={onDelete} onClick={handleDelete}
> >
<HiTrash className="text-secondary-foreground" /> <HiTrash className="text-secondary-foreground" />
{isDesktop && <div className="text-primary">Delete</div>} {isDesktop && (
<div className="text-primary">
{bypassDialog ? "Delete Now" : "Delete"}
</div>
)}
</Button> </Button>
</div> </div>
</div> </div>
</>
); );
} }

View File

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

View File

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

View File

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