mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 13:34:13 +03:00
Tracked Object Details pane tweaks (#20830)
* add prev/next buttons on desktop * buttons should work with summary and grid view * i18n * small tweaks * don't change dialog size * remove heading and count * remove icons * spacing * two column detail view * add actions to dots menu * move actions menu to its own component * set modal to false on face library dropdown to guard against improper closures https://github.com/shadcn-ui/ui/discussions/6908 * frigate plus layout * remove face training * clean up unused * refactor to remove duplication between mobile and desktop * turn annotation settings into a popover * fix popover * improve annotation offset popver * change icon and popover text in detail stream for annotation settings * clean up * use drawer on mobile * fix setter function * use dialog ref for popover portal * don't portal popover * tweaks * add button type * lower xl max width * fixes * justify
This commit is contained in:
parent
32f1d85a6f
commit
945317b44e
@ -74,7 +74,7 @@
|
|||||||
"label": "Annotation Offset",
|
"label": "Annotation Offset",
|
||||||
"desc": "This data comes from your camera's detect feed but is overlayed on images from the the record feed. It is unlikely that the two streams are perfectly in sync. As a result, the bounding box and the footage will not line up perfectly. You can use this setting to offset the annotations forward or backward in time to better align them with the recorded footage.",
|
"desc": "This data comes from your camera's detect feed but is overlayed on images from the the record feed. It is unlikely that the two streams are perfectly in sync. As a result, the bounding box and the footage will not line up perfectly. You can use this setting to offset the annotations forward or backward in time to better align them with the recorded footage.",
|
||||||
"millisecondsToOffset": "Milliseconds to offset detect annotations by. <em>Default: 0</em>",
|
"millisecondsToOffset": "Milliseconds to offset detect annotations by. <em>Default: 0</em>",
|
||||||
"tips": "TIP: Imagine there is an event clip with a person walking from left to right. If the event timeline bounding box is consistently to the left of the person then the value should be decreased. Similarly, if a person is walking from left to right and the bounding box is consistently ahead of the person then the value should be increased.",
|
"tips": "Lower the value if the video playback is ahead of the boxes and path points, and increase the value if the video playback is behind them. This value can be negative.",
|
||||||
"toast": {
|
"toast": {
|
||||||
"success": "Annotation offset for {{camera}} has been saved to the config file. Restart Frigate to apply your changes."
|
"success": "Annotation offset for {{camera}} has been saved to the config file. Restart Frigate to apply your changes."
|
||||||
}
|
}
|
||||||
@ -215,6 +215,8 @@
|
|||||||
"trackedObjectsCount_other": "{{count}} tracked objects ",
|
"trackedObjectsCount_other": "{{count}} tracked objects ",
|
||||||
"searchResult": {
|
"searchResult": {
|
||||||
"tooltip": "Matched {{type}} at {{confidence}}%",
|
"tooltip": "Matched {{type}} at {{confidence}}%",
|
||||||
|
"previousTrackedObject": "Previous tracked object",
|
||||||
|
"nextTrackedObject": "Next tracked object",
|
||||||
"deleteTrackedObject": {
|
"deleteTrackedObject": {
|
||||||
"toast": {
|
"toast": {
|
||||||
"success": "Tracked object deleted successfully.",
|
"success": "Tracked object deleted successfully.",
|
||||||
|
|||||||
@ -121,13 +121,13 @@ export default function AnnotationOffsetSlider({ className }: Props) {
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
className="focus:outline-none"
|
className="focus:outline-none"
|
||||||
aria-label={t("trackingDetails.annotationSettings.offset.desc")}
|
aria-label={t("trackingDetails.annotationSettings.offset.tips")}
|
||||||
>
|
>
|
||||||
<LuInfo className="size-4" />
|
<LuInfo className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-80 text-sm">
|
<PopoverContent className="w-80 text-sm">
|
||||||
{t("trackingDetails.annotationSettings.offset.desc")}
|
{t("trackingDetails.annotationSettings.offset.tips")}
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,3 @@
|
|||||||
import Heading from "@/components/ui/heading";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@ -8,7 +5,6 @@ import axios from "axios";
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { LuExternalLink } from "react-icons/lu";
|
import { LuExternalLink } from "react-icons/lu";
|
||||||
import { PiWarningCircle } from "react-icons/pi";
|
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
@ -31,15 +27,11 @@ import { useDocDomain } from "@/hooks/use-doc-domain";
|
|||||||
|
|
||||||
type AnnotationSettingsPaneProps = {
|
type AnnotationSettingsPaneProps = {
|
||||||
event: Event;
|
event: Event;
|
||||||
showZones: boolean;
|
|
||||||
setShowZones: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
annotationOffset: number;
|
annotationOffset: number;
|
||||||
setAnnotationOffset: React.Dispatch<React.SetStateAction<number>>;
|
setAnnotationOffset: React.Dispatch<React.SetStateAction<number>>;
|
||||||
};
|
};
|
||||||
export function AnnotationSettingsPane({
|
export function AnnotationSettingsPane({
|
||||||
event,
|
event,
|
||||||
showZones,
|
|
||||||
setShowZones,
|
|
||||||
annotationOffset,
|
annotationOffset,
|
||||||
setAnnotationOffset,
|
setAnnotationOffset,
|
||||||
}: AnnotationSettingsPaneProps) {
|
}: AnnotationSettingsPaneProps) {
|
||||||
@ -140,26 +132,12 @@ export function AnnotationSettingsPane({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-3 space-y-3 rounded-lg border border-secondary-foreground bg-background_alt p-2">
|
<div className="p-4">
|
||||||
<Heading as="h4" className="my-2">
|
<div className="text-md mb-2">
|
||||||
{t("trackingDetails.annotationSettings.title")}
|
{t("trackingDetails.annotationSettings.title")}
|
||||||
</Heading>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex flex-row items-center justify-start gap-2 p-3">
|
|
||||||
<Switch
|
|
||||||
id="show-zones"
|
|
||||||
checked={showZones}
|
|
||||||
onCheckedChange={setShowZones}
|
|
||||||
/>
|
|
||||||
<Label className="cursor-pointer" htmlFor="show-zones">
|
|
||||||
{t("trackingDetails.annotationSettings.showAllZones.title")}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{t("trackingDetails.annotationSettings.showAllZones.desc")}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-2 flex bg-secondary" />
|
|
||||||
|
<Separator className="mb-4 flex bg-secondary" />
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
@ -169,17 +147,18 @@ export function AnnotationSettingsPane({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="annotationOffset"
|
name="annotationOffset"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem className="flex flex-row items-start justify-between space-x-2">
|
||||||
<FormLabel>
|
<div className="flex flex-col gap-1">
|
||||||
{t("trackingDetails.annotationSettings.offset.label")}
|
<FormLabel>
|
||||||
</FormLabel>
|
{t("trackingDetails.annotationSettings.offset.label")}
|
||||||
<div className="flex flex-col gap-3 md:flex-row-reverse md:gap-8">
|
</FormLabel>
|
||||||
<div className="flex flex-row items-center gap-3 rounded-lg bg-destructive/50 p-3 text-sm text-primary-variant md:my-5">
|
<FormDescription>
|
||||||
<PiWarningCircle className="size-24" />
|
<Trans ns="views/explore">
|
||||||
<div>
|
trackingDetails.annotationSettings.offset.millisecondsToOffset
|
||||||
<Trans ns="views/explore">
|
</Trans>
|
||||||
trackingDetails.annotationSettings.offset.desc
|
<FormMessage />
|
||||||
</Trans>
|
<div className="mt-2">
|
||||||
|
{t("trackingDetails.annotationSettings.offset.tips")}
|
||||||
<div className="mt-2 flex items-center text-primary">
|
<div className="mt-2 flex items-center text-primary">
|
||||||
<Link
|
<Link
|
||||||
to={getLocaleDocUrl("configuration/reference")}
|
to={getLocaleDocUrl("configuration/reference")}
|
||||||
@ -192,26 +171,19 @@ export function AnnotationSettingsPane({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FormDescription>
|
||||||
<div className="flex flex-col">
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="min-w-24">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
className="text-md w-full border border-input bg-background p-2 text-center hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
|
||||||
<Trans ns="views/explore">
|
|
||||||
trackingDetails.annotationSettings.offset.millisecondsToOffset
|
|
||||||
</Trans>
|
|
||||||
<div className="mt-2">
|
|
||||||
{t("trackingDetails.annotationSettings.offset.tips")}
|
|
||||||
</div>
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -220,7 +192,9 @@ export function AnnotationSettingsPane({
|
|||||||
<div className="flex flex-row gap-2 pt-5">
|
<div className="flex flex-row gap-2 pt-5">
|
||||||
<Button
|
<Button
|
||||||
className="flex flex-1"
|
className="flex flex-1"
|
||||||
|
variant="default"
|
||||||
aria-label={t("button.apply", { ns: "common" })}
|
aria-label={t("button.apply", { ns: "common" })}
|
||||||
|
type="button"
|
||||||
onClick={form.handleSubmit(onApply)}
|
onClick={form.handleSubmit(onApply)}
|
||||||
>
|
>
|
||||||
{t("button.apply", { ns: "common" })}
|
{t("button.apply", { ns: "common" })}
|
||||||
|
|||||||
118
web/src/components/overlay/detail/DetailActionsMenu.tsx
Normal file
118
web/src/components/overlay/detail/DetailActionsMenu.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
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 { 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,
|
||||||
|
}: 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" 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>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -2,21 +2,12 @@ import useSWR from "swr";
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { TrackingDetailsSequence } from "@/types/timeline";
|
import { TrackingDetailsSequence } from "@/types/timeline";
|
||||||
import Heading from "@/components/ui/heading";
|
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
import { getIconForLabel } from "@/utils/iconUtil";
|
import { getIconForLabel } from "@/utils/iconUtil";
|
||||||
import { LuCircle, LuFolderX, LuSettings } from "react-icons/lu";
|
import { LuCircle, LuFolderX } from "react-icons/lu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { AnnotationSettingsPane } from "./AnnotationSettingsPane";
|
|
||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
|
||||||
import HlsVideoPlayer from "@/components/player/HlsVideoPlayer";
|
import HlsVideoPlayer from "@/components/player/HlsVideoPlayer";
|
||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { REVIEW_PADDING } from "@/types/review";
|
import { REVIEW_PADDING } from "@/types/review";
|
||||||
@ -38,8 +29,6 @@ import axios from "axios";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useDetailStream } from "@/context/detail-stream-context";
|
import { useDetailStream } from "@/context/detail-stream-context";
|
||||||
import { isDesktop, isIOS, isMobileOnly, isSafari } from "react-device-detect";
|
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 { useApiHost } from "@/api";
|
||||||
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
|
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
|
||||||
import ObjectTrackOverlay from "../ObjectTrackOverlay";
|
import ObjectTrackOverlay from "../ObjectTrackOverlay";
|
||||||
@ -58,15 +47,13 @@ export function TrackingDetails({
|
|||||||
}: TrackingDetailsProps) {
|
}: TrackingDetailsProps) {
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const { t } = useTranslation(["views/explore"]);
|
const { t } = useTranslation(["views/explore"]);
|
||||||
const navigate = useNavigate();
|
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||||
const [imgLoaded, setImgLoaded] = useState(false);
|
const [imgLoaded, setImgLoaded] = useState(false);
|
||||||
const [displaySource, _setDisplaySource] = useState<"video" | "image">(
|
const [displaySource, _setDisplaySource] = useState<"video" | "image">(
|
||||||
"video",
|
"video",
|
||||||
);
|
);
|
||||||
const { setSelectedObjectIds, annotationOffset, setAnnotationOffset } =
|
const { setSelectedObjectIds, annotationOffset } = useDetailStream();
|
||||||
useDetailStream();
|
|
||||||
|
|
||||||
// manualOverride holds a record-stream timestamp explicitly chosen by the
|
// manualOverride holds a record-stream timestamp explicitly chosen by the
|
||||||
// user (eg, clicking a lifecycle row). When null we display `currentTime`.
|
// user (eg, clicking a lifecycle row). When null we display `currentTime`.
|
||||||
@ -97,8 +84,6 @@ export function TrackingDetails({
|
|||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [_selectedZone, setSelectedZone] = useState("");
|
const [_selectedZone, setSelectedZone] = useState("");
|
||||||
const [_lifecycleZones, setLifecycleZones] = useState<string[]>([]);
|
const [_lifecycleZones, setLifecycleZones] = useState<string[]>([]);
|
||||||
const [showControls, setShowControls] = useState(false);
|
|
||||||
const [showZones, setShowZones] = useState(true);
|
|
||||||
const [seekToTimestamp, setSeekToTimestamp] = useState<number | null>(null);
|
const [seekToTimestamp, setSeekToTimestamp] = useState<number | null>(null);
|
||||||
|
|
||||||
const aspectRatio = useMemo(() => {
|
const aspectRatio = useMemo(() => {
|
||||||
@ -359,7 +344,7 @@ export function TrackingDetails({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
isDesktop
|
isDesktop
|
||||||
? "flex size-full gap-4 overflow-hidden"
|
? "flex size-full justify-evenly gap-4 overflow-hidden"
|
||||||
: "flex size-full flex-col gap-2",
|
: "flex size-full flex-col gap-2",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@ -452,128 +437,34 @@ export function TrackingDetails({
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div className={cn(isDesktop && "flex-[2] overflow-hidden")}>
|
<div
|
||||||
{isDesktop && tabs && <div className="mb-4">{tabs}</div>}
|
className={cn(
|
||||||
|
isDesktop && "justify-between overflow-hidden md:basis-2/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isDesktop && tabs && (
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="flex-1">{tabs}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
isDesktop && "scrollbar-container h-full overflow-y-auto",
|
isDesktop && "scrollbar-container h-full overflow-y-auto",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-between">
|
|
||||||
<Heading as="h4">{t("trackingDetails.title")}</Heading>
|
|
||||||
|
|
||||||
<div className="flex flex-row gap-2">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={showControls ? "select" : "default"}
|
|
||||||
className="size-7 p-1.5"
|
|
||||||
aria-label={t("trackingDetails.adjustAnnotationSettings")}
|
|
||||||
>
|
|
||||||
<LuSettings
|
|
||||||
className="size-5"
|
|
||||||
onClick={() => setShowControls(!showControls)}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipPortal>
|
|
||||||
<TooltipContent>
|
|
||||||
{t("trackingDetails.adjustAnnotationSettings")}
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPortal>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row items-center justify-between">
|
|
||||||
<div className="mb-2 text-sm text-muted-foreground">
|
|
||||||
{t("trackingDetails.scrollViewTips")}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-20 text-right text-sm text-muted-foreground">
|
|
||||||
{t("trackingDetails.count", {
|
|
||||||
first: eventSequence?.length ?? 0,
|
|
||||||
second: eventSequence?.length ?? 0,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{config?.cameras[event.camera]?.onvif.autotracking
|
{config?.cameras[event.camera]?.onvif.autotracking
|
||||||
.enabled_in_config && (
|
.enabled_in_config && (
|
||||||
<div className="-mt-2 mb-2 text-sm text-danger">
|
<div className="mb-2 text-sm text-danger">
|
||||||
{t("trackingDetails.autoTrackingTips")}
|
{t("trackingDetails.autoTrackingTips")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showControls && (
|
|
||||||
<AnnotationSettingsPane
|
|
||||||
event={event}
|
|
||||||
showZones={showZones}
|
|
||||||
setShowZones={setShowZones}
|
|
||||||
annotationOffset={annotationOffset}
|
|
||||||
setAnnotationOffset={(value) => {
|
|
||||||
if (typeof value === "function") {
|
|
||||||
const newValue = value(annotationOffset);
|
|
||||||
setAnnotationOffset(newValue);
|
|
||||||
} else {
|
|
||||||
setAnnotationOffset(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn("rounded-md bg-background_alt px-0 py-3 md:px-2")}
|
||||||
"rounded-md bg-secondary p-3 outline outline-[3px] -outline-offset-[2.8px] outline-transparent duration-500",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
<div
|
<div
|
||||||
@ -599,7 +490,7 @@ export function TrackingDetails({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="capitalize">{label}</span>
|
<span className="capitalize">{label}</span>
|
||||||
<span className="text-secondary-foreground">
|
<span className="md:text-md text-xs text-secondary-foreground">
|
||||||
{formattedStart ?? ""} - {formattedEnd ?? ""}
|
{formattedStart ?? ""} - {formattedEnd ?? ""}
|
||||||
</span>
|
</span>
|
||||||
{event.data?.recognized_license_plate && (
|
{event.data?.recognized_license_plate && (
|
||||||
|
|||||||
@ -16,13 +16,7 @@ import ActivityIndicator from "../indicators/activity-indicator";
|
|||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
import { getIconForLabel } from "@/utils/iconUtil";
|
import { getIconForLabel } from "@/utils/iconUtil";
|
||||||
import { ReviewSegment } from "@/types/review";
|
import { ReviewSegment } from "@/types/review";
|
||||||
import {
|
import { LuChevronDown, LuCircle, LuChevronRight } from "react-icons/lu";
|
||||||
LuChevronDown,
|
|
||||||
LuCircle,
|
|
||||||
LuChevronRight,
|
|
||||||
LuSettings,
|
|
||||||
} from "react-icons/lu";
|
|
||||||
import { MdAutoAwesome } from "react-icons/md";
|
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
import EventMenu from "@/components/timeline/EventMenu";
|
import EventMenu from "@/components/timeline/EventMenu";
|
||||||
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
|
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
|
||||||
@ -32,6 +26,8 @@ import { Link } from "react-router-dom";
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { usePersistence } from "@/hooks/use-persistence";
|
import { usePersistence } from "@/hooks/use-persistence";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
|
import { PiSlidersHorizontalBold } from "react-icons/pi";
|
||||||
|
import { MdAutoAwesome } from "react-icons/md";
|
||||||
|
|
||||||
type DetailStreamProps = {
|
type DetailStreamProps = {
|
||||||
reviewItems?: ReviewSegment[];
|
reviewItems?: ReviewSegment[];
|
||||||
@ -237,7 +233,7 @@ export default function DetailStream({
|
|||||||
className="flex w-full items-center justify-between p-3"
|
className="flex w-full items-center justify-between p-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
<LuSettings className="size-4" />
|
<PiSlidersHorizontalBold className="size-4" />
|
||||||
<span>{t("detail.settings")}</span>
|
<span>{t("detail.settings")}</span>
|
||||||
</div>
|
</div>
|
||||||
{controlsExpanded ? (
|
{controlsExpanded ? (
|
||||||
|
|||||||
@ -11,13 +11,21 @@ const PopoverContent = React.forwardRef<
|
|||||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
|
||||||
container?: HTMLElement | null;
|
container?: HTMLElement | null;
|
||||||
|
disablePortal?: boolean;
|
||||||
}
|
}
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
{ className, container, align = "center", sideOffset = 4, ...props },
|
{
|
||||||
|
className,
|
||||||
|
container,
|
||||||
|
disablePortal = false,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
},
|
||||||
ref,
|
ref,
|
||||||
) => (
|
) => {
|
||||||
<PopoverPrimitive.Portal container={container}>
|
const content = (
|
||||||
<PopoverPrimitive.Content
|
<PopoverPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
align={align}
|
align={align}
|
||||||
@ -28,8 +36,18 @@ const PopoverContent = React.forwardRef<
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</PopoverPrimitive.Portal>
|
);
|
||||||
),
|
|
||||||
|
if (disablePortal) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal container={container}>
|
||||||
|
{content}
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ export interface DetailStreamContextType {
|
|||||||
camera: string;
|
camera: string;
|
||||||
annotationOffset: number; // milliseconds
|
annotationOffset: number; // milliseconds
|
||||||
setSelectedObjectIds: React.Dispatch<React.SetStateAction<string[]>>;
|
setSelectedObjectIds: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
setAnnotationOffset: (ms: number) => void;
|
setAnnotationOffset: React.Dispatch<React.SetStateAction<number>>;
|
||||||
toggleObjectSelection: (id: string | undefined) => void;
|
toggleObjectSelection: (id: string | undefined) => void;
|
||||||
isDetailMode: boolean;
|
isDetailMode: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -524,7 +524,7 @@ function LibrarySelector({
|
|||||||
regexErrorMessage={t("description.invalidName")}
|
regexErrorMessage={t("description.invalidName")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button className="flex justify-between smart-capitalize">
|
<Button className="flex justify-between smart-capitalize">
|
||||||
{pageToggle == "train" ? t("train.title") : pageToggle}
|
{pageToggle == "train" ? t("train.title") : pageToggle}
|
||||||
|
|||||||
@ -80,6 +80,15 @@ export default function SearchView({
|
|||||||
});
|
});
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data: exploreEvents } = useSWR<SearchResult[]>(
|
||||||
|
(!searchFilter || Object.keys(searchFilter).length === 0) &&
|
||||||
|
!searchTerm &&
|
||||||
|
defaultView === "summary"
|
||||||
|
? ["events/explore", { limit: isMobileOnly ? 5 : 10 }]
|
||||||
|
: null,
|
||||||
|
{ revalidateOnFocus: true },
|
||||||
|
);
|
||||||
|
|
||||||
// grid
|
// grid
|
||||||
|
|
||||||
const gridClassName = cn(
|
const gridClassName = cn(
|
||||||
@ -202,20 +211,24 @@ export default function SearchView({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// remove duplicate event ids
|
|
||||||
|
|
||||||
const uniqueResults = useMemo(() => {
|
|
||||||
return searchResults?.filter(
|
|
||||||
(value, index, self) =>
|
|
||||||
index === self.findIndex((v) => v.id === value.id),
|
|
||||||
);
|
|
||||||
}, [searchResults]);
|
|
||||||
|
|
||||||
// detail
|
// detail
|
||||||
|
|
||||||
const [searchDetail, setSearchDetail] = useState<SearchResult>();
|
const [searchDetail, setSearchDetail] = useState<SearchResult>();
|
||||||
const [page, setPage] = useState<SearchTab>("snapshot");
|
const [page, setPage] = useState<SearchTab>("snapshot");
|
||||||
|
|
||||||
|
// remove duplicate event ids
|
||||||
|
|
||||||
|
const uniqueResults = useMemo(() => {
|
||||||
|
if (!searchResults) return [];
|
||||||
|
|
||||||
|
const results = searchResults.filter(
|
||||||
|
(value, index, self) =>
|
||||||
|
index === self.findIndex((v) => v.id === value.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}, [searchResults]);
|
||||||
|
|
||||||
// search interaction
|
// search interaction
|
||||||
|
|
||||||
const [selectedObjects, setSelectedObjects] = useState<string[]>([]);
|
const [selectedObjects, setSelectedObjects] = useState<string[]>([]);
|
||||||
@ -285,17 +298,23 @@ export default function SearchView({
|
|||||||
// update search detail when results change
|
// update search detail when results change
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchDetail && searchResults) {
|
if (searchDetail) {
|
||||||
const flattenedResults = searchResults.flat();
|
const results =
|
||||||
const updatedSearchDetail = flattenedResults.find(
|
defaultView === "summary" ? exploreEvents : searchResults?.flat();
|
||||||
(result) => result.id === searchDetail.id,
|
if (results) {
|
||||||
);
|
const updatedSearchDetail = results.find(
|
||||||
|
(result) => result.id === searchDetail.id,
|
||||||
|
);
|
||||||
|
|
||||||
if (updatedSearchDetail && !isEqual(updatedSearchDetail, searchDetail)) {
|
if (
|
||||||
setSearchDetail(updatedSearchDetail);
|
updatedSearchDetail &&
|
||||||
|
!isEqual(updatedSearchDetail, searchDetail)
|
||||||
|
) {
|
||||||
|
setSearchDetail(updatedSearchDetail);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [searchResults, searchDetail]);
|
}, [searchResults, exploreEvents, searchDetail, defaultView]);
|
||||||
|
|
||||||
const hasExistingSearch = useMemo(
|
const hasExistingSearch = useMemo(
|
||||||
() => searchResults != undefined || searchFilter != undefined,
|
() => searchResults != undefined || searchFilter != undefined,
|
||||||
@ -306,13 +325,49 @@ export default function SearchView({
|
|||||||
|
|
||||||
const [inputFocused, setInputFocused] = useState(false);
|
const [inputFocused, setInputFocused] = useState(false);
|
||||||
|
|
||||||
|
const goToPrevious = useCallback(() => {
|
||||||
|
const results =
|
||||||
|
exploreEvents && defaultView === "summary"
|
||||||
|
? exploreEvents.filter((event) => event.label === searchDetail?.label)
|
||||||
|
: uniqueResults;
|
||||||
|
if (results && results.length > 0) {
|
||||||
|
const currentIndex = searchDetail
|
||||||
|
? results.findIndex((result) => result.id === searchDetail.id)
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
const newIndex =
|
||||||
|
currentIndex === -1
|
||||||
|
? results.length - 1
|
||||||
|
: (currentIndex - 1 + results.length) % results.length;
|
||||||
|
|
||||||
|
setSearchDetail(results[newIndex]);
|
||||||
|
}
|
||||||
|
}, [uniqueResults, exploreEvents, searchDetail, defaultView]);
|
||||||
|
|
||||||
|
const goToNext = useCallback(() => {
|
||||||
|
const results =
|
||||||
|
exploreEvents && defaultView === "summary"
|
||||||
|
? exploreEvents.filter((event) => event.label === searchDetail?.label)
|
||||||
|
: uniqueResults;
|
||||||
|
if (results && results.length > 0) {
|
||||||
|
const currentIndex = searchDetail
|
||||||
|
? results.findIndex((result) => result.id === searchDetail.id)
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
const newIndex =
|
||||||
|
currentIndex === -1 ? 0 : (currentIndex + 1) % results.length;
|
||||||
|
|
||||||
|
setSearchDetail(results[newIndex]);
|
||||||
|
}
|
||||||
|
}, [uniqueResults, exploreEvents, searchDetail, defaultView]);
|
||||||
|
|
||||||
const onKeyboardShortcut = useCallback(
|
const onKeyboardShortcut = useCallback(
|
||||||
(key: string | null, modifiers: KeyModifiers) => {
|
(key: string | null, modifiers: KeyModifiers) => {
|
||||||
if (inputFocused) {
|
if (inputFocused) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!modifiers.down || !uniqueResults) {
|
if (!modifiers.down || (!uniqueResults && !exploreEvents)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -327,43 +382,23 @@ export default function SearchView({
|
|||||||
setSelectedObjects([]);
|
setSelectedObjects([]);
|
||||||
return true;
|
return true;
|
||||||
case "ArrowLeft":
|
case "ArrowLeft":
|
||||||
if (uniqueResults.length > 0) {
|
goToPrevious();
|
||||||
const currentIndex = searchDetail
|
|
||||||
? uniqueResults.findIndex(
|
|
||||||
(result) => result.id === searchDetail.id,
|
|
||||||
)
|
|
||||||
: -1;
|
|
||||||
|
|
||||||
const newIndex =
|
|
||||||
currentIndex === -1
|
|
||||||
? uniqueResults.length - 1
|
|
||||||
: (currentIndex - 1 + uniqueResults.length) %
|
|
||||||
uniqueResults.length;
|
|
||||||
|
|
||||||
setSearchDetail(uniqueResults[newIndex]);
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
case "ArrowRight":
|
case "ArrowRight":
|
||||||
if (uniqueResults.length > 0) {
|
goToNext();
|
||||||
const currentIndex = searchDetail
|
|
||||||
? uniqueResults.findIndex(
|
|
||||||
(result) => result.id === searchDetail.id,
|
|
||||||
)
|
|
||||||
: -1;
|
|
||||||
|
|
||||||
const newIndex =
|
|
||||||
currentIndex === -1
|
|
||||||
? 0
|
|
||||||
: (currentIndex + 1) % uniqueResults.length;
|
|
||||||
|
|
||||||
setSearchDetail(uniqueResults[newIndex]);
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
[uniqueResults, inputFocused, onSelectAllObjects, searchDetail],
|
[
|
||||||
|
uniqueResults,
|
||||||
|
exploreEvents,
|
||||||
|
inputFocused,
|
||||||
|
onSelectAllObjects,
|
||||||
|
goToPrevious,
|
||||||
|
goToNext,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useKeyboardListener(
|
useKeyboardListener(
|
||||||
@ -469,16 +504,22 @@ export default function SearchView({
|
|||||||
return (
|
return (
|
||||||
<div className="flex size-full flex-col pt-2 md:py-2">
|
<div className="flex size-full flex-col pt-2 md:py-2">
|
||||||
<Toaster closeButton={true} />
|
<Toaster closeButton={true} />
|
||||||
<SearchDetailDialog
|
<div className="relative">
|
||||||
search={searchDetail}
|
{searchDetail && (
|
||||||
page={page}
|
<SearchDetailDialog
|
||||||
setSearch={setSearchDetail}
|
search={searchDetail}
|
||||||
setSearchPage={setPage}
|
page={page}
|
||||||
setSimilarity={
|
setSearch={setSearchDetail}
|
||||||
searchDetail && (() => setSimilaritySearch(searchDetail))
|
setSearchPage={setPage}
|
||||||
}
|
setSimilarity={
|
||||||
setInputFocused={setInputFocused}
|
searchDetail && (() => setSimilaritySearch(searchDetail))
|
||||||
/>
|
}
|
||||||
|
setInputFocused={setInputFocused}
|
||||||
|
onPrevious={goToPrevious}
|
||||||
|
onNext={goToNext}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user