From cdd6ac9071cbc6c8c17bbd1fc7bb40ace5b457a2 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 20 Feb 2024 17:22:59 -0600 Subject: [PATCH] Implement event review timeline (#9941) * initial implementation of review timeline * hooks * clean up and comments * reorganize components * colors and tweaks * remove touch events for now * remove touch events for now * fix vite config * use unix timestamps everywhere * fix corner rounding * comparison * use ReviewSegment type * update mock review event generator * severity type enum * remove testing code --- .../timeline/EventReviewTimeline.tsx | 241 +++++++++++++++ web/src/components/timeline/EventSegment.tsx | 265 ++++++++++++++++ web/src/hooks/use-event-utils.ts | 37 +++ web/src/hooks/use-handle-dragging.ts | 127 ++++++++ web/src/hooks/use-segment-utils.ts | 134 ++++++++ web/src/index.css | 12 + web/src/pages/UIPlayground.tsx | 105 +++++-- web/src/types/review.ts | 20 ++ web/tailwind.config.js | 12 + web/themes/tailwind-base.css | 287 ++++++++++++++++++ web/themes/theme-default.css | 9 + 11 files changed, 1231 insertions(+), 18 deletions(-) create mode 100644 web/src/components/timeline/EventReviewTimeline.tsx create mode 100644 web/src/components/timeline/EventSegment.tsx create mode 100644 web/src/hooks/use-event-utils.ts create mode 100644 web/src/hooks/use-handle-dragging.ts create mode 100644 web/src/hooks/use-segment-utils.ts create mode 100644 web/src/types/review.ts create mode 100644 web/themes/tailwind-base.css diff --git a/web/src/components/timeline/EventReviewTimeline.tsx b/web/src/components/timeline/EventReviewTimeline.tsx new file mode 100644 index 000000000..4b6735093 --- /dev/null +++ b/web/src/components/timeline/EventReviewTimeline.tsx @@ -0,0 +1,241 @@ +import useDraggableHandler from "@/hooks/use-handle-dragging"; +import { + useEffect, + useCallback, + useMemo, + useRef, + useState, + RefObject, +} from "react"; +import EventSegment from "./EventSegment"; +import { useEventUtils } from "@/hooks/use-event-utils"; +import { ReviewSegment, ReviewSeverity } from "@/types/review"; + +export type EventReviewTimelineProps = { + segmentDuration: number; + timestampSpread: number; + timelineStart: number; + timelineDuration?: number; + showHandlebar?: boolean; + handlebarTime?: number; + showMinimap?: boolean; + minimapStartTime?: number; + minimapEndTime?: number; + events: ReviewSegment[]; + severityType: ReviewSeverity; + contentRef: RefObject; +}; + +export function EventReviewTimeline({ + segmentDuration, + timestampSpread, + timelineStart, + timelineDuration = 24 * 60 * 60, + showHandlebar = false, + handlebarTime, + showMinimap = false, + minimapStartTime, + minimapEndTime, + events, + severityType, + contentRef, +}: EventReviewTimelineProps) { + const [isDragging, setIsDragging] = useState(false); + const [currentTimeSegment, setCurrentTimeSegment] = useState(0); + const scrollTimeRef = useRef(null); + const timelineRef = useRef(null); + const currentTimeRef = useRef(null); + const observer = useRef(null); + + const { alignDateToTimeline } = useEventUtils(events, segmentDuration); + + const { handleMouseDown, handleMouseUp, handleMouseMove } = + useDraggableHandler({ + contentRef, + timelineRef, + scrollTimeRef, + alignDateToTimeline, + segmentDuration, + showHandlebar, + timelineDuration, + timelineStart, + isDragging, + setIsDragging, + currentTimeRef, + }); + + function handleResize() { + // TODO: handle screen resize for mobile + if (timelineRef.current && contentRef.current) { + } + } + + useEffect(() => { + if (contentRef.current) { + const content = contentRef.current; + observer.current = new ResizeObserver(() => { + handleResize(); + }); + observer.current.observe(content); + return () => { + observer.current?.unobserve(content); + }; + } + }, []); + + // Generate segments for the timeline + const generateSegments = useCallback(() => { + const segmentCount = timelineDuration / segmentDuration; + const segmentAlignedTime = alignDateToTimeline(timelineStart); + + return Array.from({ length: segmentCount }, (_, index) => { + const segmentTime = segmentAlignedTime - index * segmentDuration; + + return ( + + ); + }); + }, [ + segmentDuration, + timestampSpread, + timelineStart, + timelineDuration, + showMinimap, + minimapStartTime, + minimapEndTime, + ]); + + const segments = useMemo( + () => generateSegments(), + [ + segmentDuration, + timestampSpread, + timelineStart, + timelineDuration, + showMinimap, + minimapStartTime, + minimapEndTime, + events, + ] + ); + + useEffect(() => { + if (showHandlebar) { + requestAnimationFrame(() => { + if (currentTimeRef.current && currentTimeSegment) { + currentTimeRef.current.textContent = new Date( + currentTimeSegment * 1000 + ).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + ...(segmentDuration < 60 && { second: "2-digit" }), + }); + } + }); + } + }, [currentTimeSegment, showHandlebar]); + + useEffect(() => { + if (timelineRef.current && handlebarTime && showHandlebar) { + const { scrollHeight: timelineHeight } = timelineRef.current; + + // Calculate the height of an individual segment + const segmentHeight = + timelineHeight / (timelineDuration / segmentDuration); + + // Calculate the segment index corresponding to the target time + const alignedHandlebarTime = alignDateToTimeline(handlebarTime); + const segmentIndex = Math.ceil( + (timelineStart - alignedHandlebarTime) / segmentDuration + ); + + // Calculate the top position based on the segment index + const newTopPosition = Math.max(0, segmentIndex * segmentHeight); + + // Set the top position of the handle + const thumb = scrollTimeRef.current; + if (thumb) { + requestAnimationFrame(() => { + thumb.style.top = `${newTopPosition}px`; + }); + } + + setCurrentTimeSegment(alignedHandlebarTime); + } + }, [ + handlebarTime, + segmentDuration, + showHandlebar, + timelineDuration, + timelineStart, + alignDateToTimeline, + ]); + + useEffect(() => { + generateSegments(); + if (!currentTimeSegment && !handlebarTime) { + setCurrentTimeSegment(timelineStart); + } + // TODO: touch events for mobile + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [ + currentTimeSegment, + generateSegments, + timelineStart, + handleMouseUp, + handleMouseMove, + ]); + + return ( +
+
{segments}
+ {showHandlebar && ( +
+
+
+
+
+
+
+
+
+
+ )} +
+ ); +} + +export default EventReviewTimeline; diff --git a/web/src/components/timeline/EventSegment.tsx b/web/src/components/timeline/EventSegment.tsx new file mode 100644 index 000000000..3650b5922 --- /dev/null +++ b/web/src/components/timeline/EventSegment.tsx @@ -0,0 +1,265 @@ +import { useEventUtils } from "@/hooks/use-event-utils"; +import { useSegmentUtils } from "@/hooks/use-segment-utils"; +import { ReviewSegment, ReviewSeverity } from "@/types/review"; +import { useMemo } from "react"; + +type EventSegmentProps = { + events: ReviewSegment[]; + segmentTime: number; + segmentDuration: number; + timestampSpread: number; + showMinimap: boolean; + minimapStartTime?: number; + minimapEndTime?: number; + severityType: ReviewSeverity; +}; + +type MinimapSegmentProps = { + isFirstSegmentInMinimap: boolean; + isLastSegmentInMinimap: boolean; + alignedMinimapStartTime: number; + alignedMinimapEndTime: number; +}; + +type TickSegmentProps = { + isFirstSegmentInMinimap: boolean; + isLastSegmentInMinimap: boolean; + timestamp: Date; + timestampSpread: number; +}; + +type TimestampSegmentProps = { + isFirstSegmentInMinimap: boolean; + isLastSegmentInMinimap: boolean; + timestamp: Date; + timestampSpread: number; + segmentKey: number; +}; + +function MinimapBounds({ + isFirstSegmentInMinimap, + isLastSegmentInMinimap, + alignedMinimapStartTime, + alignedMinimapEndTime, +}: MinimapSegmentProps) { + return ( + <> + {isFirstSegmentInMinimap && ( +
+ {new Date(alignedMinimapStartTime * 1000).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + month: "short", + day: "2-digit", + })} +
+ )} + + {isLastSegmentInMinimap && ( +
+ {new Date(alignedMinimapEndTime * 1000).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + month: "short", + day: "2-digit", + })} +
+ )} + + ); +} + +function Tick({ + isFirstSegmentInMinimap, + isLastSegmentInMinimap, + timestamp, + timestampSpread, +}: TickSegmentProps) { + return ( +
+ {!isFirstSegmentInMinimap && !isLastSegmentInMinimap && ( +
+ )} +
+ ); +} + +function Timestamp({ + isFirstSegmentInMinimap, + isLastSegmentInMinimap, + timestamp, + timestampSpread, + segmentKey, +}: TimestampSegmentProps) { + return ( +
+ {!isFirstSegmentInMinimap && !isLastSegmentInMinimap && ( +
+ {timestamp.getMinutes() % timestampSpread === 0 && + timestamp.getSeconds() === 0 && + timestamp.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} +
+ )} +
+ ); +} + +export function EventSegment({ + events, + segmentTime, + segmentDuration, + timestampSpread, + showMinimap, + minimapStartTime, + minimapEndTime, + severityType, +}: EventSegmentProps) { + const { + getSeverity, + getReviewed, + displaySeverityType, + shouldShowRoundedCorners, + } = useSegmentUtils(segmentDuration, events, severityType); + + const { alignDateToTimeline } = useEventUtils(events, segmentDuration); + + const severity = useMemo( + () => getSeverity(segmentTime), + [getSeverity, segmentTime] + ); + const reviewed = useMemo( + () => getReviewed(segmentTime), + [getReviewed, segmentTime] + ); + const { roundTop, roundBottom } = useMemo( + () => shouldShowRoundedCorners(segmentTime), + [shouldShowRoundedCorners, segmentTime] + ); + + const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]); + const segmentKey = useMemo(() => segmentTime, [segmentTime]); + + const alignedMinimapStartTime = useMemo( + () => alignDateToTimeline(minimapStartTime ?? 0), + [minimapStartTime, alignDateToTimeline] + ); + const alignedMinimapEndTime = useMemo( + () => alignDateToTimeline(minimapEndTime ?? 0), + [minimapEndTime, alignDateToTimeline] + ); + + const isInMinimapRange = useMemo(() => { + return ( + showMinimap && + minimapStartTime && + minimapEndTime && + segmentTime > minimapStartTime && + segmentTime < minimapEndTime + ); + }, [showMinimap, minimapStartTime, minimapEndTime, segmentTime]); + + const isFirstSegmentInMinimap = useMemo(() => { + return showMinimap && segmentTime === alignedMinimapStartTime; + }, [showMinimap, segmentTime, alignedMinimapStartTime]); + + const isLastSegmentInMinimap = useMemo(() => { + return showMinimap && segmentTime === alignedMinimapEndTime; + }, [showMinimap, segmentTime, alignedMinimapEndTime]); + + const segmentClasses = `flex flex-row ${ + showMinimap + ? isInMinimapRange + ? "bg-card" + : isLastSegmentInMinimap + ? "" + : "opacity-70" + : "" + } ${ + isFirstSegmentInMinimap || isLastSegmentInMinimap + ? "relative h-2 border-b border-gray-500" + : "" + }`; + + const severityColors: { [key: number]: string } = { + 1: reviewed + ? "from-severity_motion-dimmed/30 to-severity_motion/30" + : "from-severity_motion-dimmed to-severity_motion", + 2: reviewed + ? "from-severity_detection-dimmed/30 to-severity_detection/30" + : "from-severity_detection-dimmed to-severity_detection", + 3: reviewed + ? "from-severity_alert-dimmed/30 to-severity_alert/30" + : "from-severity_alert-dimmed to-severity_alert", + }; + + return ( +
+ + + + + + + {severity == displaySeverityType && ( +
+
+
+ )} + + {severity != displaySeverityType && ( +
+
+
+ )} +
+ ); +} + +export default EventSegment; diff --git a/web/src/hooks/use-event-utils.ts b/web/src/hooks/use-event-utils.ts new file mode 100644 index 000000000..d98ede6d8 --- /dev/null +++ b/web/src/hooks/use-event-utils.ts @@ -0,0 +1,37 @@ +import { useCallback } from 'react'; +import { ReviewSegment } from '@/types/review'; + +export const useEventUtils = (events: ReviewSegment[], segmentDuration: number) => { + const isStartOfEvent = useCallback((time: number): boolean => { + return events.some((event) => { + const segmentStart = getSegmentStart(event.start_time); + return time >= segmentStart && time < segmentStart + segmentDuration; + }); + }, [events, segmentDuration]); + + const isEndOfEvent = useCallback((time: number): boolean => { + return events.some((event) => { + if (typeof event.end_time === 'number') { + const segmentEnd = getSegmentEnd(event.end_time); + return time >= segmentEnd - segmentDuration && time < segmentEnd; + } + return false; // Return false if end_time is undefined + }); + }, [events, segmentDuration]); + + const getSegmentStart = useCallback((time: number): number => { + return Math.floor(time / (segmentDuration)) * (segmentDuration); + }, [segmentDuration]); + + const getSegmentEnd = useCallback((time: number): number => { + return Math.ceil(time / (segmentDuration)) * (segmentDuration); + }, [segmentDuration]); + + const alignDateToTimeline = useCallback((time: number): number => { + const remainder = time % (segmentDuration); + const adjustment = remainder !== 0 ? segmentDuration - remainder : 0; + return time + adjustment; + }, [segmentDuration]); + + return { isStartOfEvent, isEndOfEvent, getSegmentStart, getSegmentEnd, alignDateToTimeline }; +}; diff --git a/web/src/hooks/use-handle-dragging.ts b/web/src/hooks/use-handle-dragging.ts new file mode 100644 index 000000000..6d74384ca --- /dev/null +++ b/web/src/hooks/use-handle-dragging.ts @@ -0,0 +1,127 @@ +import { useCallback } from "react"; + +interface DragHandlerProps { + contentRef: React.RefObject; + timelineRef: React.RefObject; + scrollTimeRef: React.RefObject; + alignDateToTimeline: (time: number) => number; + segmentDuration: number; + showHandlebar: boolean; + timelineDuration: number; + timelineStart: number; + isDragging: boolean; + setIsDragging: React.Dispatch>; + currentTimeRef: React.MutableRefObject; +} + +// TODO: handle mobile touch events +function useDraggableHandler({ + contentRef, + timelineRef, + scrollTimeRef, + alignDateToTimeline, + segmentDuration, + showHandlebar, + timelineDuration, + timelineStart, + isDragging, + setIsDragging, + currentTimeRef, +}: DragHandlerProps) { + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }, + [setIsDragging] + ); + + const handleMouseUp = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (isDragging) { + setIsDragging(false); + } + }, + [isDragging, setIsDragging] + ); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!contentRef.current || !timelineRef.current || !scrollTimeRef.current) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + if (isDragging) { + const { + scrollHeight: timelineHeight, + clientHeight: visibleTimelineHeight, + scrollTop: scrolled, + offsetTop: timelineTop, + } = timelineRef.current; + + const segmentHeight = + timelineHeight / (timelineDuration / segmentDuration); + + const getCumulativeScrollTop = ( + element: HTMLElement | null + ) => { + let scrollTop = 0; + while (element) { + scrollTop += element.scrollTop; + element = element.parentElement; + } + return scrollTop; + }; + + const parentScrollTop = getCumulativeScrollTop(timelineRef.current); + + const newHandlePosition = Math.min( + visibleTimelineHeight - timelineTop + parentScrollTop, + Math.max( + segmentHeight + scrolled, + e.clientY - timelineTop + parentScrollTop + ) + ); + + const segmentIndex = Math.floor(newHandlePosition / segmentHeight); + const segmentStartTime = alignDateToTimeline( + timelineStart - segmentIndex * segmentDuration + ); + + if (showHandlebar) { + const thumb = scrollTimeRef.current; + requestAnimationFrame(() => { + thumb.style.top = `${newHandlePosition - segmentHeight}px`; + if (currentTimeRef.current) { + currentTimeRef.current.textContent = new Date( + segmentStartTime*1000 + ).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + ...(segmentDuration < 60 && { second: "2-digit" }), + }); + } + }); + } + } + }, + [ + isDragging, + contentRef, + segmentDuration, + showHandlebar, + timelineDuration, + timelineStart, + ] + ); + + return { handleMouseDown, handleMouseUp, handleMouseMove }; +} + +export default useDraggableHandler; diff --git a/web/src/hooks/use-segment-utils.ts b/web/src/hooks/use-segment-utils.ts new file mode 100644 index 000000000..c31393a2e --- /dev/null +++ b/web/src/hooks/use-segment-utils.ts @@ -0,0 +1,134 @@ +import { useCallback, useMemo } from 'react'; +import { ReviewSegment } from '@/types/review'; + +export const useSegmentUtils = ( + segmentDuration: number, + events: ReviewSegment[], + severityType: string, +) => { + const getSegmentStart = useCallback((time: number): number => { + return Math.floor(time / (segmentDuration)) * (segmentDuration); + }, [segmentDuration]); + + const getSegmentEnd = useCallback((time: number | undefined): number => { + if (time) { + return Math.ceil(time / (segmentDuration)) * (segmentDuration); + } else { + return (Date.now()/1000)+(segmentDuration); + } + }, [segmentDuration]); + + const mapSeverityToNumber = useCallback((severity: string): number => { + switch (severity) { + case "significant_motion": + return 1; + case "detection": + return 2; + case "alert": + return 3; + default: + return 0; + } + }, []); + + const displaySeverityType = useMemo( + () => mapSeverityToNumber(severityType ?? ""), + [severityType] + ); + + const getSeverity = useCallback((time: number): number => { + const activeEvents = events?.filter((event) => { + const segmentStart = getSegmentStart(event.start_time); + const segmentEnd = getSegmentEnd(event.end_time); + return time >= segmentStart && time < segmentEnd; + }); + if (activeEvents?.length === 0) return 0; // No event at this time + const severityValues = activeEvents?.map((event) => + mapSeverityToNumber(event.severity) + ); + return Math.max(...severityValues); + }, [events, getSegmentStart, getSegmentEnd, mapSeverityToNumber]); + + const getReviewed = useCallback((time: number): boolean => { + return events.some((event) => { + const segmentStart = getSegmentStart(event.start_time); + const segmentEnd = getSegmentEnd(event.end_time); + return ( + time >= segmentStart && time < segmentEnd && event.has_been_reviewed + ); + }); + }, [events, getSegmentStart, getSegmentEnd]); + + const shouldShowRoundedCorners = useCallback( + (segmentTime: number): { roundTop: boolean, roundBottom: boolean } => { + + const prevSegmentTime = segmentTime - segmentDuration; + const nextSegmentTime = segmentTime + segmentDuration; + + const severityEvents = events.filter(e => e.severity === severityType); + + const otherEvents = events.filter(e => e.severity !== severityType); + + const hasPrevSeverityEvent = severityEvents.some(e => { + return ( + prevSegmentTime >= getSegmentStart(e.start_time) && + prevSegmentTime < getSegmentEnd(e.end_time) + ); + }); + + const hasNextSeverityEvent = severityEvents.some(e => { + return ( + nextSegmentTime >= getSegmentStart(e.start_time) && + nextSegmentTime < getSegmentEnd(e.end_time) + ); + }); + + const hasPrevOtherEvent = otherEvents.some(e => { + return ( + prevSegmentTime >= getSegmentStart(e.start_time) && + prevSegmentTime < getSegmentEnd(e.end_time) + ); + }); + + const hasNextOtherEvent = otherEvents.some(e => { + return ( + nextSegmentTime >= getSegmentStart(e.start_time) && + nextSegmentTime < getSegmentEnd(e.end_time) + ); + }); + + const hasOverlappingSeverityEvent = severityEvents.some(e => { + return segmentTime >= getSegmentStart(e.start_time) && + segmentTime < getSegmentEnd(e.end_time) + }); + + const hasOverlappingOtherEvent = otherEvents.some(e => { + return segmentTime >= getSegmentStart(e.start_time) && + segmentTime < getSegmentEnd(e.end_time) + }); + + let roundTop = false; + let roundBottom = false; + + if (hasOverlappingSeverityEvent) { + roundBottom = !hasPrevSeverityEvent; + roundTop = !hasNextSeverityEvent; + } else if (hasOverlappingOtherEvent) { + roundBottom = !hasPrevOtherEvent; + roundTop = !hasNextOtherEvent; + } else { + roundTop = !hasNextSeverityEvent || !hasNextOtherEvent; + roundBottom = !hasPrevSeverityEvent || !hasPrevOtherEvent; + } + + return { + roundTop, + roundBottom + }; + + }, + [events, getSegmentStart, getSegmentEnd, segmentDuration, severityType] + ); + + return { getSegmentStart, getSegmentEnd, getSeverity, displaySeverityType, getReviewed, shouldShowRoundedCorners }; +}; diff --git a/web/src/index.css b/web/src/index.css index abb9da523..0f9156ac8 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,3 +1,4 @@ +@import "/themes/tailwind-base.css"; @import "/themes/theme-default.css"; @import "/themes/theme-blue.css"; @import "/themes/theme-gold.css"; @@ -27,3 +28,14 @@ font-family: "Inter"; src: url("../fonts/Inter-VariableFont_slnt,wght.ttf"); } + +/* Hide scrollbar for Chrome, Safari and Opera */ +.no-scrollbar::-webkit-scrollbar { + display: none; +} + +/* Hide scrollbar for IE, Edge and Firefox */ +.no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} diff --git a/web/src/pages/UIPlayground.tsx b/web/src/pages/UIPlayground.tsx index 1359f77bf..42b4e88f7 100644 --- a/web/src/pages/UIPlayground.tsx +++ b/web/src/pages/UIPlayground.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import Heading from "@/components/ui/heading"; import ActivityScrubber, { ScrubberItem, @@ -9,6 +9,8 @@ import { Event } from "@/types/event"; import ActivityIndicator from "@/components/ui/activity-indicator"; import { useApiHost } from "@/api"; import TimelineScrubber from "@/components/playground/TimelineScrubber"; +import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; +import { ReviewData, ReviewSegment, ReviewSeverity } from "@/types/review"; // Color data const colors = [ @@ -57,9 +59,46 @@ function eventsToScrubberItems(events: Event[]): ScrubberItem[] { })); } +const generateRandomEvent = (): ReviewSegment => { + const start_time = Math.floor(Date.now() / 1000) - Math.random() * 60 * 60; + const end_time = Math.floor(start_time + Math.random() * 60 * 10); + const severities: ReviewSeverity[] = [ + "significant_motion", + "detection", + "alert", + ]; + const severity = severities[Math.floor(Math.random() * severities.length)]; + const has_been_reviewed = Math.random() < 0.2; + const id = new Date(start_time * 1000).toISOString(); // Date string as mock ID + + // You need to provide values for camera, thumb_path, and data + const camera = "CameraXYZ"; + const thumb_path = "/path/to/thumb"; + const data: ReviewData = { + audio: [], + detections: [], + objects: [], + significant_motion_areas: [], + zones: [], + }; + + return { + id, + start_time, + end_time, + severity, + has_been_reviewed, + camera, + thumb_path, + data, + }; +}; + function UIPlayground() { const { data: config } = useSWR("config"); const [timeline, setTimeline] = useState(undefined); + const contentRef = useRef(null); + const [mockEvents, setMockEvents] = useState([]); const onSelect = useCallback(({ items }: { items: string[] }) => { setTimeline(items[0]); @@ -75,6 +114,11 @@ function UIPlayground() { { limit: 10, after: recentTimestamp }, ]); + useMemo(() => { + const initialEvents = Array.from({ length: 50 }, generateRandomEvent); + setMockEvents(initialEvents); + }, []); + return ( <> UI Playground @@ -111,25 +155,50 @@ function UIPlayground() { )} - - Color scheme - -

- Colors as set by the current theme. See the{" "} - - shadcn theming docs - {" "} - for usage. -

+
+
+
+ + Color scheme + +

+ Colors as set by the current theme. See the{" "} + + shadcn theming docs + {" "} + for usage. +

-
- {colors.map((color, index) => ( - + {colors.map((color, index) => ( + + ))} +
+
+
+
+ - ))} +
); diff --git a/web/src/types/review.ts b/web/src/types/review.ts new file mode 100644 index 000000000..16dc2defa --- /dev/null +++ b/web/src/types/review.ts @@ -0,0 +1,20 @@ +export interface ReviewSegment { + id: string; + camera: string; + severity: ReviewSeverity; + start_time: number; + end_time: number; + thumb_path: string; + has_been_reviewed: boolean; + data: ReviewData; + } + + export type ReviewSeverity = "alert" | "detection" | "significant_motion"; + + export type ReviewData = { + audio: string[]; + detections: string[]; + objects: string[]; + significant_motion_areas: number[]; + zones: string[]; + }; \ No newline at end of file diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 120dd8747..e15ec4dc0 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -67,6 +67,18 @@ module.exports = { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))", }, + severity_alert: { + DEFAULT: "hsl(var(--severity_alert))", + dimmed: "hsl(var(--severity_alert_dimmed))", + }, + severity_detection: { + DEFAULT: "hsl(var(--severity_detection))", + dimmed: "hsl(var(--severity_detection_dimmed))", + }, + severity_motion: { + DEFAULT: "hsl(var(--severity_motion))", + dimmed: "hsl(var(--severity_motion_dimmed))", + }, }, keyframes: { "accordion-down": { diff --git a/web/themes/tailwind-base.css b/web/themes/tailwind-base.css new file mode 100644 index 000000000..6b676632c --- /dev/null +++ b/web/themes/tailwind-base.css @@ -0,0 +1,287 @@ +:root { + /* Slate */ + --slate-50: 210 40% 98%; + --slate-100: 210 40% 96.1%; + --slate-200: 214.3 31.8% 91.4%; + --slate-300: 212.7 26.8% 83.9%; + --slate-400: 215 20.2% 65.1%; + --slate-500: 215.4 16.3% 46.9%; + --slate-600: 215.3 19.3% 34.5%; + --slate-700: 215.3 25% 26.7%; + --slate-800: 217.2 32.6% 17.5%; + --slate-900: 222.2 47.4% 11.2%; + --slate-950: 228.6 84% 4.9%; + + /* Gray */ + --gray-50: 210 20% 98%; + --gray-100: 220 14.3% 95.9%; + --gray-200: 220 13% 91%; + --gray-300: 216 12.2% 83.9%; + --gray-400: 217.9 10.6% 64.9%; + --gray-500: 220 8.9% 46.1%; + --gray-600: 215 13.8% 34.1%; + --gray-700: 216.9 19.1% 26.7%; + --gray-800: 215 27.9% 16.9%; + --gray-900: 220.9 39.3% 11%; + --gray-950: 224 71.4% 4.1%; + + /* Zinc */ + --zinc-50: 0 0% 98%; + --zinc-100: 240 4.8% 95.9%; + --zinc-200: 240 5.9% 90%; + --zinc-300: 240 4.9% 83.9%; + --zinc-400: 240 5% 64.9%; + --zinc-500: 240 3.8% 46.1%; + --zinc-600: 240 5.2% 33.9%; + --zinc-700: 240 5.3% 26.1%; + --zinc-800: 240 3.7% 15.9%; + --zinc-900: 240 5.9% 10%; + --zinc-950: 240 10% 3.9%; + + /* Neutral */ + --neutral-50: 0 0% 98%; + --neutral-100: 0 0% 96.1%; + --neutral-200: 0 0% 89.8%; + --neutral-300: 0 0% 83.1%; + --neutral-400: 0 0% 63.9%; + --neutral-500: 0 0% 45.1%; + --neutral-600: 0 0% 32.2%; + --neutral-700: 0 0% 25.1%; + --neutral-800: 0 0% 14.9%; + --neutral-900: 0 0% 9%; + --neutral-950: 0 0% 3.9%; + + /* Stone */ + --stone-50: 60 9.1% 97.8%; + --stone-100: 60 4.8% 95.9%; + --stone-200: 20 5.9% 90%; + --stone-300: 24 5.7% 82.9%; + --stone-400: 24 5.4% 63.9%; + --stone-500: 25 5.3% 44.7%; + --stone-600: 33.3 5.5% 32.4%; + --stone-700: 30 6.3% 25.1%; + --stone-800: 12 6.5% 15.1%; + --stone-900: 24 9.8% 10%; + --stone-950: 20 14.3% 4.1%; + + /* Red */ + --red-50: 0 85.7% 97.3%; + --red-100: 0 93.3% 94.1%; + --red-200: 0 96.3% 89.4%; + --red-300: 0 93.5% 81.8%; + --red-400: 0 90.6% 70.8%; + --red-500: 0 84.2% 60.2%; + --red-600: 0 72.2% 50.6%; + --red-700: 0 73.7% 41.8%; + --red-800: 0 70% 35.3%; + --red-900: 0 62.8% 30.6%; + --red-950: 0 74.7% 15.5%; + + /* Orange */ + --orange-50: 33.3 100% 96.5%; + --orange-100: 34.3 100% 91.8%; + --orange-200: 32.1 97.7% 83.1%; + --orange-300: 30.7 97.2% 72.4%; + --orange-400: 27 96% 61%; + --orange-500: 24.6 95% 53.1%; + --orange-600: 20.5 90.2% 48.2%; + --orange-700: 17.5 88.3% 40.4%; + --orange-800: 15 79.1% 33.7%; + --orange-900: 15.3 74.6% 27.8%; + --orange-950: 13 81.1% 14.5%; + + /* Amber */ + --amber-50: 48 100% 96.1%; + --amber-100: 48 96.5% 88.8%; + --amber-200: 48 96.6% 76.7%; + --amber-300: 45.9 96.7% 64.5%; + --amber-400: 43.3 96.4% 56.3%; + --amber-500: 37.7 92.1% 50.2%; + --amber-600: 32.1 94.6% 43.7%; + --amber-700: 26 90.5% 37.1%; + --amber-800: 22.7 82.5% 31.4%; + --amber-900: 21.7 77.8% 26.5%; + --amber-950: 20.9 91.7% 14.1%; + + /* Yellow */ + --yellow-50: 54.5 91.7% 95.3%; + --yellow-100: 54.9 96.7% 88%; + --yellow-200: 52.8 98.3% 76.9%; + --yellow-300: 50.4 97.8% 63.5%; + --yellow-400: 47.9 95.8% 53.1%; + --yellow-500: 45.4 93.4% 47.5%; + --yellow-600: 40.6 96.1% 40.4%; + --yellow-700: 35.5 91.7% 32.9%; + --yellow-800: 31.8 81% 28.8%; + --yellow-900: 28.4 72.5% 25.7%; + --yellow-950: 26 83.3% 14.1%; + + /* Lime */ + --lime-50: 78.3 92% 95.1%; + --lime-100: 79.6 89.1% 89.2%; + --lime-200: 80.9 88.5% 79.6%; + --lime-300: 82 84.5% 67.1%; + --lime-400: 82.7 78% 55.5%; + --lime-500: 83.7 80.5% 44.3%; + --lime-600: 84.8 85.2% 34.5%; + --lime-700: 85.9 78.4% 27.3%; + --lime-800: 86.3 69% 22.7%; + --lime-900: 87.6 61.2% 20.2%; + --lime-950: 89.3 80.4% 10%; + + /* Green */ + --green-50: 138.5 76.5% 96.7%; + --green-100: 140.6 84.2% 92.5%; + --green-200: 141 78.9% 85.1%; + --green-300: 141.7 76.6% 73.1%; + --green-400: 141.9 69.2% 58%; + --green-500: 142.1 70.6% 45.3%; + --green-600: 142.1 76.2% 36.3%; + --green-700: 142.4 71.8% 29.2%; + --green-800: 142.8 64.2% 24.1%; + --green-900: 143.8 61.2% 20.2%; + --green-950: 144.9 80.4% 10%; + + /* Emerald */ + --emerald-50: 151.8 81% 95.9%; + --emerald-100: 149.3 80.4% 90%; + --emerald-200: 152.4 76% 80.4%; + --emerald-300: 156.2 71.6% 66.9%; + --emerald-400: 158.1 64.4% 51.6%; + --emerald-500: 160.1 84.1% 39.4%; + --emerald-600: 161.4 93.5% 30.4%; + --emerald-700: 162.9 93.5% 24.3%; + --emerald-800: 163.1 88.1% 19.8%; + --emerald-900: 164.2 85.7% 16.5%; + --emerald-950: 165.7 91.3% 9%; + + /* Teal */ + --teal-50: 166.2 76.5% 96.7%; + --teal-100: 167.2 85.5% 89.2%; + --teal-200: 168.4 83.8% 78.2%; + --teal-300: 170.6 76.9% 64.3%; + --teal-400: 172.5 66% 50.4%; + --teal-500: 173.4 80.4% 40%; + --teal-600: 174.7 83.9% 31.6%; + --teal-700: 175.3 77.4% 26.1%; + --teal-800: 176.1 69.4% 21.8%; + --teal-900: 175.9 60.8% 19%; + --teal-950: 178.6 84.3% 10%; + + /* Cyan */ + --cyan-50: 183.2 100% 96.3%; + --cyan-100: 185.1 95.9% 90.4%; + --cyan-200: 186.2 93.5% 81.8%; + --cyan-300: 187 92.4% 69%; + --cyan-400: 187.9 85.7% 53.3%; + --cyan-500: 188.7 94.5% 42.7%; + --cyan-600: 191.6 91.4% 36.5%; + --cyan-700: 192.9 82.3% 31%; + --cyan-800: 194.4 69.6% 27.1%; + --cyan-900: 196.4 63.6% 23.7%; + --cyan-950: 197 78.9% 14.9%; + + /* Sky */ + --sky-50: 204 100% 97.1%; + --sky-100: 204 93.8% 93.7%; + --sky-200: 200.6 94.4% 86.1%; + --sky-300: 199.4 95.5% 73.9%; + --sky-400: 198.4 93.2% 59.6%; + --sky-500: 198.6 88.7% 48.4%; + --sky-600: 200.4 98% 39.4%; + --sky-700: 201.3 96.3% 32.2%; + --sky-800: 201 90% 27.5%; + --sky-900: 202 80.3% 23.9%; + --sky-950: 204 80.2% 15.9%; + + /* Blue */ + --blue-50: 213.8 100% 96.9%; + --blue-100: 214.3 94.6% 92.7%; + --blue-200: 213.3 96.9% 87.3%; + --blue-300: 211.7 96.4% 78.4%; + --blue-400: 213.1 93.9% 67.8%; + --blue-500: 217.2 91.2% 59.8%; + --blue-600: 221.2 83.2% 53.3%; + --blue-700: 224.3 76.3% 48%; + --blue-800: 225.9 70.7% 40.2%; + --blue-900: 224.4 64.3% 32.9%; + --blue-950: 226.2 57% 21%; + + /* Indigo */ + --indigo-50: 225.9 100% 96.7%; + --indigo-100: 226.5 100% 93.9%; + --indigo-200: 228 96.5% 88.8%; + --indigo-300: 229.7 93.5% 81.8%; + --indigo-400: 234.5 89.5% 73.9%; + --indigo-500: 238.7 83.5% 66.7%; + --indigo-600: 243.4 75.4% 58.6%; + --indigo-700: 244.5 57.9% 50.6%; + --indigo-800: 243.7 54.5% 41.4%; + --indigo-900: 242.2 47.4% 34.3%; + --indigo-950: 243.8 47.1% 20%; + + /* Violet */ + --violet-50: 250 100% 97.6%; + --violet-100: 251.4 91.3% 95.5%; + --violet-200: 250.5 95.2% 91.8%; + --violet-300: 252.5 94.7% 85.1%; + --violet-400: 255.1 91.7% 76.3%; + --violet-500: 258.3 89.5% 66.3%; + --violet-600: 262.1 83.3% 57.8%; + --violet-700: 263.4 70% 50.4%; + --violet-800: 263.4 69.3% 42.2%; + --violet-900: 263.5 67.4% 34.9%; + --violet-950: 261.2 72.6% 22.9%; + + /* Purple */ + --purple-50: 270 100% 98%; + --purple-100: 268.7 100% 95.5%; + --purple-200: 268.6 100% 91.8%; + --purple-300: 269.2 97.4% 85.1%; + --purple-400: 270 95.2% 75.3%; + --purple-500: 270.7 91% 65.1%; + --purple-600: 271.5 81.3% 55.9%; + --purple-700: 272.1 71.7% 47.1%; + --purple-800: 272.9 67.2% 39.4%; + --purple-900: 273.6 65.6% 32%; + --purple-950: 273.5 86.9% 21%; + + /* Fuchsia */ + --fuchsia-50: 289.1 100% 97.8%; + --fuchsia-100: 287 100% 95.5%; + --fuchsia-200: 288.3 95.8% 90.6%; + --fuchsia-300: 291.1 93.1% 82.9%; + --fuchsia-400: 292 91.4% 72.5%; + --fuchsia-500: 292.2 84.1% 60.6%; + --fuchsia-600: 293.4 69.5% 48.8%; + --fuchsia-700: 294.7 72.4% 39.8%; + --fuchsia-800: 295.4 70.2% 32.9%; + --fuchsia-900: 296.7 63.6% 28%; + --fuchsia-950: 296.8 90.2% 16.1%; + + /* Pink */ + --pink-50: 327.3 73.3% 97.1%; + --pink-100: 325.7 77.8% 94.7%; + --pink-200: 325.9 84.6% 89.8%; + --pink-300: 327.4 87.1% 81.8%; + --pink-400: 328.6 85.5% 70.2%; + --pink-500: 330.4 81.2% 60.4%; + --pink-600: 333.3 71.4% 50.6%; + --pink-700: 335.1 77.6% 42%; + --pink-800: 335.8 74.4% 35.3%; + --pink-900: 335.9 69% 30.4%; + --pink-950: 336.2 83.9% 17.1%; + + /* Rose */ + --rose-50: 355.7 100% 97.3%; + --rose-100: 355.6 100% 94.7%; + --rose-200: 352.7 96.1% 90%; + --rose-300: 352.6 95.7% 81.8%; + --rose-400: 351.3 94.5% 71.4%; + --rose-500: 349.7 89.2% 60.2%; + --rose-600: 346.8 77.2% 49.8%; + --rose-700: 345.3 82.7% 40.8%; + --rose-800: 343.4 79.7% 34.7%; + --rose-900: 341.5 75.5% 30.4%; + --rose-950: 343.1 87.7% 15.9%; +} diff --git a/web/themes/theme-default.css b/web/themes/theme-default.css index ce094fe99..eef79318a 100644 --- a/web/themes/theme-default.css +++ b/web/themes/theme-default.css @@ -58,6 +58,15 @@ --ring: 222.2 84% 4.9%; --radius: 0.5rem; + + --severity_alert: var(--red-800); + --severity_alert_dimmed: var(--red-500); + + --severity_detection: var(--orange-600); + --severity_detection_dimmed: var(--orange-400); + + --severity_motion: var(--yellow-400); + --severity_motion_dimmed: var(--yellow-200); } .dark {