Files
frigate/web/src/components/card/ReviewCard.tsx
T

390 lines
13 KiB
TypeScript
Raw Normal View History

2024-03-26 15:03:58 -06:00
import { baseUrl } from "@/api/baseUrl";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { FrigateConfig } from "@/types/frigateConfig";
2024-06-20 07:25:02 -06:00
import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
2024-03-30 12:45:42 -06:00
import { getIconForLabel } from "@/utils/iconUtil";
import { isDesktop, isIOS, isSafari } from "react-device-detect";
2024-03-26 15:03:58 -06:00
import useSWR from "swr";
import TimeAgo from "../dynamic/TimeAgo";
2025-10-08 13:55:04 -06:00
import { useCallback, useRef, useState } from "react";
2024-03-26 15:03:58 -06:00
import useImageLoaded from "@/hooks/use-image-loaded";
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
import { FaCompactDisc } from "react-icons/fa";
import { FaCircleCheck } from "react-icons/fa6";
import { HiTrash } from "react-icons/hi";
import {
ContextMenu,
ContextMenuContent,
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";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { Button, buttonVariants } from "../ui/button";
import { Trans, useTranslation } from "react-i18next";
2025-10-08 13:55:04 -06:00
import { cn } from "@/lib/utils";
2025-11-04 08:45:45 -06:00
import { LuCircle } from "react-icons/lu";
2025-11-05 08:48:47 -07:00
import { MdAutoAwesome } from "react-icons/md";
2025-12-31 06:48:56 -06:00
import { GenAISummaryDialog } from "../overlay/chip/GenAISummaryChip";
2026-02-08 07:02:40 -06:00
import { getTranslatedLabel } from "@/utils/i18n";
import { formatList } from "@/utils/stringUtil";
2024-03-26 15:03:58 -06:00
type ReviewCardProps = {
event: ReviewSegment;
2025-10-08 13:55:04 -06:00
activeReviewItem?: ReviewSegment;
2024-03-26 15:03:58 -06:00
onClick?: () => void;
};
export default function ReviewCard({
event,
2025-10-08 13:55:04 -06:00
activeReviewItem,
2024-03-26 15:03:58 -06:00
onClick,
}: ReviewCardProps) {
const { t } = useTranslation(["components/dialog"]);
2024-03-26 15:03:58 -06:00
const { data: config } = useSWR<FrigateConfig>("config");
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
const formattedDate = useFormattedTimestamp(
event.start_time,
2025-04-22 16:50:21 -05:00
config?.ui.time_format == "24hour"
? t("time.formattedTimestampHourMinute.24hour", { ns: "common" })
: t("time.formattedTimestampHourMinute.12hour", { ns: "common" }),
2024-09-12 13:28:15 -06:00
config?.ui.timezone,
2024-03-26 15:03:58 -06:00
);
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] });
event.has_been_reviewed = true;
setOptionsOpen(false);
}, [event]);
const onExport = useCallback(async () => {
2024-06-20 07:25:02 -06:00
const endTime = event.end_time
? event.end_time + REVIEW_PADDING
: Date.now() / 1000;
axios
.post(
2024-06-20 07:25:02 -06:00
`export/${event.camera}/start/${event.start_time + REVIEW_PADDING}/end/${endTime}`,
{ playback: "realtime" },
)
.then((response) => {
if (response.status == 200) {
toast.success(t("export.toast.success"), {
position: "top-center",
action: (
<a href="/export" target="_blank" rel="noopener noreferrer">
2025-11-25 07:34:20 -06:00
<Button>{t("export.toast.view")}</Button>
</a>
),
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message || error.message || "Unknown error";
toast.error(t("export.toast.error.failed", { error: errorMessage }), {
position: "top-center",
});
});
setOptionsOpen(false);
}, [event, t]);
const onDelete = useCallback(async () => {
await axios.post(`reviews/delete`, { ids: [event.id] });
event.id = "";
setOptionsOpen(false);
}, [event]);
useKeyboardListener(["Shift"], (_, modifiers) => {
bypassDialogRef.current = modifiers.shift;
2025-10-02 07:21:37 -06:00
return false;
});
const handleDelete = useCallback(() => {
if (bypassDialogRef.current) {
onDelete();
} else {
setDeleteDialogOpen(true);
}
}, [bypassDialogRef, onDelete]);
2026-02-08 07:02:40 -06:00
const getEventType = (text: string) => {
if (event.data.sub_labels?.includes(text)) return "manual";
if (event.data.audio.includes(text)) return "audio";
return "object";
};
const content = (
2024-03-26 15:03:58 -06:00
<div
2024-05-14 10:06:44 -05:00
className="relative flex w-full cursor-pointer flex-col gap-1.5"
2024-03-26 15:03:58 -06:00
onClick={onClick}
onContextMenu={
isDesktop
? undefined
: (e) => {
e.preventDefault();
setOptionsOpen(true);
}
}
2024-03-26 15:03:58 -06:00
>
<ImageLoadingIndicator
2024-03-30 12:44:12 -06:00
className="absolute inset-0"
2024-03-26 15:03:58 -06:00
imgLoaded={imgLoaded}
/>
<img
ref={imgRef}
2025-10-08 13:55:04 -06:00
className={cn(
"size-full rounded-lg",
activeReviewItem?.id == event.id &&
2025-11-04 08:45:45 -06:00
"outline outline-[3px] -outline-offset-[2.8px] outline-selected duration-200",
2025-10-08 13:55:04 -06:00
imgLoaded ? "visible" : "invisible",
)}
2024-03-26 15:03:58 -06:00
src={`${baseUrl}${event.thumb_path.replace("/media/frigate/", "")}`}
loading={isSafari ? "eager" : "lazy"}
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
2024-03-26 15:03:58 -06:00
onLoad={() => {
onImgLoad();
}}
/>
2024-05-14 10:06:44 -05:00
<div className="flex items-center justify-between">
<Tooltip>
<TooltipTrigger asChild>
2025-11-05 08:48:47 -07:00
<div className="flex items-center gap-2">
<LuCircle
className={cn(
"size-2",
event.severity == "alert"
? "fill-severity_alert text-severity_alert"
: "fill-severity_detection text-severity_detection",
)}
/>
<div className="flex items-center gap-1">
{event.data.objects.map((object, idx) => (
<div
key={`${object}-${idx}`}
className="rounded-full bg-muted-foreground p-1"
>
2026-01-09 17:23:33 -06:00
{getIconForLabel(object, "object", "size-3 text-white")}
2025-11-05 08:48:47 -07:00
</div>
))}
{event.data.audio.map((audio, idx) => (
<div
key={`${audio}-${idx}`}
className="rounded-full bg-muted-foreground p-1"
>
2026-01-09 17:23:33 -06:00
{getIconForLabel(audio, "audio", "size-3 text-white")}
2025-11-05 08:48:47 -07:00
</div>
))}
</div>
<div className="font-extra-light text-xs">{formattedDate}</div>
</div>
</TooltipTrigger>
<TooltipContent className="smart-capitalize">
2026-02-08 07:02:40 -06:00
{formatList(
[
...new Set([
...(event.data.objects || []).map((text) =>
text.replace("-verified", ""),
),
...(event.data.sub_labels || []),
...(event.data.audio || []),
]),
]
.filter((item) => item !== undefined)
.map((text) => getTranslatedLabel(text, getEventType(text)))
.sort(),
)}
</TooltipContent>
</Tooltip>
2024-03-26 15:03:58 -06:00
<TimeAgo
className="text-xs text-muted-foreground"
time={event.start_time * 1000}
dense
/>
</div>
2025-11-05 08:48:47 -07:00
{event.data.metadata?.title && (
2025-12-31 06:48:56 -06:00
<GenAISummaryDialog review={event}>
<div className="flex items-center gap-1.5 rounded bg-secondary/50 hover:underline">
<MdAutoAwesome className="size-3 shrink-0 text-primary" />
<span className="truncate text-xs text-primary">
{event.data.metadata.title}
</span>
</div>
</GenAISummaryDialog>
2025-11-05 08:48:47 -07:00
)}
2024-03-26 15:03:58 -06:00
</div>
);
if (event.id == "") {
return;
}
if (isDesktop) {
return (
<>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("recording.confirmDelete.title")}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
<Trans ns="components/dialog">
recording.confirmDelete.title
</Trans>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setOptionsOpen(false)}>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
2024-10-17 06:30:52 -05:00
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={onDelete}
>
{t("button.delete", { ns: "common" })}
</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={onExport}
>
<FaCompactDisc className="text-secondary-foreground" />
<div className="text-primary">
{t("recording.button.export")}
</div>
</div>
</ContextMenuItem>
{!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">
{t("recording.button.markAsReviewed")}
</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
? t("recording.button.deleteNow")
: t("button.delete", { ns: "common" })}
</div>
</div>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</>
);
}
return (
<>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("recording.confirmDelete.title")}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
2025-03-17 07:26:01 -05:00
<Trans ns="components/dialog">
recording.confirmDelete.desc.selected
</Trans>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setOptionsOpen(false)}>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
2024-10-17 06:30:52 -05:00
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={onDelete}
>
{t("button.delete", { ns: "common" })}
</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={onExport}
>
<FaCompactDisc className="text-secondary-foreground" />
<div className="text-primary">{t("recording.button.export")}</div>
</div>
{!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">
{t("recording.button.markAsReviewed")}
</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
? t("recording.button.deleteNow")
: t("button.delete", { ns: "common" })}
</div>
</div>
</DrawerContent>
</Drawer>
</>
);
2024-03-26 15:03:58 -06:00
}