mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-07 22:05:44 +03:00
move actions menu to its own component
This commit is contained in:
parent
c9d20fa1ee
commit
50e139f419
135
web/src/components/overlay/detail/DetailActionsMenu.tsx
Normal file
135
web/src/components/overlay/detail/DetailActionsMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user