From 945317b44e910eed1a72a2967eb8946a9dc66afa Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 6 Nov 2025 10:22:52 -0600 Subject: [PATCH] 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 --- web/public/locales/en/views/explore.json | 4 +- .../overlay/detail/AnnotationOffsetSlider.tsx | 4 +- .../overlay/detail/AnnotationSettingsPane.tsx | 72 +- .../overlay/detail/DetailActionsMenu.tsx | 118 ++ .../overlay/detail/SearchDetailDialog.tsx | 1350 +++++++++-------- .../overlay/detail/TrackingDetails.tsx | 141 +- web/src/components/timeline/DetailStream.tsx | 12 +- web/src/components/ui/popover.tsx | 28 +- web/src/context/detail-stream-context.tsx | 2 +- web/src/pages/FaceLibrary.tsx | 2 +- web/src/views/search/SearchView.tsx | 157 +- 11 files changed, 1013 insertions(+), 877 deletions(-) create mode 100644 web/src/components/overlay/detail/DetailActionsMenu.tsx diff --git a/web/public/locales/en/views/explore.json b/web/public/locales/en/views/explore.json index 787581bf7..afc81eaa6 100644 --- a/web/public/locales/en/views/explore.json +++ b/web/public/locales/en/views/explore.json @@ -74,7 +74,7 @@ "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.", "millisecondsToOffset": "Milliseconds to offset detect annotations by. Default: 0", - "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": { "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 ", "searchResult": { "tooltip": "Matched {{type}} at {{confidence}}%", + "previousTrackedObject": "Previous tracked object", + "nextTrackedObject": "Next tracked object", "deleteTrackedObject": { "toast": { "success": "Tracked object deleted successfully.", diff --git a/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx b/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx index 4af982da5..9f4851d42 100644 --- a/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx +++ b/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx @@ -121,13 +121,13 @@ export default function AnnotationOffsetSlider({ className }: Props) { - {t("trackingDetails.annotationSettings.offset.desc")} + {t("trackingDetails.annotationSettings.offset.tips")} diff --git a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx index c180502f4..33bf10c5c 100644 --- a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx +++ b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx @@ -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>; annotationOffset: number; setAnnotationOffset: React.Dispatch>; }; export function AnnotationSettingsPane({ event, - showZones, - setShowZones, annotationOffset, setAnnotationOffset, }: AnnotationSettingsPaneProps) { @@ -140,26 +132,12 @@ export function AnnotationSettingsPane({ } return ( -
- +
+
{t("trackingDetails.annotationSettings.title")} - -
-
- - -
-
- {t("trackingDetails.annotationSettings.showAllZones.desc")} -
- + +
( - - - {t("trackingDetails.annotationSettings.offset.label")} - -
-
- -
- - trackingDetails.annotationSettings.offset.desc - + +
+ + {t("trackingDetails.annotationSettings.offset.label")} + + + + trackingDetails.annotationSettings.offset.millisecondsToOffset + + +
+ {t("trackingDetails.annotationSettings.offset.tips")}
-
-
+ +
+
+
- - - trackingDetails.annotationSettings.offset.millisecondsToOffset - -
- {t("trackingDetails.annotationSettings.offset.tips")} -
-
-
)} /> @@ -220,7 +192,9 @@ export function AnnotationSettingsPane({
+ + + + + + +
+ ); +} + +type DialogContentComponentProps = { + page: SearchTab; + search: SearchResult; + isDesktop: boolean; + apiHost: string; + config?: FrigateConfig; + searchTabs: SearchTab[]; + pageToggle: SearchTab; + setPageToggle: (v: SearchTab) => void; + setSearch: (s: SearchResult | undefined) => void; + setInputFocused: React.Dispatch>; + setSimilarity?: () => void; + isPopoverOpen: boolean; + setIsPopoverOpen: (open: boolean) => void; + dialogContainer: HTMLDivElement | null; +}; + +function DialogContentComponent({ + page, + search, + isDesktop, + apiHost, + config, + searchTabs, + pageToggle, + setPageToggle, + setSearch, + setInputFocused, + setSimilarity, + isPopoverOpen, + setIsPopoverOpen, + dialogContainer, +}: DialogContentComponentProps) { + if (page === "tracking_details") { + return ( + + ) : undefined + } + /> + ); + } + + // Snapshot page content + const snapshotElement = search.has_snapshot ? ( + + ) : ( +
+ +
+ ); + + if (isDesktop) { + return ( +
+
+ {snapshotElement} +
+
+ +
+ +
+
+
+ ); + } + + // mobile + return ( + <> + {snapshotElement} + + + ); +} + type SearchDetailDialogProps = { search?: SearchResult; page: SearchTab; @@ -91,7 +415,10 @@ type SearchDetailDialogProps = { setSearchPage: (page: SearchTab) => void; setSimilarity?: () => void; setInputFocused: React.Dispatch>; + onPrevious?: () => void; + onNext?: () => void; }; + export default function SearchDetailDialog({ search, page, @@ -99,6 +426,8 @@ export default function SearchDetailDialog({ setSearchPage, setSimilarity, setInputFocused, + onPrevious, + onNext, }: SearchDetailDialogProps) { const { t } = useTranslation(["views/explore", "views/faceLibrary"]); const { data: config } = useSWR("config", { @@ -117,11 +446,17 @@ export default function SearchDetailDialog({ // dialog and mobile page const [isOpen, setIsOpen] = useState(search != undefined); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const dialogContentRef = useRef(null); + const [dialogContainer, setDialogContainer] = useState( + null, + ); const handleOpenChange = useCallback( (open: boolean) => { setIsOpen(open); if (!open) { + setIsPopoverOpen(false); // short timeout to allow the mobile page animation // to complete before updating the state setTimeout(() => { @@ -132,12 +467,18 @@ export default function SearchDetailDialog({ [setSearch], ); + useLayoutEffect(() => { + setDialogContainer(dialogContentRef.current); + }, [isOpen, search?.id]); + useEffect(() => { if (search) { setIsOpen(search != undefined); } }, [search]); + // show/hide annotation settings is handled inside TabsWithActions + const searchTabs = useMemo(() => { if (!config || !search) { return []; @@ -163,46 +504,6 @@ export default function SearchDetailDialog({ } }, [pageToggle, searchTabs, setSearchPage]); - // Tabs component for reuse - const tabsComponent = ( - -
- { - if (value) { - setPageToggle(value); - } - }} - > - {Object.values(searchTabs).map((item) => ( - - {item == "snapshot" && } - {item == "tracking_details" && } -
- {item === "snapshot" - ? search?.has_snapshot - ? t("type.snapshot") - : t("type.thumbnail") - : t(`type.${item}`)} -
-
- ))} -
- -
-
- ); - if (!search) { return; } @@ -227,174 +528,115 @@ export default function SearchDetailDialog({ onOpenChange={handleOpenChange} enableHistoryBack={true} > + {isDesktop && onPrevious && onNext && ( + +
+
+ + + + + + {t("searchResult.previousTrackedObject")} + + + + + + + + + {t("searchResult.nextTrackedObject")} + + +
+
+
+ )} { + if (isPopoverOpen) { + e.preventDefault(); + } + const target = e.target as HTMLElement; + if (target.closest(".nav-button")) { + e.preventDefault(); + } + }} >
{t("trackedObjectDetails")} {t("trackedObjectDetails")} +
- {isDesktop ? ( - page === "tracking_details" ? ( - + - ) : ( -
-
- {page === "snapshot" && search.has_snapshot && ( - { - search.plus_id = "new_upload"; - }} - /> - )} - {page === "snapshot" && !search.has_snapshot && ( - - )} -
-
- {tabsComponent} -
- {page == "snapshot" && ( - - )} -
-
-
- ) - ) : ( - <> - -
- { - if (value) { - setPageToggle(value); - } - }} - > - {Object.values(searchTabs).map((item) => ( - - {item == "snapshot" && } - {item == "tracking_details" && ( - - )} -
- {t(`type.${item}`)} -
-
- ))} -
- -
-
- {page == "snapshot" && ( - <> - {search.has_snapshot && ( - { - search.plus_id = "new_upload"; - }} - /> - )} - {page == "snapshot" && !search.has_snapshot && ( - - )} - - {t("type.details")} - - - - )} - {page == "tracking_details" && ( - - )} - +
)} + + @@ -405,19 +647,19 @@ type ObjectDetailsTabProps = { search: SearchResult; config?: FrigateConfig; setSearch: (search: SearchResult | undefined) => void; - setSimilarity?: () => void; setInputFocused: React.Dispatch>; - showThumbnail?: boolean; }; function ObjectDetailsTab({ search, config, setSearch, - setSimilarity, setInputFocused, - showThumbnail = true, }: ObjectDetailsTabProps) { - const { t } = useTranslation(["views/explore", "views/faceLibrary"]); + const { t, i18n } = useTranslation([ + "views/explore", + "views/faceLibrary", + "components/dialog", + ]); const apiHost = useApiHost(); @@ -783,57 +1025,6 @@ function ObjectDetailsTab({ [search, apiHost, mutate, setSearch, t], ); - // face training - - const hasFace = useMemo(() => { - if (!config?.face_recognition.enabled || !search) { - return false; - } - - return search.data.attributes?.find((attr) => attr.label == "face"); - }, [config, search]); - - const { data: faceData } = useSWR(hasFace ? "faces" : null); - - const faceNames = useMemo( - () => - faceData ? Object.keys(faceData).filter((face) => face != "train") : [], - [faceData], - ); - - const onTrainFace = useCallback( - (trainName: string) => { - axios - .post(`/faces/train/${trainName}/classify`, { event_id: search.id }) - .then((resp) => { - if (resp.status == 200) { - toast.success( - t("toast.success.trainedFace", { ns: "views/faceLibrary" }), - { - position: "top-center", - }, - ); - } - }) - .catch((error) => { - const errorMessage = - error.response?.data?.message || - error.response?.data?.detail || - "Unknown error"; - toast.error( - t("toast.error.trainFailed", { - ns: "views/faceLibrary", - errorMessage, - }), - { - position: "top-center", - }, - ); - }); - }, - [search, t], - ); - // speech transcription const onTranscribe = useCallback(() => { @@ -862,35 +1053,159 @@ function ObjectDetailsTab({ }); }, [search, t]); + // frigate+ submission + + type SubmissionState = "reviewing" | "uploading" | "submitted"; + const [state, setState] = useState( + search?.plus_id ? "submitted" : "reviewing", + ); + + useEffect( + () => setState(search?.plus_id ? "submitted" : "reviewing"), + [search], + ); + + const onSubmitToPlus = useCallback( + async (falsePositive: boolean) => { + if (!search) { + return; + } + + falsePositive + ? axios.put(`events/${search.id}/false_positive`) + : axios.post(`events/${search.id}/plus`, { + include_annotation: 1, + }); + + setState("submitted"); + setSearch({ + ...search, + plus_id: "new_upload", + }); + }, + [search, setSearch], + ); + + const popoverContainerRef = useRef(null); return ( -
+
-
-
{t("details.label")}
-
- {getIconForLabel(search.label, "size-4 text-primary")} - {getTranslatedLabel(search.label)} - {search.sub_label && ` (${search.sub_label})`} - {isAdmin && search.end_time && ( - - - - { - setIsSubLabelDialogOpen(true); - }} - /> - - - - - {t("details.editSubLabel.title")} - - - - )} +
+
+
+
+
+
+ {t("details.label")} +
+
+ {getIconForLabel(search.label, "size-4 text-primary")} + {getTranslatedLabel(search.label)} + {search.sub_label && ` (${search.sub_label})`} + {isAdmin && search.end_time && ( + + + + setIsSubLabelDialogOpen(true)} + /> + + + + + {t("details.editSubLabel.title")} + + + + )} +
+
+ +
+
+
+ {t("details.topScore.label")} + + +
+ + Info +
+
+ + {t("details.topScore.info")} + +
+
+
+
+ {topScore}%{subLabelScore && ` (${subLabelScore}%)`} +
+
+ +
+
+ {t("details.camera")} +
+
+ +
+
+
+
+ +
+
+ {snapScore != undefined && ( +
+
+
+ {t("details.snapshotScore.label")} +
+
+
{snapScore}%
+
+ )} + + {averageEstimatedSpeed && ( +
+
+ {t("details.estimatedSpeed")} +
+
+
+ {averageEstimatedSpeed}{" "} + {config?.ui.unit_system == "imperial" + ? t("unit.speed.mph", { ns: "common" }) + : t("unit.speed.kph", { ns: "common" })} + {velocityAngle != undefined && ( + + + + )} +
+
+
+ )} + +
+
+ {t("details.timestamp")} +
+
{formattedDate}
+
+
+
{search?.data.recognized_license_plate && ( @@ -909,9 +1224,7 @@ function ObjectDetailsTab({ { - setIsLPRDialogOpen(true); - }} + onClick={() => setIsLPRDialogOpen(true)} /> @@ -926,142 +1239,108 @@ function ObjectDetailsTab({
)} -
-
-
- {t("details.topScore.label")} - - -
- - Info -
-
- - {t("details.topScore.info")} - -
-
-
-
- {topScore}%{subLabelScore && ` (${subLabelScore}%)`} -
-
- {snapScore != undefined && ( -
-
-
- {t("details.snapshotScore.label")} +
+
+ +
+
+
+ {t("explore.plus.submitToPlus.label", { + ns: "components/dialog", + })} + + +
+ + Info
-
-
{snapScore}%
-
- )} - {averageEstimatedSpeed && ( -
-
- {t("details.estimatedSpeed")} -
-
- {averageEstimatedSpeed && ( -
- {averageEstimatedSpeed}{" "} - {config?.ui.unit_system == "imperial" - ? t("unit.speed.mph", { ns: "common" }) - : t("unit.speed.kph", { ns: "common" })}{" "} - {velocityAngle != undefined && ( - - - - )} -
- )} -
-
- )} -
-
{t("details.camera")}
-
- -
-
-
-
- {t("details.timestamp")} -
-
{formattedDate}
+ + + {t("explore.plus.submitToPlus.desc", { + ns: "components/dialog", + })} + +
- {showThumbnail && ( -
- -
- {config?.semantic_search.enabled && - setSimilarity != undefined && - search.data.type == "object" && ( - + explore.plus.review.question.ask_full + )} - {hasFace && ( - +
+ - - )} - {config?.cameras[search?.camera].audio_transcription.enabled && - search?.label == "speech" && - search?.end_time && ( - - )} + {t("button.yes", { ns: "common" })} + + +
+ + )} + {state == "uploading" && } + {state == "submitted" && ( +
+ + {t("explore.plus.review.state.submitted")}
-
- )} + )} +
{config?.cameras[search.camera].objects.genai.enabled && @@ -1103,6 +1382,15 @@ function ObjectDetailsTab({ )}
+ {config?.cameras[search?.camera].audio_transcription.enabled && + search?.label == "speech" && + search?.end_time && ( + + )} {config?.cameras[search.camera].objects.genai.enabled && search.end_time && (
@@ -1154,6 +1442,7 @@ function ObjectDetailsTab({ {t("button.save", { ns: "common" })} )} + void; + className?: string; + onEventUploaded?: () => void; }; export function ObjectSnapshotTab({ search, - onEventUploaded, + className, }: ObjectSnapshotTabProps) { - const { t, i18n } = useTranslation(["components/dialog"]); - type SubmissionState = "reviewing" | "uploading" | "submitted"; - const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); - // upload - - const [state, setState] = useState( - search?.plus_id ? "submitted" : "reviewing", - ); - - useEffect( - () => setState(search?.plus_id ? "submitted" : "reviewing"), - [search], - ); - - const onSubmitToPlus = useCallback( - async (falsePositive: boolean) => { - if (!search) { - return; - } - - falsePositive - ? axios.put(`events/${search.id}/false_positive`) - : axios.post(`events/${search.id}/plus`, { - include_annotation: 1, - }); - - setState("submitted"); - onEventUploaded(); - }, - [search, onEventUploaded], - ); - return ( -
+
-
+
-
+
{search?.id && ( -
+
{`${search?.label}`} -
- - - - - - - - - - - {t("button.download", { ns: "common" })} - - - -
)} - {search.data.type == "object" && - search.plus_id !== "not_enabled" && - search.end_time && - search.label != "on_demand" && ( - - -
-
- {t("explore.plus.submitToPlus.label")} -
-
- {t("explore.plus.submitToPlus.desc")} -
-
- -
- {state == "reviewing" && ( - <> -
- {i18n.language === "en" ? ( - // English with a/an logic plus label - <> - {/^[aeiou]/i.test(search?.label || "") ? ( - - explore.plus.review.question.ask_an - - ) : ( - - explore.plus.review.question.ask_a - - )} - - ) : ( - // For other languages - - explore.plus.review.question.ask_full - - )} -
-
- - -
- - )} - {state == "uploading" && } - {state == "submitted" && ( -
- - {t("explore.plus.review.state.submitted")} -
- )} -
-
-
- )}
@@ -1391,12 +1542,6 @@ type VideoTabProps = { }; export function VideoTab({ search }: VideoTabProps) { - const { t } = useTranslation(["views/explore"]); - const navigate = useNavigate(); - const { data: reviewItem } = useSWR([ - `review/event/${search.id}`, - ]); - const clipTimeRange = useMemo(() => { const startTime = search.start_time - REVIEW_PADDING; const endTime = (search.end_time ?? Date.now() / 1000) + REVIEW_PADDING; @@ -1408,56 +1553,7 @@ export function VideoTab({ search }: VideoTabProps) { return ( <> - -
- {reviewItem && ( - - - { - if (reviewItem?.id) { - const params = new URLSearchParams({ - id: reviewItem.id, - }).toString(); - navigate(`/review?${params}`); - } - }} - > - - - - - - {t("itemMenu.viewInHistory.label")} - - - - )} - - - - - - - - - - - {t("button.download", { ns: "common" })} - - - -
-
+ ); } diff --git a/web/src/components/overlay/detail/TrackingDetails.tsx b/web/src/components/overlay/detail/TrackingDetails.tsx index b505130cc..cd4e18e3b 100644 --- a/web/src/components/overlay/detail/TrackingDetails.tsx +++ b/web/src/components/overlay/detail/TrackingDetails.tsx @@ -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(null); const { t } = useTranslation(["views/explore"]); - const navigate = useNavigate(); const apiHost = useApiHost(); const imgRef = useRef(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(null); const [_selectedZone, setSelectedZone] = useState(""); const [_lifecycleZones, setLifecycleZones] = useState([]); - const [showControls, setShowControls] = useState(false); - const [showZones, setShowZones] = useState(true); const [seekToTimestamp, setSeekToTimestamp] = useState(null); const aspectRatio = useMemo(() => { @@ -359,7 +344,7 @@ 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 && ( +
+
{tabs}
+
+ )}
-
- {t("trackingDetails.title")} - -
- - - - - - - {t("trackingDetails.adjustAnnotationSettings")} - - - -
-
-
-
- {t("trackingDetails.scrollViewTips")} -
-
- {t("trackingDetails.count", { - first: eventSequence?.length ?? 0, - second: eventSequence?.length ?? 0, - })} -
-
{config?.cameras[event.camera]?.onvif.autotracking .enabled_in_config && ( -
+
{t("trackingDetails.autoTrackingTips")}
)} - {showControls && ( - { - if (typeof value === "function") { - const newValue = value(annotationOffset); - setAnnotationOffset(newValue); - } else { - setAnnotationOffset(value); - } - }} - /> - )}
{label} - + {formattedStart ?? ""} - {formattedEnd ?? ""} {event.data?.recognized_license_plate && ( diff --git a/web/src/components/timeline/DetailStream.tsx b/web/src/components/timeline/DetailStream.tsx index 5b45de19f..ca834e2a8 100644 --- a/web/src/components/timeline/DetailStream.tsx +++ b/web/src/components/timeline/DetailStream.tsx @@ -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" >
- + {t("detail.settings")}
{controlsExpanded ? ( diff --git a/web/src/components/ui/popover.tsx b/web/src/components/ui/popover.tsx index bba83f977..017d1bdc7 100644 --- a/web/src/components/ui/popover.tsx +++ b/web/src/components/ui/popover.tsx @@ -11,13 +11,21 @@ const PopoverContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { container?: HTMLElement | null; + disablePortal?: boolean; } >( ( - { className, container, align = "center", sideOffset = 4, ...props }, + { + className, + container, + disablePortal = false, + align = "center", + sideOffset = 4, + ...props + }, ref, - ) => ( - + ) => { + const content = ( - - ), + ); + + if (disablePortal) { + return content; + } + + return ( + + {content} + + ); + }, ); PopoverContent.displayName = PopoverPrimitive.Content.displayName; diff --git a/web/src/context/detail-stream-context.tsx b/web/src/context/detail-stream-context.tsx index ff909a30e..57971f7ac 100644 --- a/web/src/context/detail-stream-context.tsx +++ b/web/src/context/detail-stream-context.tsx @@ -8,7 +8,7 @@ export interface DetailStreamContextType { camera: string; annotationOffset: number; // milliseconds setSelectedObjectIds: React.Dispatch>; - setAnnotationOffset: (ms: number) => void; + setAnnotationOffset: React.Dispatch>; toggleObjectSelection: (id: string | undefined) => void; isDetailMode: boolean; } diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx index b6a04ada9..6cc113e77 100644 --- a/web/src/pages/FaceLibrary.tsx +++ b/web/src/pages/FaceLibrary.tsx @@ -524,7 +524,7 @@ function LibrarySelector({ regexErrorMessage={t("description.invalidName")} /> - +