import { isDesktop, isIOS, isMobile } from "react-device-detect"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from "../../ui/sheet"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import { getIconForLabel } from "@/utils/iconUtil"; import { useApiHost } from "@/api"; import { ReviewDetailPaneType, ReviewSegment } from "@/types/review"; import { Event } from "@/types/event"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog"; import ObjectLifecycle from "./ObjectLifecycle"; import Chip from "@/components/indicators/Chip"; import { FaDownload, FaImages, FaShareAlt } from "react-icons/fa"; import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon"; import { FaArrowsRotate } from "react-icons/fa6"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { baseUrl } from "@/api/baseUrl"; import { shareOrCopy } from "@/utils/browserUtil"; import { MobilePage, MobilePageContent, MobilePageDescription, MobilePageHeader, MobilePageTitle, } from "@/components/mobile/MobilePage"; import { useOverlayState } from "@/hooks/use-overlay-state"; import { DownloadVideoButton } from "@/components/button/DownloadVideoButton"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { LuSearch } from "react-icons/lu"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { Trans, useTranslation } from "react-i18next"; type ReviewDetailDialogProps = { review?: ReviewSegment; setReview: (review: ReviewSegment | undefined) => void; }; export default function ReviewDetailDialog({ review, setReview, }: ReviewDetailDialogProps) { const { t } = useTranslation(["views/explore"]); const { data: config } = useSWR("config", { revalidateOnFocus: false, }); const navigate = useNavigate(); // upload const [upload, setUpload] = useState(); // data const { data: events } = useSWR( review ? ["event_ids", { ids: review.data.detections.join(",") }] : null, ); const hasMismatch = useMemo(() => { if (!review || !events) { return false; } return events.length != review?.data.detections.length; }, [review, events]); const missingObjects = useMemo(() => { if (!review || !events) { return []; } const detectedIds = review.data.detections; const missing = Array.from( new Set( events .filter((event) => !detectedIds.includes(event.id)) .map((event) => event.label), ), ); return missing; }, [review, events]); const formattedDate = useFormattedTimestamp( review?.start_time ?? 0, config?.ui.time_format == "24hour" ? t("time.formattedTimestampWithYear.24hour", { ns: "common" }) : t("time.formattedTimestampWithYear.12hour", { ns: "common" }), config?.ui.timezone, ); // content const [selectedEvent, setSelectedEvent] = useState(); const [pane, setPane] = useState("overview"); // dialog and mobile page const [isOpen, setIsOpen] = useOverlayState( "reviewPane", review != undefined, ); const handleOpenChange = useCallback( (open: boolean) => { setIsOpen(open); if (!open) { // short timeout to allow the mobile page animation // to complete before updating the state setTimeout(() => { setReview(undefined); setSelectedEvent(undefined); setPane("overview"); }, 300); } }, [setReview, setIsOpen], ); useEffect(() => { setIsOpen(review != undefined); // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps }, [review]); // keyboard listener useKeyboardListener(["Esc"], (key, modifiers) => { if (key == "Esc" && modifiers.down && !modifiers.repeat) { setIsOpen(false); } }); const Overlay = isDesktop ? Sheet : MobilePage; const Content = isDesktop ? SheetContent : MobilePageContent; const Header = isDesktop ? SheetHeader : MobilePageHeader; const Title = isDesktop ? SheetTitle : MobilePageTitle; const Description = isDesktop ? SheetDescription : MobilePageDescription; if (!review) { return; } return ( <> setUpload(undefined)} onEventUploaded={() => { if (upload) { upload.plus_id = "new_upload"; } }} /> {pane == "overview" && (
{t("details.item.title")} {t("details.item.desc")}
{t("details.item.button.share")} {t("button.download", { ns: "common" })}
)} {pane == "overview" && (
{t("details.camera")}
{review.camera.replaceAll("_", " ")}
{t("details.timestamp")}
{formattedDate}
{t("details.objects")}
{events?.map((event) => { return (
{getIconForLabel( event.label, "size-3 text-primary", )} {event.sub_label ?? event.label.replaceAll("_", " ")}{" "} ({Math.round(event.data.top_score * 100)}%)
{ navigate(`/explore?event_id=${event.id}`); }} >
{t("details.item.button.viewInExplore")}
); })}
{review.data.zones.length > 0 && (
{t("details.zones")}
{review.data.zones.map((zone) => { return (
{zone.replaceAll("_", " ")}
); })}
)}
{hasMismatch && (
{(() => { const detectedCount = Math.abs( (events?.length ?? 0) - (review?.data.detections.length ?? 0), ); return t("details.item.tips.mismatch", { count: detectedCount, }); })()} {missingObjects.length > 0 && (
t(x, { ns: "objects" })) .join(", "), }} > details.item.tips.hasMissingObjects
)}
)}
{events?.map((event) => ( ))}
)} {pane == "details" && selectedEvent && (
)}
); } type EventItemProps = { event: Event; setPane: React.Dispatch>; setSelectedEvent: React.Dispatch>; setUpload?: React.Dispatch>; }; function EventItem({ event, setPane, setSelectedEvent, setUpload, }: EventItemProps) { const { t } = useTranslation(["views/explore"]); const { data: config } = useSWR("config", { revalidateOnFocus: false, }); const apiHost = useApiHost(); const imgRef = useRef(null); const [hovered, setHovered] = useState(isMobile); const navigate = useNavigate(); return ( <>
setHovered(true) : undefined} onMouseLeave={isDesktop ? () => setHovered(false) : undefined} key={event.id} > {event.has_snapshot && ( <>
)} {hovered && (
{t("button.download", { ns: "common" })} {event.has_snapshot && event.plus_id == undefined && event.data.type == "object" && config?.plus.enabled && ( { setUpload?.(event); }} > {t("itemMenu.submitToPlus.label")} )} {event.has_clip && ( { setPane("details"); setSelectedEvent(event); }} > {t("itemMenu.viewObjectLifecycle.label")} )} {event.has_snapshot && config?.semantic_search.enabled && ( { navigate( `/explore?search_type=similarity&event_id=${event.id}`, ); }} > {t("itemMenu.findSimilar.label")} )}
)}
); }