move actions menu to its own component

This commit is contained in:
Josh Hawkins 2025-11-05 18:07:53 -06:00
parent c9d20fa1ee
commit 50e139f419
3 changed files with 164 additions and 141 deletions

View File

@ -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<ReviewSegment>([
`review/event/${search.id}`,
]);
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger>
<div className="rounded p-1 pr-2" role="button">
<HiDotsHorizontal className="size-4 text-muted-foreground" />
</div>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<a
className="w-full"
href={`${baseUrl}api/events/${search.id}/snapshot.jpg?bbox=1`}
download={`${search.camera}_${search.label}.jpg`}
>
<div className="flex cursor-pointer items-center gap-2">
<span>{t("itemMenu.downloadSnapshot.label")}</span>
</div>
</a>
</DropdownMenuItem>
<DropdownMenuItem>
<a
className="w-full"
href={`${baseUrl}api/${search.camera}/${clipTimeRange}/clip.mp4`}
download
>
<div className="flex cursor-pointer items-center gap-2">
<span>{t("itemMenu.downloadVideo.label")}</span>
</div>
</a>
</DropdownMenuItem>
{config?.semantic_search.enabled &&
setSimilarity != undefined &&
search.data?.type == "object" && (
<DropdownMenuItem
onClick={() => {
setIsOpen(false);
setTimeout(() => {
setSearch?.(undefined);
setSimilarity?.();
}, 0);
}}
>
<div className="flex cursor-pointer items-center gap-2">
<span>{t("itemMenu.findSimilar.label")}</span>
</div>
</DropdownMenuItem>
)}
{reviewItem && reviewItem.id && (
<DropdownMenuItem
onClick={() => {
setIsOpen(false);
setTimeout(() => {
navigate(`/review?id=${reviewItem.id}`);
}, 0);
}}
>
<div className="flex cursor-pointer items-center gap-2">
<span>{t("itemMenu.viewInHistory.label")}</span>
</div>
</DropdownMenuItem>
)}
{hasFace && onTrainFace && (
<DropdownMenuItem asChild>
<FaceSelectionDialog
faceNames={faceNames}
onTrainAttempt={onTrainFace}
>
<div className="flex cursor-pointer items-center gap-2">
<span>{t("trainFace", { ns: "views/faceLibrary" })}</span>
</div>
</FaceSelectionDialog>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
);
}

View File

@ -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={
<DetailActionsMenu
search={search}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
/>
}
/>
) : (
<div className="flex h-full gap-4 overflow-hidden">
@ -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<ReviewSegment>([
`review/event/${search.id}`,
]);
const navigate = useNavigate();
const onTrainFace = useCallback(
(trainName: string) => {
axios
@ -965,80 +962,15 @@ function ObjectDetailsTab({
<div className="flex items-center justify-between">
<div className="flex-1">{tabs}</div>
<div className="ml-2">
<DropdownMenu>
<DropdownMenuTrigger>
<div className="rounded p-1 pr-2" role="button">
<HiDotsHorizontal className="size-4 text-muted-foreground" />
</div>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<a
className="w-full"
href={`${baseUrl}api/events/${search.id}/snapshot.jpg?bbox=1`}
download={`${search.camera}_${search.label}.jpg`}
>
<div className="flex cursor-pointer items-center gap-2">
<span>{t("itemMenu.downloadSnapshot.label")}</span>
</div>
</a>
</DropdownMenuItem>
<DropdownMenuItem>
<a
className="w-full"
href={`${baseUrl}api/${search.camera}/${clipTimeRange}/clip.mp4`}
download
>
<div className="flex cursor-pointer items-center gap-2">
<span>{t("itemMenu.downloadVideo.label")}</span>
</div>
</a>
</DropdownMenuItem>
{config?.semantic_search.enabled &&
setSimilarity != undefined &&
search.data.type == "object" && (
<DropdownMenuItem
onClick={() => {
setSearch(undefined);
setSimilarity();
}}
>
<div className="flex cursor-pointer items-center gap-2">
<span>{t("itemMenu.findSimilar.label")}</span>
</div>
</DropdownMenuItem>
)}
{reviewItem && reviewItem.id && (
<DropdownMenuItem
onClick={() => {
navigate(`/review?id=${reviewItem.id}`);
}}
>
<div className="flex cursor-pointer items-center gap-2">
<span>{t("itemMenu.viewInHistory.label")}</span>
</div>
</DropdownMenuItem>
)}
{hasFace && (
<DropdownMenuItem asChild>
<FaceSelectionDialog
faceNames={faceNames}
onTrainAttempt={onTrainFace}
>
<div className="flex cursor-pointer items-center gap-2">
<span>
{t("trainFace", { ns: "views/faceLibrary" })}
</span>
</div>
</FaceSelectionDialog>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
<DetailActionsMenu
search={search}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
faceNames={faceNames}
onTrainFace={onTrainFace}
hasFace={!!hasFace}
/>
</div>
</div>
)}

View File

@ -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<HTMLVideoElement | null>(null);
const { t } = useTranslation(["views/explore"]);
const navigate = useNavigate();
const apiHost = useApiHost();
const imgRef = useRef<HTMLImageElement | null>(null);
const [imgLoaded, setImgLoaded] = useState(false);
@ -451,59 +450,16 @@ export function TrackingDetails({
</div>
</>
)}
<div
className={cn(
"absolute top-2 z-[5] flex items-center gap-2",
isIOS ? "right-8" : "right-2",
)}
>
{event && (
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
if (event?.id) {
const params = new URLSearchParams({
id: event.id,
}).toString();
navigate(`/review?${params}`);
}
}}
>
<FaHistory className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("itemMenu.viewInHistory.label")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<a
download
href={`${baseUrl}api/${event.camera}/start/${event.start_time - REVIEW_PADDING}/end/${(event.end_time ?? Date.now() / 1000) + REVIEW_PADDING}/clip.mp4`}
>
<Chip className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500">
<FaDownload className="size-4 text-white" />
</Chip>
</a>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("button.download", { ns: "common" })}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</div>
</div>
<div className={cn(isDesktop && "flex-[2] overflow-hidden")}>
{isDesktop && tabs && <div className="mb-4">{tabs}</div>}
{isDesktop && (tabs || actions) && (
<div className="mb-4 flex items-center justify-between">
<div className="flex-1">{tabs}</div>
<div className="ml-2">{actions}</div>
</div>
)}
<div
className={cn(
isDesktop && "scrollbar-container h-full overflow-y-auto",