import { isDesktop, isIOS, isMobile, isSafari } from "react-device-detect"; import { SearchResult } from "@/types/search"; 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 { Button } from "../../ui/button"; import { useCallback, useEffect, useMemo, useState } from "react"; import axios from "axios"; import { toast } from "sonner"; import { Textarea } from "../../ui/textarea"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import useOptimisticState from "@/hooks/use-optimistic-state"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Event } from "@/types/event"; import { baseUrl } from "@/api/baseUrl"; import { cn } from "@/lib/utils"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { FaArrowRight, FaCheckCircle, FaChevronDown, FaDownload, FaHistory, FaImage, FaRegListAlt, FaVideo, } from "react-icons/fa"; import { FaRotate } from "react-icons/fa6"; import ObjectLifecycle from "./ObjectLifecycle"; import { MobilePage, MobilePageContent, MobilePageDescription, MobilePageHeader, MobilePageTitle, } from "@/components/mobile/MobilePage"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { ReviewSegment } from "@/types/review"; import { useNavigate } from "react-router-dom"; import Chip from "@/components/indicators/Chip"; import { capitalizeAll } from "@/utils/stringUtil"; import useGlobalMutation from "@/hooks/use-global-mutate"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; import { Card, CardContent } from "@/components/ui/card"; import useImageLoaded from "@/hooks/use-image-loaded"; import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator"; import { GenericVideoPlayer } from "@/components/player/GenericVideoPlayer"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { LuInfo, LuSearch } from "react-icons/lu"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { FaPencilAlt } from "react-icons/fa"; import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog"; import { Trans, useTranslation } from "react-i18next"; import { TbFaceId } from "react-icons/tb"; import { useIsAdmin } from "@/hooks/use-is-admin"; import FaceSelectionDialog from "../FaceSelectionDialog"; const SEARCH_TABS = [ "details", "snapshot", "video", "object_lifecycle", ] as const; export type SearchTab = (typeof SEARCH_TABS)[number]; type SearchDetailDialogProps = { search?: SearchResult; page: SearchTab; setSearch: (search: SearchResult | undefined) => void; setSearchPage: (page: SearchTab) => void; setSimilarity?: () => void; setInputFocused: React.Dispatch>; }; export default function SearchDetailDialog({ search, page, setSearch, setSearchPage, setSimilarity, setInputFocused, }: SearchDetailDialogProps) { const { t } = useTranslation(["views/explore", "views/faceLibrary"]); const { data: config } = useSWR("config", { revalidateOnFocus: false, }); // tabs const [pageToggle, setPageToggle] = useOptimisticState( page, setSearchPage, 100, ); // dialog and mobile page const [isOpen, setIsOpen] = useState(search != 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(() => { setSearch(undefined); }, 300); } }, [setSearch], ); useEffect(() => { if (search) { setIsOpen(search != undefined); } }, [search]); const searchTabs = useMemo(() => { if (!config || !search) { return []; } const views = [...SEARCH_TABS]; if (!search.has_snapshot) { const index = views.indexOf("snapshot"); views.splice(index, 1); } if (!search.has_clip) { const index = views.indexOf("video"); views.splice(index, 1); } if (search.data.type != "object" || !search.has_clip) { const index = views.indexOf("object_lifecycle"); views.splice(index, 1); } return views; }, [config, search]); useEffect(() => { if (searchTabs.length == 0) { return; } if (!searchTabs.includes(pageToggle)) { setSearchPage("details"); } }, [pageToggle, searchTabs, setSearchPage]); if (!search) { return; } // content const Overlay = isDesktop ? Dialog : MobilePage; const Content = isDesktop ? DialogContent : MobilePageContent; const Header = isDesktop ? DialogHeader : MobilePageHeader; const Title = isDesktop ? DialogTitle : MobilePageTitle; const Description = isDesktop ? DialogDescription : MobilePageDescription; return (
{t("trackedObjectDetails")} {t("trackedObjectDetails")}
{ if (value) { setPageToggle(value); } }} > {Object.values(searchTabs).map((item) => ( {item == "details" && } {item == "snapshot" && } {item == "video" && } {item == "object_lifecycle" && ( )}
{t(`type.${item}`)}
))}
{page == "details" && ( )} {page == "snapshot" && ( { search.plus_id = "new_upload"; }} /> )} {page == "video" && } {page == "object_lifecycle" && ( {}} /> )}
); } type ObjectDetailsTabProps = { search: SearchResult; config?: FrigateConfig; setSearch: (search: SearchResult | undefined) => void; setSimilarity?: () => void; setInputFocused: React.Dispatch>; }; function ObjectDetailsTab({ search, config, setSearch, setSimilarity, setInputFocused, }: ObjectDetailsTabProps) { const { t } = useTranslation(["views/explore", "views/faceLibrary"]); const apiHost = useApiHost(); // mutation / revalidation const mutate = useGlobalMutation(); // users const isAdmin = useIsAdmin(); // data const [desc, setDesc] = useState(search?.data.description); const [isSubLabelDialogOpen, setIsSubLabelDialogOpen] = useState(false); const [isLPRDialogOpen, setIsLPRDialogOpen] = useState(false); const handleDescriptionFocus = useCallback(() => { setInputFocused(true); }, [setInputFocused]); const handleDescriptionBlur = useCallback(() => { setInputFocused(false); }, [setInputFocused]); // we have to make sure the current selected search item stays in sync useEffect(() => setDesc(search?.data.description ?? ""), [search]); const formattedDate = useFormattedTimestamp( search?.start_time ?? 0, config?.ui.time_format == "24hour" ? t("time.formattedTimestampMonthDayYearHourMinute.24hour", { ns: "common", }) : t("time.formattedTimestampMonthDayYearHourMinute.12hour", { ns: "common", }), config?.ui.timezone, ); const topScore = useMemo(() => { if (!search) { return 0; } const value = search.data.top_score ?? search.top_score ?? 0; return Math.round(value * 100); }, [search]); const subLabelScore = useMemo(() => { if (!search) { return undefined; } if (search.sub_label && search.data?.sub_label_score) { return Math.round((search.data?.sub_label_score ?? 0) * 100); } else { return undefined; } }, [search]); const recognizedLicensePlateScore = useMemo(() => { if (!search) { return undefined; } if ( search.data.recognized_license_plate && search.data?.recognized_license_plate_score ) { return Math.round( (search.data?.recognized_license_plate_score ?? 0) * 100, ); } else { return undefined; } }, [search]); const snapScore = useMemo(() => { if (!search?.has_snapshot) { return undefined; } const value = search.data.score ?? search.score ?? 0; return Math.floor(value * 100); }, [search]); const averageEstimatedSpeed = useMemo(() => { if (!search || !search.data?.average_estimated_speed) { return undefined; } if (search.data?.average_estimated_speed != 0) { return search.data?.average_estimated_speed.toFixed(1); } else { return undefined; } }, [search]); const velocityAngle = useMemo(() => { if (!search || !search.data?.velocity_angle) { return undefined; } if (search.data?.velocity_angle != 0) { return search.data?.velocity_angle.toFixed(1); } else { return undefined; } }, [search]); const updateDescription = useCallback(() => { if (!search) { return; } axios .post(`events/${search.id}/description`, { description: desc }) .then((resp) => { if (resp.status == 200) { toast.success(t("details.tips.descriptionSaved"), { position: "top-center", }); } mutate( (key) => typeof key === "string" && (key.includes("events") || key.includes("events/search") || key.includes("events/explore")), (currentData: SearchResult[][] | SearchResult[] | undefined) => { if (!currentData) return currentData; // optimistic update return currentData .flat() .map((event) => event.id === search.id ? { ...event, data: { ...event.data, description: desc } } : event, ); }, { optimisticData: true, rollbackOnError: true, revalidate: false, }, ); }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("details.tips.saveDescriptionFailed", { errorMessage, }), { position: "top-center", }, ); setDesc(search.data.description); }); }, [desc, search, mutate, t]); const regenerateDescription = useCallback( (source: "snapshot" | "thumbnails") => { if (!search) { return; } axios .put(`events/${search.id}/description/regenerate?source=${source}`) .then((resp) => { if (resp.status == 200) { toast.success( t("details.item.toast.success.regenerate", { provider: capitalizeAll( config?.genai.provider.replaceAll("_", " ") ?? t("generativeAI"), ), }), { position: "top-center", duration: 7000, }, ); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("details.item.toast.error.regenerate", { provider: capitalizeAll( config?.genai.provider.replaceAll("_", " ") ?? t("generativeAI"), ), errorMessage, }), { position: "top-center" }, ); }); }, [search, config, t], ); const handleSubLabelSave = useCallback( (text: string) => { if (!search) return; // set score to 1.0 if we're manually entering a sub label const subLabelScore = text === "" ? undefined : search.data?.sub_label_score || 1.0; axios .post(`${apiHost}api/events/${search.id}/sub_label`, { camera: search.camera, subLabel: text, subLabelScore: subLabelScore, }) .then((response) => { if (response.status === 200) { toast.success(t("details.item.toast.success.updatedSublabel"), { position: "top-center", }); mutate( (key) => typeof key === "string" && (key.includes("events") || key.includes("events/search") || key.includes("events/explore")), (currentData: SearchResult[][] | SearchResult[] | undefined) => { if (!currentData) return currentData; return currentData.flat().map((event) => event.id === search.id ? { ...event, sub_label: text, data: { ...event.data, sub_label_score: subLabelScore, }, } : event, ); }, { optimisticData: true, rollbackOnError: true, revalidate: false, }, ); setSearch({ ...search, sub_label: text, data: { ...search.data, sub_label_score: subLabelScore, }, }); setIsSubLabelDialogOpen(false); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("details.item.toast.error.updatedSublabelFailed", { errorMessage, }), { position: "top-center", }, ); }); }, [search, apiHost, mutate, setSearch, t], ); // recognized plate const handleLPRSave = useCallback( (text: string) => { if (!search) return; // set score to 1.0 if we're manually entering a new plate const plateScore = text === "" ? undefined : 1.0; axios .post(`${apiHost}api/events/${search.id}/recognized_license_plate`, { recognizedLicensePlate: text, recognizedLicensePlateScore: plateScore, }) .then((response) => { if (response.status === 200) { toast.success(t("details.item.toast.success.updatedLPR"), { position: "top-center", }); mutate( (key) => typeof key === "string" && (key.includes("events") || key.includes("events/search") || key.includes("events/explore")), (currentData: SearchResult[][] | SearchResult[] | undefined) => { if (!currentData) return currentData; return currentData.flat().map((event) => event.id === search.id ? { ...event, data: { ...event.data, recognized_license_plate: text, recognized_license_plate_score: plateScore, }, } : event, ); }, { optimisticData: true, rollbackOnError: true, revalidate: false, }, ); setSearch({ ...search, data: { ...search.data, recognized_license_plate: text, recognized_license_plate_score: plateScore, }, }); setIsLPRDialogOpen(false); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("details.item.toast.error.updatedLPRFailed", { errorMessage, }), { position: "top-center", }, ); }); }, [search, apiHost, mutate, setSearch, t], ); // face training const hasFace = useMemo(() => { if (!config?.face_recognition.enabled || !search) { return false; } return search.data.attributes?.find((attr) => attr.label == "face"); }, [config, search]); const { data: faceData } = useSWR(hasFace ? "faces" : null); const faceNames = useMemo( () => faceData ? Object.keys(faceData).filter((face) => face != "train") : [], [faceData], ); const onTrainFace = useCallback( (trainName: string) => { axios .post(`/faces/train/${trainName}/classify`, { event_id: search.id }) .then((resp) => { if (resp.status == 200) { toast.success( t("toast.success.trainedFace", { ns: "views/faceLibrary" }), { position: "top-center", }, ); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("toast.error.trainFailed", { ns: "views/faceLibrary", errorMessage, }), { position: "top-center", }, ); }); }, [search, t], ); return (
{t("details.label")}
{getIconForLabel(search.label, "size-4 text-primary")} {t(search.label, { ns: "objects", })} {search.sub_label && ` (${search.sub_label})`} {isAdmin && ( { setIsSubLabelDialogOpen(true); }} /> {t("details.editSubLabel.title")} )}
{search?.data.recognized_license_plate && (
{t("details.recognizedLicensePlate")}
{search.data.recognized_license_plate}{" "} {recognizedLicensePlateScore && ` (${recognizedLicensePlateScore}%)`} {isAdmin && ( { setIsLPRDialogOpen(true); }} /> {t("details.editLPR.title")} )}
)}
{t("details.topScore.label")}
Info
{t("details.topScore.info")}
{topScore}%{subLabelScore && ` (${subLabelScore}%)`}
{snapScore != undefined && (
{t("details.snapshotScore.label")}
{snapScore}%
)} {averageEstimatedSpeed && (
{t("details.estimatedSpeed")}
{averageEstimatedSpeed && (
{averageEstimatedSpeed}{" "} {config?.ui.unit_system == "imperial" ? t("unit.speed.mph", { ns: "common" }) : t("unit.speed.kph", { ns: "common" })}{" "} {velocityAngle != undefined && ( )}
)}
)}
{t("details.camera")}
{search.camera.replaceAll("_", " ")}
{t("details.timestamp")}
{formattedDate}
{config?.semantic_search.enabled && setSimilarity != undefined && search.data.type == "object" && ( )} {hasFace && ( )}
{config?.cameras[search.camera].genai.enabled && !search.end_time && (config.cameras[search.camera].genai.required_zones.length === 0 || search.zones.some((zone) => config.cameras[search.camera].genai.required_zones.includes(zone), )) && (config.cameras[search.camera].genai.objects.length === 0 || config.cameras[search.camera].genai.objects.includes( search.label, )) ? ( <>
{t("details.description.label")}
{t("details.description.aiTips")}
) : ( <>