mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-26 19:06:11 +01:00
Add confirmation dialog before deleting review items (#12950)
This commit is contained in:
parent
9b96211faf
commit
77bf710299
@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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],
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user