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:
Josh Hawkins
2025-11-06 10:22:52 -06:00
committed by GitHub
parent 32f1d85a6f
commit 945317b44e
11 changed files with 1013 additions and 877 deletions

View File

@@ -121,13 +121,13 @@ export default function AnnotationOffsetSlider({ className }: Props) {
<PopoverTrigger asChild>
<button
className="focus:outline-none"
aria-label={t("trackingDetails.annotationSettings.offset.desc")}
aria-label={t("trackingDetails.annotationSettings.offset.tips")}
>
<LuInfo className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent className="w-80 text-sm">
{t("trackingDetails.annotationSettings.offset.desc")}
{t("trackingDetails.annotationSettings.offset.tips")}
</PopoverContent>
</Popover>
</div>

View File

@@ -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 { FrigateConfig } from "@/types/frigateConfig";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -8,7 +5,6 @@ import axios from "axios";
import { useCallback, useState } from "react";
import { useForm } from "react-hook-form";
import { LuExternalLink } from "react-icons/lu";
import { PiWarningCircle } from "react-icons/pi";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import useSWR from "swr";
@@ -31,15 +27,11 @@ import { useDocDomain } from "@/hooks/use-doc-domain";
type AnnotationSettingsPaneProps = {
event: Event;
showZones: boolean;
setShowZones: React.Dispatch<React.SetStateAction<boolean>>;
annotationOffset: number;
setAnnotationOffset: React.Dispatch<React.SetStateAction<number>>;
};
export function AnnotationSettingsPane({
event,
showZones,
setShowZones,
annotationOffset,
setAnnotationOffset,
}: AnnotationSettingsPaneProps) {
@@ -140,26 +132,12 @@ export function AnnotationSettingsPane({
}
return (
<div className="mb-3 space-y-3 rounded-lg border border-secondary-foreground bg-background_alt p-2">
<Heading as="h4" className="my-2">
<div className="p-4">
<div className="text-md mb-2">
{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>
<Separator className="my-2 flex bg-secondary" />
<Separator className="mb-4 flex bg-secondary" />
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
@@ -169,17 +147,18 @@ export function AnnotationSettingsPane({
control={form.control}
name="annotationOffset"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("trackingDetails.annotationSettings.offset.label")}
</FormLabel>
<div className="flex flex-col gap-3 md:flex-row-reverse md:gap-8">
<div className="flex flex-row items-center gap-3 rounded-lg bg-destructive/50 p-3 text-sm text-primary-variant md:my-5">
<PiWarningCircle className="size-24" />
<div>
<Trans ns="views/explore">
trackingDetails.annotationSettings.offset.desc
</Trans>
<FormItem className="flex flex-row items-start justify-between space-x-2">
<div className="flex flex-col gap-1">
<FormLabel>
{t("trackingDetails.annotationSettings.offset.label")}
</FormLabel>
<FormDescription>
<Trans ns="views/explore">
trackingDetails.annotationSettings.offset.millisecondsToOffset
</Trans>
<FormMessage />
<div className="mt-2">
{t("trackingDetails.annotationSettings.offset.tips")}
<div className="mt-2 flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/reference")}
@@ -192,26 +171,19 @@ export function AnnotationSettingsPane({
</Link>
</div>
</div>
</div>
<div className="flex flex-col">
</FormDescription>
</div>
<div className="flex flex-col gap-3">
<div className="min-w-24">
<FormControl>
<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"
{...field}
/>
</FormControl>
<FormDescription>
<Trans ns="views/explore">
trackingDetails.annotationSettings.offset.millisecondsToOffset
</Trans>
<div className="mt-2">
{t("trackingDetails.annotationSettings.offset.tips")}
</div>
</FormDescription>
</div>
</div>
<FormMessage />
</FormItem>
)}
/>
@@ -220,7 +192,9 @@ export function AnnotationSettingsPane({
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
variant="default"
aria-label={t("button.apply", { ns: "common" })}
type="button"
onClick={form.handleSubmit(onApply)}
>
{t("button.apply", { ns: "common" })}

View 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

View File

@@ -2,21 +2,12 @@ import useSWR from "swr";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Event } from "@/types/event";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button";
import { TrackingDetailsSequence } from "@/types/timeline";
import Heading from "@/components/ui/heading";
import { FrigateConfig } from "@/types/frigateConfig";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
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 {
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 { baseUrl } from "@/api/baseUrl";
import { REVIEW_PADDING } from "@/types/review";
@@ -38,8 +29,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";
@@ -58,15 +47,13 @@ export function TrackingDetails({
}: 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);
const [displaySource, _setDisplaySource] = useState<"video" | "image">(
"video",
);
const { setSelectedObjectIds, annotationOffset, setAnnotationOffset } =
useDetailStream();
const { setSelectedObjectIds, annotationOffset } = useDetailStream();
// manualOverride holds a record-stream timestamp explicitly chosen by the
// 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 [_selectedZone, setSelectedZone] = useState("");
const [_lifecycleZones, setLifecycleZones] = useState<string[]>([]);
const [showControls, setShowControls] = useState(false);
const [showZones, setShowZones] = useState(true);
const [seekToTimestamp, setSeekToTimestamp] = useState<number | null>(null);
const aspectRatio = useMemo(() => {
@@ -359,7 +344,7 @@ export function TrackingDetails({
<div
className={cn(
isDesktop
? "flex size-full gap-4 overflow-hidden"
? "flex size-full justify-evenly gap-4 overflow-hidden"
: "flex size-full flex-col gap-2",
className,
)}
@@ -452,128 +437,34 @@ 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>}
<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
className={cn(
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
.enabled_in_config && (
<div className="-mt-2 mb-2 text-sm text-danger">
<div className="mb-2 text-sm text-danger">
{t("trackingDetails.autoTrackingTips")}
</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={cn(
"rounded-md bg-secondary p-3 outline outline-[3px] -outline-offset-[2.8px] outline-transparent duration-500",
)}
className={cn("rounded-md bg-background_alt px-0 py-3 md:px-2")}
>
<div className="flex w-full items-center justify-between">
<div
@@ -599,7 +490,7 @@ export function TrackingDetails({
</div>
<div className="flex items-center gap-2">
<span className="capitalize">{label}</span>
<span className="text-secondary-foreground">
<span className="md:text-md text-xs text-secondary-foreground">
{formattedStart ?? ""} - {formattedEnd ?? ""}
</span>
{event.data?.recognized_license_plate && (

View File

@@ -16,13 +16,7 @@ import ActivityIndicator from "../indicators/activity-indicator";
import { Event } from "@/types/event";
import { getIconForLabel } from "@/utils/iconUtil";
import { ReviewSegment } from "@/types/review";
import {
LuChevronDown,
LuCircle,
LuChevronRight,
LuSettings,
} from "react-icons/lu";
import { MdAutoAwesome } from "react-icons/md";
import { LuChevronDown, LuCircle, LuChevronRight } from "react-icons/lu";
import { getTranslatedLabel } from "@/utils/i18n";
import EventMenu from "@/components/timeline/EventMenu";
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
@@ -32,6 +26,8 @@ import { Link } from "react-router-dom";
import { Switch } from "@/components/ui/switch";
import { usePersistence } from "@/hooks/use-persistence";
import { isDesktop } from "react-device-detect";
import { PiSlidersHorizontalBold } from "react-icons/pi";
import { MdAutoAwesome } from "react-icons/md";
type DetailStreamProps = {
reviewItems?: ReviewSegment[];
@@ -237,7 +233,7 @@ export default function DetailStream({
className="flex w-full items-center justify-between p-3"
>
<div className="flex items-center gap-2 text-sm font-medium">
<LuSettings className="size-4" />
<PiSlidersHorizontalBold className="size-4" />
<span>{t("detail.settings")}</span>
</div>
{controlsExpanded ? (

View File

@@ -11,13 +11,21 @@ const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
container?: HTMLElement | null;
disablePortal?: boolean;
}
>(
(
{ className, container, align = "center", sideOffset = 4, ...props },
{
className,
container,
disablePortal = false,
align = "center",
sideOffset = 4,
...props
},
ref,
) => (
<PopoverPrimitive.Portal container={container}>
) => {
const content = (
<PopoverPrimitive.Content
ref={ref}
align={align}
@@ -28,8 +36,18 @@ const PopoverContent = React.forwardRef<
)}
{...props}
/>
</PopoverPrimitive.Portal>
),
);
if (disablePortal) {
return content;
}
return (
<PopoverPrimitive.Portal container={container}>
{content}
</PopoverPrimitive.Portal>
);
},
);
PopoverContent.displayName = PopoverPrimitive.Content.displayName;