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 { FaCheckCircle, FaChevronDown, 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"; 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; }; export default function SearchDetailDialog({ search, page, setSearch, setSearchPage, setSimilarity, }: SearchDetailDialogProps) { 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); useEffect(() => { 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.data.type != "object") { const index = views.indexOf("object lifecycle"); views.splice(index, 1); } // TODO implement //if (!config.semantic_search.enabled) { // const index = views.indexOf("similar-calendar"); // 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 ( { if (!open) { setSearch(undefined); } }} >
setIsOpen(false)}> Tracked Object Details Tracked object details
{ if (value) { setPageToggle(value); } }} > {Object.values(searchTabs).map((item) => ( {item == "details" && } {item == "snapshot" && } {item == "video" && } {item == "object lifecycle" && ( )}
{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; }; function ObjectDetailsTab({ search, config, setSearch, setSimilarity, }: ObjectDetailsTabProps) { const apiHost = useApiHost(); // mutation / revalidation const mutate = useGlobalMutation(); // data const [desc, setDesc] = useState(search?.data.description); // 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" ? "%b %-d %Y, %H:%M" : "%b %-d %Y, %I:%M %p", config?.ui.timezone, ); const score = useMemo(() => { if (!search) { return 0; } const value = search.score ?? search.data.top_score; return Math.round(value * 100); }, [search]); const subLabelScore = useMemo(() => { if (!search) { return undefined; } if (search.sub_label) { return Math.round((search.data?.top_score ?? 0) * 100); } 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("Successfully saved description", { position: "top-center", }); } mutate( (key) => typeof key === "string" && (key.includes("events") || key.includes("events/search") || key.includes("events/explore")), ); }) .catch(() => { toast.error("Failed to update the description", { position: "top-center", }); setDesc(search.data.description); }); }, [desc, search, mutate]); 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( `A new description has been requested from ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")}. Depending on the speed of your provider, the new description may take some time to regenerate.`, { position: "top-center", duration: 7000, }, ); } }) .catch(() => { toast.error( `Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description`, { position: "top-center", }, ); }); }, [search, config], ); return (
Label
{getIconForLabel(search.label, "size-4 text-primary")} {search.label} {search.sub_label && ` (${search.sub_label})`}
Score
{score}%{subLabelScore && ` (${subLabelScore}%)`}
Camera
{search.camera.replaceAll("_", " ")}
Timestamp
{formattedDate}
{config?.semantic_search.enabled && ( )}
Description