diff --git a/web/src/components/overlay/detail/DetailActionsMenu.tsx b/web/src/components/overlay/detail/DetailActionsMenu.tsx new file mode 100644 index 000000000..32eb53532 --- /dev/null +++ b/web/src/components/overlay/detail/DetailActionsMenu.tsx @@ -0,0 +1,135 @@ +import { useMemo, useState } from "react"; +import { Event } from "@/types/event"; +import { baseUrl } from "@/api/baseUrl"; +import { ReviewSegment, REVIEW_PADDING } from "@/types/review"; +import useSWR from "swr"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuPortal, +} from "@/components/ui/dropdown-menu"; +import { HiDotsHorizontal } from "react-icons/hi"; +import FaceSelectionDialog from "../FaceSelectionDialog"; +import { SearchResult } from "@/types/search"; +import { FrigateConfig } from "@/types/frigateConfig"; + +type Props = { + search: SearchResult | Event; + config?: FrigateConfig; + setSearch?: (s: SearchResult | undefined) => void; + setSimilarity?: () => void; + faceNames?: string[]; + onTrainFace?: (name: string) => void; + hasFace?: boolean; +}; + +export default function DetailActionsMenu({ + search, + config, + setSearch, + setSimilarity, + faceNames = [], + onTrainFace, + hasFace = false, +}: Props) { + const { t } = useTranslation(["views/explore", "views/faceLibrary"]); + const navigate = useNavigate(); + const [isOpen, setIsOpen] = useState(false); + + const clipTimeRange = useMemo(() => { + const startTime = (search.start_time ?? 0) - REVIEW_PADDING; + const endTime = (search.end_time ?? Date.now() / 1000) + REVIEW_PADDING; + return `start/${startTime}/end/${endTime}`; + }, [search]); + + const { data: reviewItem } = useSWR([ + `review/event/${search.id}`, + ]); + + return ( + + +
+ +
+
+ + + + +
+ {t("itemMenu.downloadSnapshot.label")} +
+
+
+ + + +
+ {t("itemMenu.downloadVideo.label")} +
+
+
+ + {config?.semantic_search.enabled && + setSimilarity != undefined && + search.data?.type == "object" && ( + { + setIsOpen(false); + setTimeout(() => { + setSearch?.(undefined); + setSimilarity?.(); + }, 0); + }} + > +
+ {t("itemMenu.findSimilar.label")} +
+
+ )} + + {reviewItem && reviewItem.id && ( + { + setIsOpen(false); + setTimeout(() => { + navigate(`/review?id=${reviewItem.id}`); + }, 0); + }} + > +
+ {t("itemMenu.viewInHistory.label")} +
+
+ )} + + {hasFace && onTrainFace && ( + + +
+ {t("trainFace", { ns: "views/faceLibrary" })} +
+
+
+ )} +
+
+
+ ); +} diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index f195230a2..57880ebcc 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -45,18 +45,16 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { REVIEW_PADDING, ReviewSegment } from "@/types/review"; -import { useNavigate } from "react-router-dom"; +import { REVIEW_PADDING } from "@/types/review"; // Chip removed from VideoTab - kept import commented out previously import { capitalizeAll } from "@/utils/stringUtil"; import useGlobalMutation from "@/hooks/use-global-mutate"; -import { HiDotsHorizontal } from "react-icons/hi"; +import DetailActionsMenu from "./DetailActionsMenu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - DropdownMenuPortal, } from "@/components/ui/dropdown-menu"; import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; import useImageLoaded from "@/hooks/use-image-loaded"; @@ -73,7 +71,7 @@ import { FaPencilAlt } from "react-icons/fa"; import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog"; import { Trans, useTranslation } from "react-i18next"; import { useIsAdmin } from "@/hooks/use-is-admin"; -import FaceSelectionDialog from "../FaceSelectionDialog"; +// FaceSelectionDialog moved into DetailActionsMenu import { getTranslatedLabel } from "@/utils/i18n"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; import Heading from "@/components/ui/heading"; @@ -304,6 +302,14 @@ export default function SearchDetailDialog({ className="size-full" event={search as unknown as Event} tabs={tabsComponent} + actions={ + + } /> ) : (
@@ -583,11 +589,7 @@ function ObjectDetailsTab({ } }, [search]); - const clipTimeRange = useMemo(() => { - const startTime = (search.start_time ?? 0) - REVIEW_PADDING; - const endTime = (search.end_time ?? Date.now() / 1000) + REVIEW_PADDING; - return `start/${startTime}/end/${endTime}`; - }, [search]); + // clipTimeRange is calculated inside the shared DetailActionsMenu const updateDescription = useCallback(() => { if (!search) { @@ -859,11 +861,6 @@ function ObjectDetailsTab({ [faceData], ); - const { data: reviewItem } = useSWR([ - `review/event/${search.id}`, - ]); - const navigate = useNavigate(); - const onTrainFace = useCallback( (trainName: string) => { axios @@ -965,80 +962,15 @@ function ObjectDetailsTab({
{tabs}
- - -
- -
-
- - - - -
- {t("itemMenu.downloadSnapshot.label")} -
-
-
- - - -
- {t("itemMenu.downloadVideo.label")} -
-
-
- {config?.semantic_search.enabled && - setSimilarity != undefined && - search.data.type == "object" && ( - { - setSearch(undefined); - setSimilarity(); - }} - > -
- {t("itemMenu.findSimilar.label")} -
-
- )} - {reviewItem && reviewItem.id && ( - { - navigate(`/review?id=${reviewItem.id}`); - }} - > -
- {t("itemMenu.viewInHistory.label")} -
-
- )} - - {hasFace && ( - - -
- - {t("trainFace", { ns: "views/faceLibrary" })} - -
-
-
- )} -
-
-
+
)} diff --git a/web/src/components/overlay/detail/TrackingDetails.tsx b/web/src/components/overlay/detail/TrackingDetails.tsx index 78b340fd4..44bd5afa0 100644 --- a/web/src/components/overlay/detail/TrackingDetails.tsx +++ b/web/src/components/overlay/detail/TrackingDetails.tsx @@ -37,8 +37,6 @@ import axios from "axios"; import { toast } from "sonner"; import { useDetailStream } from "@/context/detail-stream-context"; import { isDesktop, isIOS, isMobileOnly, isSafari } from "react-device-detect"; -import Chip from "@/components/indicators/Chip"; -import { FaDownload, FaHistory } from "react-icons/fa"; import { useApiHost } from "@/api"; import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator"; import ObjectTrackOverlay from "../ObjectTrackOverlay"; @@ -48,16 +46,17 @@ type TrackingDetailsProps = { event: Event; fullscreen?: boolean; tabs?: React.ReactNode; + actions?: React.ReactNode; }; export function TrackingDetails({ className, event, tabs, + actions, }: TrackingDetailsProps) { const videoRef = useRef(null); const { t } = useTranslation(["views/explore"]); - const navigate = useNavigate(); const apiHost = useApiHost(); const imgRef = useRef(null); const [imgLoaded, setImgLoaded] = useState(false); @@ -451,59 +450,16 @@ export function TrackingDetails({
)} -
- {event && ( - - - { - if (event?.id) { - const params = new URLSearchParams({ - id: event.id, - }).toString(); - navigate(`/review?${params}`); - } - }} - > - - - - - - {t("itemMenu.viewInHistory.label")} - - - - )} - - - - - - - - - - - {t("button.download", { ns: "common" })} - - - -
- {isDesktop && tabs &&
{tabs}
} + {isDesktop && (tabs || actions) && ( +
+
{tabs}
+
{actions}
+
+ )}