mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-12-19 19:06:16 +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