diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 893fbacc6..d4fa7a0f0 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -10,10 +10,10 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "../ui/dropdown-menu"; -import { ReviewFilter, ReviewSummary } from "@/types/review"; +import { ReviewFilter, ReviewSeverity, ReviewSummary } from "@/types/review"; import { getEndOfDayTimestamp } from "@/utils/dateUtil"; import { useFormattedTimestamp } from "@/hooks/use-date-utils"; -import { FaCalendarAlt, FaFilter, FaVideo } from "react-icons/fa"; +import { FaCalendarAlt, FaFilter, FaRunning, FaVideo } from "react-icons/fa"; import { isMobile } from "react-device-detect"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Switch } from "../ui/switch"; @@ -27,12 +27,18 @@ type ReviewFilterGroupProps = { reviewSummary?: ReviewSummary; filter?: ReviewFilter; onUpdateFilter: (filter: ReviewFilter) => void; + severity: ReviewSeverity; + motionOnly: boolean; + setMotionOnly: React.Dispatch>; }; export default function ReviewFilterGroup({ reviewSummary, filter, onUpdateFilter, + severity, + motionOnly, + setMotionOnly, }: ReviewFilterGroupProps) { const { data: config } = useSWR("config"); @@ -94,7 +100,7 @@ export default function ReviewFilterGroup({ ); return ( -
+
- { - onUpdateFilter({ ...filter, labels: newLabels }); - }} - showReviewed={filter?.showReviewed || 0} - setShowReviewed={(reviewed) => - onUpdateFilter({ ...filter, showReviewed: reviewed }) - } - /> + {severity == "significant_motion" ? ( + + ) : ( + { + onUpdateFilter({ ...filter, labels: newLabels }); + }} + showReviewed={filter?.showReviewed || 0} + setShowReviewed={(reviewed) => + onUpdateFilter({ ...filter, showReviewed: reviewed }) + } + /> + )}
); } @@ -485,3 +498,46 @@ function GeneralFilterButton({ ); } + +type ShowMotionOnlyButtonProps = { + motionOnly: boolean; + setMotionOnly: React.Dispatch>; +}; +function ShowMotionOnlyButton({ + motionOnly, + setMotionOnly, +}: ShowMotionOnlyButtonProps) { + return ( + <> +
+ { + setMotionOnly(!motionOnly); + }} + /> + +
+ +
+ +
+ + ); +} diff --git a/web/src/components/timeline/EventReviewTimeline.tsx b/web/src/components/timeline/EventReviewTimeline.tsx index 1e165d799..56f2f6472 100644 --- a/web/src/components/timeline/EventReviewTimeline.tsx +++ b/web/src/components/timeline/EventReviewTimeline.tsx @@ -236,10 +236,12 @@ export function EventReviewTimeline({ const element = selectedTimelineRef.current?.querySelector( `[data-segment-id="${Math.max(...alignedVisibleTimestamps)}"]`, ); - scrollIntoView(element as HTMLDivElement, { - scrollMode: "if-needed", - behavior: "smooth", - }); + if (element) { + scrollIntoView(element, { + scrollMode: "if-needed", + behavior: "smooth", + }); + } } }, [ selectedTimelineRef, diff --git a/web/src/components/timeline/EventSegment.tsx b/web/src/components/timeline/EventSegment.tsx index 263b3bdbc..3cbf8826b 100644 --- a/web/src/components/timeline/EventSegment.tsx +++ b/web/src/components/timeline/EventSegment.tsx @@ -201,7 +201,7 @@ export function EventSegment({
handleTouchStart(event, segmentClick)} > diff --git a/web/src/components/timeline/MotionReviewTimeline.tsx b/web/src/components/timeline/MotionReviewTimeline.tsx index 9255df53e..6560e84ae 100644 --- a/web/src/components/timeline/MotionReviewTimeline.tsx +++ b/web/src/components/timeline/MotionReviewTimeline.tsx @@ -21,6 +21,7 @@ export type MotionReviewTimelineProps = { showHandlebar?: boolean; handlebarTime?: number; setHandlebarTime?: React.Dispatch>; + motionOnly?: boolean; showMinimap?: boolean; minimapStartTime?: number; minimapEndTime?: number; @@ -45,6 +46,7 @@ export function MotionReviewTimeline({ showHandlebar = false, handlebarTime, setHandlebarTime, + motionOnly = false, showMinimap = false, minimapStartTime, minimapEndTime, @@ -113,6 +115,7 @@ export function MotionReviewTimeline({ draggableElementTime: handlebarTime, setDraggableElementTime: setHandlebarTime, timelineDuration, + timelineCollapsed: motionOnly, timelineStartAligned, isDragging, setIsDragging, @@ -176,6 +179,7 @@ export function MotionReviewTimeline({ segmentDuration={segmentDuration} segmentTime={segmentTime} timestampSpread={timestampSpread} + motionOnly={motionOnly} showMinimap={showMinimap} minimapStartTime={minimapStartTime} minimapEndTime={minimapEndTime} @@ -195,6 +199,7 @@ export function MotionReviewTimeline({ minimapEndTime, events, motion_events, + motionOnly, ]); const segments = useMemo( @@ -211,6 +216,7 @@ export function MotionReviewTimeline({ minimapEndTime, events, motion_events, + motionOnly, ], ); diff --git a/web/src/components/timeline/MotionSegment.tsx b/web/src/components/timeline/MotionSegment.tsx index 21d404859..dd54a03b6 100644 --- a/web/src/components/timeline/MotionSegment.tsx +++ b/web/src/components/timeline/MotionSegment.tsx @@ -14,6 +14,7 @@ type MotionSegmentProps = { segmentTime: number; segmentDuration: number; timestampSpread: number; + motionOnly: boolean; showMinimap: boolean; minimapStartTime?: number; minimapEndTime?: number; @@ -26,6 +27,7 @@ export function MotionSegment({ segmentTime, segmentDuration, timestampSpread, + motionOnly, showMinimap, minimapStartTime, minimapEndTime, @@ -180,79 +182,96 @@ export function MotionSegment({ }, [segmentTime, setHandlebarTime]); return ( -
1 || secondHalfSegmentWidth > 1 ? "has-data" : ""} ${segmentClasses}`} - onClick={segmentClick} - onTouchEnd={(event) => handleTouchStart(event, segmentClick)} - > - + <> + {(((firstHalfSegmentWidth > 1 || secondHalfSegmentWidth > 1) && + motionOnly && + severity[0] < 2) || + !motionOnly) && ( +
1 || secondHalfSegmentWidth > 1 ? "has-data" : ""} ${segmentClasses}`} + onClick={segmentClick} + onTouchEnd={(event) => handleTouchStart(event, segmentClick)} + > + {!motionOnly && ( + <> + - + - + + + )} -
-
-
-
-
-
- -
-
-
-
-
-
- - {severity.map((severityValue: number, index: number) => { - if (severityValue > 1) { - return ( - -
+
+
+
- - ); - } else { - return null; - } - })} -
+
+ +
+
+
+
+
+
+ + {!motionOnly && + severity.map((severityValue: number, index: number) => { + if (severityValue > 1) { + return ( + +
+
+
+
+ ); + } else { + return null; + } + })} +
+ )} + ); } diff --git a/web/src/components/ui/switch.tsx b/web/src/components/ui/switch.tsx index 343df17d0..162260f87 100644 --- a/web/src/components/ui/switch.tsx +++ b/web/src/components/ui/switch.tsx @@ -1,7 +1,7 @@ -import * as React from "react" -import * as SwitchPrimitives from "@radix-ui/react-switch" +import * as React from "react"; +import * as SwitchPrimitives from "@radix-ui/react-switch"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const Switch = React.forwardRef< React.ElementRef, @@ -10,18 +10,18 @@ const Switch = React.forwardRef< -)) -Switch.displayName = SwitchPrimitives.Root.displayName +)); +Switch.displayName = SwitchPrimitives.Root.displayName; -export { Switch } +export { Switch }; diff --git a/web/src/hooks/use-draggable-element.ts b/web/src/hooks/use-draggable-element.ts index 1a70658f9..d4cd3e713 100644 --- a/web/src/hooks/use-draggable-element.ts +++ b/web/src/hooks/use-draggable-element.ts @@ -15,6 +15,7 @@ type DraggableElementProps = { setDraggableElementTime?: React.Dispatch>; draggableElementTimeRef: React.MutableRefObject; timelineDuration: number; + timelineCollapsed?: boolean; timelineStartAligned: number; isDragging: boolean; setIsDragging: React.Dispatch>; @@ -33,6 +34,7 @@ function useDraggableElement({ setDraggableElementTime, draggableElementTimeRef, timelineDuration, + timelineCollapsed, timelineStartAligned, isDragging, setIsDragging, @@ -40,6 +42,7 @@ function useDraggableElement({ }: DraggableElementProps) { const [clientYPosition, setClientYPosition] = useState(null); const [initialClickAdjustment, setInitialClickAdjustment] = useState(0); + const [segments, setSegments] = useState([]); const { alignStartDateToTimeline, getCumulativeScrollTop } = useTimelineUtils( { segmentDuration: segmentDuration, @@ -101,7 +104,7 @@ function useDraggableElement({ } else if (e.nativeEvent instanceof MouseEvent) { clientY = e.nativeEvent.clientY; } - if (clientY && draggableElementRef.current && isDesktop) { + if (clientY && draggableElementRef.current) { const draggableElementRect = draggableElementRef.current.getBoundingClientRect(); if (!isDragging) { @@ -203,6 +206,12 @@ function useDraggableElement({ [contentRef, draggableElementRef, timelineRef, getClientYPosition], ); + useEffect(() => { + if (timelineRef.current) { + setSegments(Array.from(timelineRef.current.querySelectorAll(".segment"))); + } + }, [timelineRef, segmentDuration, timelineDuration, timelineCollapsed]); + useEffect(() => { let animationFrameId: number | null = null; @@ -211,13 +220,11 @@ function useDraggableElement({ timelineRef.current && showDraggableElement && isDragging && - clientYPosition + clientYPosition && + segments ) { - const { - scrollHeight: timelineHeight, - scrollTop: scrolled, - offsetTop: timelineTop, - } = timelineRef.current; + const { scrollHeight: timelineHeight, scrollTop: scrolled } = + timelineRef.current; const segmentHeight = timelineHeight / (timelineDuration / segmentDuration); @@ -235,22 +242,53 @@ function useDraggableElement({ ? timestampToPixels(draggableElementLatestTime) : segmentHeight * 2 + scrolled; + const timelineRect = timelineRef.current.getBoundingClientRect(); + const timelineTopAbsolute = timelineRect.top; + const newElementPosition = Math.min( elementEarliest, Math.max( elementLatest, // current Y position clientYPosition - - timelineTop + + timelineTopAbsolute + parentScrollTop - initialClickAdjustment, ), ); - const segmentIndex = Math.floor(newElementPosition / segmentHeight); - const segmentStartTime = alignStartDateToTimeline( - timelineStartAligned - segmentIndex * segmentDuration, - ); + if ( + newElementPosition >= elementEarliest || + newElementPosition <= elementLatest + ) { + return; + } + + let targetSegmentId = 0; + let offset = 0; + + segments.forEach((segmentElement: HTMLDivElement) => { + const rect = segmentElement.getBoundingClientRect(); + const segmentTop = + rect.top + scrolled - timelineTopAbsolute - segmentHeight; + const segmentBottom = + rect.bottom + scrolled - timelineTopAbsolute - segmentHeight; + + // Check if handlebar position falls within the segment bounds + if ( + newElementPosition >= segmentTop && + newElementPosition <= segmentBottom + ) { + targetSegmentId = parseFloat( + segmentElement.getAttribute("data-segment-id") || "0", + ); + offset = Math.min( + segmentBottom - newElementPosition, + segmentHeight, + ); + return; + } + }); if (draggingAtTopEdge || draggingAtBottomEdge) { let newPosition = clientYPosition; @@ -267,17 +305,15 @@ function useDraggableElement({ } updateDraggableElementPosition( - newElementPosition - segmentHeight, - segmentStartTime, + newElementPosition, + targetSegmentId, false, false, ); if (setDraggableElementTime) { setDraggableElementTime( - timelineStartAligned - - ((newElementPosition - segmentHeight / 2 - 2) / segmentHeight) * - segmentDuration, + targetSegmentId + segmentDuration * (offset / segmentHeight), ); } @@ -321,7 +357,8 @@ function useDraggableElement({ draggableElementRef.current && showDraggableElement && draggableElementTime && - !isDragging + !isDragging && + segments.length > 0 ) { const { scrollHeight: timelineHeight, scrollTop: scrolled } = timelineRef.current; @@ -329,29 +366,60 @@ function useDraggableElement({ const segmentHeight = timelineHeight / (timelineDuration / segmentDuration); - const parentScrollTop = getCumulativeScrollTop(timelineRef.current); + const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime); - const newElementPosition = - ((timelineStartAligned - draggableElementTime) / segmentDuration) * - segmentHeight + - parentScrollTop - - scrolled - - 2; // height of draggableElement horizontal line - - updateDraggableElementPosition( - newElementPosition, - draggableElementTime, - true, - true, + let segmentElement = timelineRef.current.querySelector( + `[data-segment-id="${alignedSegmentTime}"]`, ); + + if (!segmentElement) { + // segment not found, maybe we collapsed over a collapsible segment + let searchTime = alignedSegmentTime; + while (searchTime >= timelineStartAligned - timelineDuration) { + // Decrement currentTime by segmentDuration + searchTime -= segmentDuration; + segmentElement = timelineRef.current.querySelector( + `[data-segment-id="${searchTime}"]`, + ); + + if (segmentElement) { + // segmentElement found + break; + } + } + } + + if (segmentElement) { + const timelineRect = timelineRef.current.getBoundingClientRect(); + const timelineTopAbsolute = timelineRect.top; + const rect = segmentElement.getBoundingClientRect(); + const segmentTop = + rect.top + scrolled - timelineTopAbsolute - segmentHeight / 2; + const offset = + ((draggableElementTime - alignedSegmentTime) / segmentDuration) * + segmentHeight; + const newElementPosition = segmentTop - offset; + + updateDraggableElementPosition( + newElementPosition, + draggableElementTime, + true, + true, + ); + } } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps }, [ draggableElementTime, + timelineDuration, + segmentDuration, showDraggableElement, draggableElementRef, timelineStartAligned, + timelineRef, + timelineCollapsed, + segments, ]); return { handleMouseDown, handleMouseUp, handleMouseMove }; diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 15c795713..156d16b2d 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -196,6 +196,8 @@ export default function EventView({ [reviewItems], ); + const [motionOnly, setMotionOnly] = useState(false); + if (!config) { return ; } @@ -253,6 +255,9 @@ export default function EventView({ reviewSummary={reviewSummary} filter={filter} onUpdateFilter={updateFilter} + severity={severity} + motionOnly={motionOnly} + setMotionOnly={setMotionOnly} /> ) : ( )} @@ -603,6 +609,7 @@ type MotionReviewProps = { timeRange: { before: number; after: number }; startTime?: number; filter?: ReviewFilter; + motionOnly?: boolean; onOpenRecording: (data: RecordingStartingPoint) => void; }; function MotionReview({ @@ -612,6 +619,7 @@ function MotionReview({ timeRange, startTime, filter, + motionOnly = false, onOpenRecording, }: MotionReviewProps) { const segmentDuration = 30; @@ -784,6 +792,7 @@ function MotionReview({ timestampSpread={15} timelineStart={timeRangeSegments.end} timelineEnd={timeRangeSegments.start} + motionOnly={motionOnly} showHandlebar handlebarTime={currentTime} setHandlebarTime={setCurrentTime} diff --git a/web/themes/theme-default.css b/web/themes/theme-default.css index 19ee09bb3..6dba4474e 100644 --- a/web/themes/theme-default.css +++ b/web/themes/theme-default.css @@ -55,7 +55,7 @@ --border: 214.3 31.8% 91.4%; --input: hsl(214.3 31.8% 91.4%); - --input: 214.3 31.8% 91.4%; + --input: 0 0 85%; --ring: hsl(222.2 84% 4.9%); --ring: 222.2 84% 4.9%; @@ -140,7 +140,7 @@ --border: 0 0% 32%; --input: hsl(217.2 32.6% 17.5%); - --input: 217.2 32.6% 17.5%; + --input: 0 0 25%; --ring: hsl(212.7 26.8% 83.9%); --ring: 212.7 26.8% 83.9%;