mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	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
This commit is contained in:
		
							parent
							
								
									aa99e11e1a
								
							
						
					
					
						commit
						cdd6ac9071
					
				
							
								
								
									
										241
									
								
								web/src/components/timeline/EventReviewTimeline.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										241
									
								
								web/src/components/timeline/EventReviewTimeline.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -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<HTMLDivElement>; | ||||
| }; | ||||
| 
 | ||||
| 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<number>(0); | ||||
|   const scrollTimeRef = useRef<HTMLDivElement>(null); | ||||
|   const timelineRef = useRef<HTMLDivElement>(null); | ||||
|   const currentTimeRef = useRef<HTMLDivElement>(null); | ||||
|   const observer = useRef<ResizeObserver | null>(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 ( | ||||
|         <EventSegment | ||||
|           key={segmentTime} | ||||
|           events={events} | ||||
|           segmentDuration={segmentDuration} | ||||
|           segmentTime={segmentTime} | ||||
|           timestampSpread={timestampSpread} | ||||
|           showMinimap={showMinimap} | ||||
|           minimapStartTime={minimapStartTime} | ||||
|           minimapEndTime={minimapEndTime} | ||||
|           severityType={severityType} | ||||
|         /> | ||||
|       ); | ||||
|     }); | ||||
|   }, [ | ||||
|     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 ( | ||||
|     <div | ||||
|       ref={timelineRef} | ||||
|       className={`relative w-[120px] md:w-[100px] h-[100dvh] overflow-y-scroll no-scrollbar bg-secondary ${ | ||||
|         isDragging && showHandlebar ? "cursor-grabbing" : "cursor-auto" | ||||
|       }`}
 | ||||
|     > | ||||
|       <div className="flex flex-col">{segments}</div> | ||||
|       {showHandlebar && ( | ||||
|         <div className={`absolute left-0 top-0 z-20 w-full `} role="scrollbar"> | ||||
|           <div className={`flex items-center justify-center `}> | ||||
|             <div | ||||
|               ref={scrollTimeRef} | ||||
|               className={`relative w-full ${ | ||||
|                 isDragging ? "cursor-grabbing" : "cursor-grab" | ||||
|               }`}
 | ||||
|               onMouseDown={handleMouseDown} | ||||
|             > | ||||
|               <div | ||||
|                 className={`bg-destructive rounded-full mx-auto ${ | ||||
|                   segmentDuration < 60 ? "w-20" : "w-16" | ||||
|                 } h-5 flex items-center justify-center`}
 | ||||
|               > | ||||
|                 <div | ||||
|                   ref={currentTimeRef} | ||||
|                   className="text-white text-xs z-10" | ||||
|                 ></div> | ||||
|               </div> | ||||
|               <div className="absolute h-1 w-full bg-destructive top-1/2 transform -translate-y-1/2"></div> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export default EventReviewTimeline; | ||||
							
								
								
									
										265
									
								
								web/src/components/timeline/EventSegment.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										265
									
								
								web/src/components/timeline/EventSegment.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -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 && ( | ||||
|         <div className="absolute inset-0 -bottom-5 w-full flex items-center justify-center text-xs text-primary font-medium z-20 text-center text-[9px]"> | ||||
|           {new Date(alignedMinimapStartTime * 1000).toLocaleTimeString([], { | ||||
|             hour: "2-digit", | ||||
|             minute: "2-digit", | ||||
|             month: "short", | ||||
|             day: "2-digit", | ||||
|           })} | ||||
|         </div> | ||||
|       )} | ||||
| 
 | ||||
|       {isLastSegmentInMinimap && ( | ||||
|         <div className="absolute inset-0 -top-1 w-full flex items-center justify-center text-xs text-primary font-medium z-20 text-center text-[9px]"> | ||||
|           {new Date(alignedMinimapEndTime * 1000).toLocaleTimeString([], { | ||||
|             hour: "2-digit", | ||||
|             minute: "2-digit", | ||||
|             month: "short", | ||||
|             day: "2-digit", | ||||
|           })} | ||||
|         </div> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| function Tick({ | ||||
|   isFirstSegmentInMinimap, | ||||
|   isLastSegmentInMinimap, | ||||
|   timestamp, | ||||
|   timestampSpread, | ||||
| }: TickSegmentProps) { | ||||
|   return ( | ||||
|     <div className="w-5 h-2 flex justify-left items-end"> | ||||
|       {!isFirstSegmentInMinimap && !isLastSegmentInMinimap && ( | ||||
|         <div | ||||
|           className={`h-0.5 ${ | ||||
|             timestamp.getMinutes() % timestampSpread === 0 && | ||||
|             timestamp.getSeconds() === 0 | ||||
|               ? "w-4 bg-gray-400" | ||||
|               : "w-2 bg-gray-600" | ||||
|           }`}
 | ||||
|         ></div> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| function Timestamp({ | ||||
|   isFirstSegmentInMinimap, | ||||
|   isLastSegmentInMinimap, | ||||
|   timestamp, | ||||
|   timestampSpread, | ||||
|   segmentKey, | ||||
| }: TimestampSegmentProps) { | ||||
|   return ( | ||||
|     <div className="w-10 h-2 flex justify-left items-top z-10"> | ||||
|       {!isFirstSegmentInMinimap && !isLastSegmentInMinimap && ( | ||||
|         <div | ||||
|           key={`${segmentKey}_timestamp`} | ||||
|           className="text-[8px] text-gray-400" | ||||
|         > | ||||
|           {timestamp.getMinutes() % timestampSpread === 0 && | ||||
|             timestamp.getSeconds() === 0 && | ||||
|             timestamp.toLocaleTimeString([], { | ||||
|               hour: "2-digit", | ||||
|               minute: "2-digit", | ||||
|             })} | ||||
|         </div> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| 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 ( | ||||
|     <div key={segmentKey} className={segmentClasses}> | ||||
|       <MinimapBounds | ||||
|         isFirstSegmentInMinimap={isFirstSegmentInMinimap} | ||||
|         isLastSegmentInMinimap={isLastSegmentInMinimap} | ||||
|         alignedMinimapStartTime={alignedMinimapStartTime} | ||||
|         alignedMinimapEndTime={alignedMinimapEndTime} | ||||
|       /> | ||||
| 
 | ||||
|       <Tick | ||||
|         isFirstSegmentInMinimap={isFirstSegmentInMinimap} | ||||
|         isLastSegmentInMinimap={isLastSegmentInMinimap} | ||||
|         timestamp={timestamp} | ||||
|         timestampSpread={timestampSpread} | ||||
|       /> | ||||
| 
 | ||||
|       <Timestamp | ||||
|         isFirstSegmentInMinimap={isFirstSegmentInMinimap} | ||||
|         isLastSegmentInMinimap={isLastSegmentInMinimap} | ||||
|         timestamp={timestamp} | ||||
|         timestampSpread={timestampSpread} | ||||
|         segmentKey={segmentKey} | ||||
|       /> | ||||
| 
 | ||||
|       {severity == displaySeverityType && ( | ||||
|         <div className="mr-3 w-2 h-2 flex justify-left items-end"> | ||||
|           <div | ||||
|             key={`${segmentKey}_primary_data`} | ||||
|             className={` | ||||
|           w-full h-2 bg-gradient-to-r | ||||
|           ${roundBottom ? "rounded-bl-full rounded-br-full" : ""} | ||||
|           ${roundTop ? "rounded-tl-full rounded-tr-full" : ""} | ||||
|           ${severityColors[severity]} | ||||
| 
 | ||||
|           `}
 | ||||
|           ></div> | ||||
|         </div> | ||||
|       )} | ||||
| 
 | ||||
|       {severity != displaySeverityType && ( | ||||
|         <div className="h-2 flex flex-grow justify-end items-end"> | ||||
|           <div | ||||
|             key={`${segmentKey}_secondary_data`} | ||||
|             className={` | ||||
|             w-1 h-2 bg-gradient-to-r | ||||
|             ${roundBottom ? "rounded-bl-full rounded-br-full" : ""} | ||||
|             ${roundTop ? "rounded-tl-full rounded-tr-full" : ""} | ||||
|             ${severityColors[severity]} | ||||
| 
 | ||||
|           `}
 | ||||
|           ></div> | ||||
|         </div> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export default EventSegment; | ||||
							
								
								
									
										37
									
								
								web/src/hooks/use-event-utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								web/src/hooks/use-event-utils.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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 }; | ||||
| }; | ||||
							
								
								
									
										127
									
								
								web/src/hooks/use-handle-dragging.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								web/src/hooks/use-handle-dragging.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,127 @@ | ||||
| import { useCallback } from "react"; | ||||
| 
 | ||||
| interface DragHandlerProps { | ||||
|   contentRef: React.RefObject<HTMLElement>; | ||||
|   timelineRef: React.RefObject<HTMLDivElement>; | ||||
|   scrollTimeRef: React.RefObject<HTMLDivElement>; | ||||
|   alignDateToTimeline: (time: number) => number; | ||||
|   segmentDuration: number; | ||||
|   showHandlebar: boolean; | ||||
|   timelineDuration: number; | ||||
|   timelineStart: number; | ||||
|   isDragging: boolean; | ||||
|   setIsDragging: React.Dispatch<React.SetStateAction<boolean>>; | ||||
|   currentTimeRef: React.MutableRefObject<HTMLDivElement | null>; | ||||
| } | ||||
| 
 | ||||
| // 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<HTMLDivElement>) => { | ||||
|       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; | ||||
							
								
								
									
										134
									
								
								web/src/hooks/use-segment-utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								web/src/hooks/use-segment-utils.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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 }; | ||||
| }; | ||||
| @ -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 */ | ||||
| } | ||||
|  | ||||
| @ -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<FrigateConfig>("config"); | ||||
|   const [timeline, setTimeline] = useState<string | undefined>(undefined); | ||||
|   const contentRef = useRef<HTMLDivElement>(null); | ||||
|   const [mockEvents, setMockEvents] = useState<ReviewSegment[]>([]); | ||||
| 
 | ||||
|   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 ( | ||||
|     <> | ||||
|       <Heading as="h2">UI Playground</Heading> | ||||
| @ -111,18 +155,24 @@ function UIPlayground() { | ||||
|         </div> | ||||
|       )} | ||||
| 
 | ||||
|       <div className="flex"> | ||||
|         <div className="flex-grow"> | ||||
|           <div ref={contentRef}> | ||||
|             <Heading as="h4" className="my-5"> | ||||
|               Color scheme | ||||
|             </Heading> | ||||
|             <p className="text-small"> | ||||
|               Colors as set by the current theme. See the{" "} | ||||
|         <a className="underline" href="https://ui.shadcn.com/docs/theming"> | ||||
|               <a | ||||
|                 className="underline" | ||||
|                 href="https://ui.shadcn.com/docs/theming" | ||||
|               > | ||||
|                 shadcn theming docs | ||||
|               </a>{" "} | ||||
|               for usage. | ||||
|             </p> | ||||
| 
 | ||||
|       <div className="w-72 my-5"> | ||||
|             <div className="my-5"> | ||||
|               {colors.map((color, index) => ( | ||||
|                 <ColorSwatch | ||||
|                   key={index} | ||||
| @ -131,6 +181,25 @@ function UIPlayground() { | ||||
|                 /> | ||||
|               ))} | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div className="flex-none"> | ||||
|           <EventReviewTimeline | ||||
|             segmentDuration={60} // seconds per segment
 | ||||
|             timestampSpread={15} // minutes between each major timestamp
 | ||||
|             timelineStart={Math.floor(Date.now() / 1000)} // start of the timeline - all times are numeric, not Date objects
 | ||||
|             timelineDuration={24 * 60 * 60} // in minutes, defaults to 24 hours
 | ||||
|             showHandlebar // show / hide the handlebar
 | ||||
|             handlebarTime={Math.floor(Date.now() / 1000) - 27 * 60} // set the time of the handlebar
 | ||||
|             showMinimap // show / hide the minimap
 | ||||
|             minimapStartTime={Math.floor(Date.now() / 1000) - 35 * 60} // start time of the minimap - the earlier time (eg 1:00pm)
 | ||||
|             minimapEndTime={Math.floor(Date.now() / 1000) - 21 * 60} // end of the minimap - the later time (eg 3:00pm)
 | ||||
|             events={mockEvents} // events, including new has_been_reviewed and severity properties
 | ||||
|             severityType={"alert"} // choose the severity type for the middle line - all other severity types are to the right
 | ||||
|             contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later
 | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
							
								
								
									
										20
									
								
								web/src/types/review.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								web/src/types/review.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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[]; | ||||
|   }; | ||||
| @ -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": { | ||||
|  | ||||
							
								
								
									
										287
									
								
								web/themes/tailwind-base.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										287
									
								
								web/themes/tailwind-base.css
									
									
									
									
									
										Normal file
									
								
							| @ -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%; | ||||
| } | ||||
| @ -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 { | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user