From a95f4d843ff7eed71eda4bfff181578e3c9aeb9e Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 20 Feb 2024 14:56:28 -0700 Subject: [PATCH] Break apart mobile and desktop views --- web/src/components/card/HistoryCard.tsx | 11 +- .../timeline/EventReviewTimeline.tsx | 2 +- web/src/hooks/use-segment-utils.ts | 41 +- web/src/pages/Events.tsx | 360 +----------------- web/src/pages/UIPlayground.tsx | 1 - web/src/pages/site-navigation.ts | 9 +- web/src/views/events/DesktopEventView.tsx | 359 +++++++++++++++++ web/src/views/events/MobileEventView.tsx | 234 ++++++++++++ 8 files changed, 619 insertions(+), 398 deletions(-) create mode 100644 web/src/views/events/DesktopEventView.tsx create mode 100644 web/src/views/events/MobileEventView.tsx diff --git a/web/src/components/card/HistoryCard.tsx b/web/src/components/card/HistoryCard.tsx index ce880fc08..11f72c8c7 100644 --- a/web/src/components/card/HistoryCard.tsx +++ b/web/src/components/card/HistoryCard.tsx @@ -1,5 +1,4 @@ import useSWR from "swr"; -import PreviewThumbnailPlayer from "../player/PreviewThumbnailPlayer"; import { Card } from "../ui/card"; import { FrigateConfig } from "@/types/frigateConfig"; import ActivityIndicator from "../ui/activity-indicator"; @@ -21,8 +20,10 @@ type HistoryCardProps = { }; export default function HistoryCard({ + // @ts-ignore relevantPreview, timeline, + // @ts-ignore isMobile, onClick, onDelete, @@ -38,14 +39,6 @@ export default function HistoryCard({ className="cursor-pointer my-2 xs:mr-2 w-full xs:w-[48%] sm:w-[284px]" onClick={onClick} > - <>
diff --git a/web/src/components/timeline/EventReviewTimeline.tsx b/web/src/components/timeline/EventReviewTimeline.tsx index 73d5fae10..e4f1cb866 100644 --- a/web/src/components/timeline/EventReviewTimeline.tsx +++ b/web/src/components/timeline/EventReviewTimeline.tsx @@ -208,7 +208,7 @@ export function EventReviewTimeline({ return (
diff --git a/web/src/hooks/use-segment-utils.ts b/web/src/hooks/use-segment-utils.ts index c9d00002d..873fc7f23 100644 --- a/web/src/hooks/use-segment-utils.ts +++ b/web/src/hooks/use-segment-utils.ts @@ -6,12 +6,9 @@ export const useSegmentUtils = ( events: ReviewSegment[], severityType: string ) => { - const getSegmentStart = useCallback( - (time: number): number => { - return Math.floor(time / segmentDuration) * segmentDuration; - }, - [segmentDuration] - ); + const getSegmentStart = useCallback((time: number): number => { + return Math.floor(time / (segmentDuration)) * (segmentDuration); + }, [segmentDuration]); const getSegmentEnd = useCallback( (time: number | undefined): number => { @@ -70,18 +67,15 @@ export const useSegmentUtils = ( [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 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 } => { @@ -156,12 +150,5 @@ export const useSegmentUtils = ( [events, getSegmentStart, getSegmentEnd, segmentDuration, severityType] ); - return { - getSegmentStart, - getSegmentEnd, - getSeverity, - displaySeverityType, - getReviewed, - shouldShowRoundedCorners, - }; -}; + return { getSegmentStart, getSegmentEnd, getSeverity, displaySeverityType, getReviewed, shouldShowRoundedCorners }; +}; \ No newline at end of file diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 0e8d2bfe2..dd8a3a52b 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -1,359 +1,15 @@ -import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer"; -import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; -import ActivityIndicator from "@/components/ui/activity-indicator"; -import { Button } from "@/components/ui/button"; -import { Calendar } from "@/components/ui/calendar"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import { FrigateConfig } from "@/types/frigateConfig"; -import { ReviewSegment, ReviewSeverity } from "@/types/review"; -import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; -import axios from "axios"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { LuCalendar, LuFilter, LuVideo } from "react-icons/lu"; -import { MdCircle } from "react-icons/md"; -import useSWR from "swr"; -import useSWRInfinite from "swr/infinite"; - -const API_LIMIT = 250; +import DesktopEventView from "@/views/events/DesktopEventView"; +import MobileEventView from "@/views/events/MobileEventView"; +import { useMemo } from "react"; export default function Events() { - const { data: config } = useSWR("config"); - const [severity, setSeverity] = useState("alert"); - const contentRef = useRef(null); - - // review paging - - const reviewSearchParams = {}; - const reviewSegmentFetcher = useCallback((key: any) => { - const [path, params] = Array.isArray(key) ? key : [key, undefined]; - return axios.get(path, { params }).then((res) => res.data); + const isMobile = useMemo(() => { + return window.innerWidth < 768; }, []); - const getKey = useCallback( - (index: number, prevData: ReviewSegment[]) => { - if (index > 0) { - const lastDate = prevData[prevData.length - 1].start_time; - const pagedParams = reviewSearchParams - ? { before: lastDate, limit: API_LIMIT } - : { - ...reviewSearchParams, - before: lastDate, - limit: API_LIMIT, - }; - return ["review", pagedParams]; - } - - const params = reviewSearchParams - ? { limit: API_LIMIT } - : { ...reviewSearchParams, limit: API_LIMIT }; - return ["review", params]; - }, - [reviewSearchParams] - ); - - const { - data: reviewPages, - mutate: updateSegments, - size, - setSize, - isValidating, - } = useSWRInfinite(getKey, reviewSegmentFetcher); - - const reviewItems = useMemo(() => { - const all: ReviewSegment[] = []; - const alerts: ReviewSegment[] = []; - const detections: ReviewSegment[] = []; - const motion: ReviewSegment[] = []; - - reviewPages?.forEach((page) => { - page.forEach((segment) => { - all.push(segment); - - switch (segment.severity) { - case "alert": - alerts.push(segment); - break; - case "detection": - detections.push(segment); - break; - default: - motion.push(segment); - break; - } - }); - }); - - return { - all: all, - alert: alerts, - detection: detections, - significant_motion: motion, - }; - }, [reviewPages]); - - const isDone = useMemo( - () => (reviewPages?.at(-1)?.length ?? 0) < API_LIMIT, - [reviewPages] - ); - - // review interaction - - const pagingObserver = useRef(); - const lastReviewRef = useCallback( - (node: HTMLElement | null) => { - if (isValidating) return; - if (pagingObserver.current) pagingObserver.current.disconnect(); - try { - pagingObserver.current = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && !isDone) { - setSize(size + 1); - } - }); - if (node) pagingObserver.current.observe(node); - } catch (e) { - // no op - } - }, - [isValidating, isDone] - ); - - const [minimap, setMinimap] = useState([]); - const minimapObserver = useRef(); - useEffect(() => { - if (!contentRef.current) { - return; - } - - const visibleTimestamps = new Set(); - minimapObserver.current = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - const start = (entry.target as HTMLElement).dataset.start; - - if (!start) { - return; - } - - if (entry.isIntersecting) { - visibleTimestamps.add(start); - } else { - visibleTimestamps.delete(start); - } - - setMinimap([...visibleTimestamps]); - }); - }, - { root: contentRef.current } - ); - - return () => { - minimapObserver.current?.disconnect(); - }; - }, [contentRef]); - const minimapRef = useCallback( - (node: HTMLElement | null) => { - if (!minimapObserver.current) { - return; - } - - try { - if (node) minimapObserver.current.observe(node); - } catch (e) { - // no op - } - }, - [minimapObserver.current] - ); - const minimapBounds = useMemo(() => { - const data = { - start: Math.floor(Date.now() / 1000) - 35 * 60, - end: Math.floor(Date.now() / 1000) - 21 * 60, - }; - const list = minimap.sort(); - - if (list.length > 0) { - data.end = parseFloat(list.at(-1)!!); - data.start = parseFloat(list[0]); - } - - return data; - }, [minimap]); - - // review status - - const setReviewed = useCallback( - async (id: string) => { - const resp = await axios.post(`review/${id}/viewed`); - - if (resp.status == 200) { - updateSegments(); - } - }, - [updateSegments] - ); - - // preview videos - - const previewTimes = useMemo(() => { - if ( - !reviewPages || - reviewPages.length == 0 || - reviewPages.at(-1)!!.length == 0 - ) { - return undefined; - } - - const startDate = new Date(); - startDate.setMinutes(0, 0, 0); - - const endDate = new Date(reviewPages.at(-1)!!.at(-1)!!.end_time); - endDate.setHours(0, 0, 0, 0); - return { - start: startDate.getTime() / 1000, - end: endDate.getTime() / 1000, - }; - }, [reviewPages]); - const { data: allPreviews } = useSWR( - previewTimes - ? `preview/all/start/${previewTimes.start}/end/${previewTimes.end}` - : null, - { revalidateOnFocus: false } - ); - - if (!config) { - return ; + if (isMobile) { + return ; } - return ( -
-
- setSeverity(value)} - > - - - Alerts - - - - Detections - - - - Motion - - -
- - - -
-
- -
- {reviewItems[severity]?.map((value, segIdx) => { - const lastRow = segIdx == reviewItems[severity].length - 1; - const relevantPreview = Object.values(allPreviews || []).find( - (preview) => - preview.camera == value.camera && - preview.start < value.start_time && - preview.end > value.end_time - ); - - return ( -
-
- setReviewed(value.id)} - /> -
- {lastRow && !isDone && } -
- ); - })} -
-
- -
-
- ); -} - -function ReviewCalendarButton() { - const disabledDates = useMemo(() => { - const tomorrow = new Date(); - tomorrow.setHours(tomorrow.getHours() + 24, -1, 0, 0); - const future = new Date(); - future.setFullYear(tomorrow.getFullYear() + 10); - return { from: tomorrow, to: future }; - }, []); - - return ( - - - - - - - - - ); + return ; } diff --git a/web/src/pages/UIPlayground.tsx b/web/src/pages/UIPlayground.tsx index 6163c00ca..68c44ab14 100644 --- a/web/src/pages/UIPlayground.tsx +++ b/web/src/pages/UIPlayground.tsx @@ -117,7 +117,6 @@ function UIPlayground() { useMemo(() => { const initialEvents = Array.from({ length: 50 }, generateRandomEvent); setMockEvents(initialEvents); - console.log(initialEvents); }, []); return ( diff --git a/web/src/pages/site-navigation.ts b/web/src/pages/site-navigation.ts index 18578eb98..fab2bbb3c 100644 --- a/web/src/pages/site-navigation.ts +++ b/web/src/pages/site-navigation.ts @@ -1,7 +1,6 @@ import { LuConstruction, LuFileUp, - LuFilm, LuFlag, LuVideo, } from "react-icons/lu"; @@ -21,18 +20,12 @@ export const navbarLinks = [ }, { id: 3, - icon: LuFilm, - title: "History", - url: "/history", - }, - { - id: 4, icon: LuFileUp, title: "Export", url: "/export", }, { - id: 5, + id: 4, icon: LuConstruction, title: "UI Playground", url: "/playground", diff --git a/web/src/views/events/DesktopEventView.tsx b/web/src/views/events/DesktopEventView.tsx new file mode 100644 index 000000000..213910783 --- /dev/null +++ b/web/src/views/events/DesktopEventView.tsx @@ -0,0 +1,359 @@ +import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer"; +import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; +import ActivityIndicator from "@/components/ui/activity-indicator"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { ReviewSegment, ReviewSeverity } from "@/types/review"; +import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; +import axios from "axios"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { LuCalendar, LuFilter, LuVideo } from "react-icons/lu"; +import { MdCircle } from "react-icons/md"; +import useSWR from "swr"; +import useSWRInfinite from "swr/infinite"; + +const API_LIMIT = 250; + +export default function DesktopEventView() { + const { data: config } = useSWR("config"); + const [severity, setSeverity] = useState("alert"); + const contentRef = useRef(null); + + // review paging + + const reviewSearchParams = {}; + const reviewSegmentFetcher = useCallback((key: any) => { + const [path, params] = Array.isArray(key) ? key : [key, undefined]; + return axios.get(path, { params }).then((res) => res.data); + }, []); + + const getKey = useCallback( + (index: number, prevData: ReviewSegment[]) => { + if (index > 0) { + const lastDate = prevData[prevData.length - 1].start_time; + const pagedParams = reviewSearchParams + ? { before: lastDate, limit: API_LIMIT } + : { + ...reviewSearchParams, + before: lastDate, + limit: API_LIMIT, + }; + return ["review", pagedParams]; + } + + const params = reviewSearchParams + ? { limit: API_LIMIT } + : { ...reviewSearchParams, limit: API_LIMIT }; + return ["review", params]; + }, + [reviewSearchParams] + ); + + const { + data: reviewPages, + mutate: updateSegments, + size, + setSize, + isValidating, + } = useSWRInfinite(getKey, reviewSegmentFetcher); + + const reviewItems = useMemo(() => { + const all: ReviewSegment[] = []; + const alerts: ReviewSegment[] = []; + const detections: ReviewSegment[] = []; + const motion: ReviewSegment[] = []; + + reviewPages?.forEach((page) => { + page.forEach((segment) => { + all.push(segment); + + switch (segment.severity) { + case "alert": + alerts.push(segment); + break; + case "detection": + detections.push(segment); + break; + default: + motion.push(segment); + break; + } + }); + }); + + return { + all: all, + alert: alerts, + detection: detections, + significant_motion: motion, + }; + }, [reviewPages]); + + const isDone = useMemo( + () => (reviewPages?.at(-1)?.length ?? 0) < API_LIMIT, + [reviewPages] + ); + + // review interaction + + const pagingObserver = useRef(); + const lastReviewRef = useCallback( + (node: HTMLElement | null) => { + if (isValidating) return; + if (pagingObserver.current) pagingObserver.current.disconnect(); + try { + pagingObserver.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !isDone) { + setSize(size + 1); + } + }); + if (node) pagingObserver.current.observe(node); + } catch (e) { + // no op + } + }, + [isValidating, isDone] + ); + + const [minimap, setMinimap] = useState([]); + const minimapObserver = useRef(); + useEffect(() => { + if (!contentRef.current) { + return; + } + + const visibleTimestamps = new Set(); + minimapObserver.current = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + const start = (entry.target as HTMLElement).dataset.start; + + if (!start) { + return; + } + + if (entry.isIntersecting) { + visibleTimestamps.add(start); + } else { + visibleTimestamps.delete(start); + } + + setMinimap([...visibleTimestamps]); + }); + }, + { root: contentRef.current } + ); + + return () => { + minimapObserver.current?.disconnect(); + }; + }, [contentRef]); + const minimapRef = useCallback( + (node: HTMLElement | null) => { + if (!minimapObserver.current) { + return; + } + + try { + if (node) minimapObserver.current.observe(node); + } catch (e) { + // no op + } + }, + [minimapObserver.current] + ); + const minimapBounds = useMemo(() => { + const data = { + start: Math.floor(Date.now() / 1000) - 35 * 60, + end: Math.floor(Date.now() / 1000) - 21 * 60, + }; + const list = minimap.sort(); + + if (list.length > 0) { + data.end = parseFloat(list.at(-1)!!); + data.start = parseFloat(list[0]); + } + + return data; + }, [minimap]); + + // review status + + const setReviewed = useCallback( + async (id: string) => { + const resp = await axios.post(`review/${id}/viewed`); + + if (resp.status == 200) { + updateSegments(); + } + }, + [updateSegments] + ); + + // preview videos + + const previewTimes = useMemo(() => { + if ( + !reviewPages || + reviewPages.length == 0 || + reviewPages.at(-1)!!.length == 0 + ) { + return undefined; + } + + const startDate = new Date(); + startDate.setMinutes(0, 0, 0); + + const endDate = new Date(reviewPages.at(-1)!!.at(-1)!!.end_time); + endDate.setHours(0, 0, 0, 0); + return { + start: startDate.getTime() / 1000, + end: endDate.getTime() / 1000, + }; + }, [reviewPages]); + const { data: allPreviews } = useSWR( + previewTimes + ? `preview/all/start/${previewTimes.start}/end/${previewTimes.end}` + : null, + { revalidateOnFocus: false } + ); + + if (!config) { + return ; + } + + return ( +
+
+ setSeverity(value)} + > + + + Alerts + + + + Detections + + + + Motion + + +
+ + + +
+
+ +
+ {reviewItems[severity]?.map((value, segIdx) => { + const lastRow = segIdx == reviewItems[severity].length - 1; + const relevantPreview = Object.values(allPreviews || []).find( + (preview) => + preview.camera == value.camera && + preview.start < value.start_time && + preview.end > value.end_time + ); + + return ( +
+
+ setReviewed(value.id)} + /> +
+ {lastRow && !isDone && } +
+ ); + })} +
+
+ +
+
+ ); +} + +function ReviewCalendarButton() { + const disabledDates = useMemo(() => { + const tomorrow = new Date(); + tomorrow.setHours(tomorrow.getHours() + 24, -1, 0, 0); + const future = new Date(); + future.setFullYear(tomorrow.getFullYear() + 10); + return { from: tomorrow, to: future }; + }, []); + + return ( + + + + + + + + + ); +} diff --git a/web/src/views/events/MobileEventView.tsx b/web/src/views/events/MobileEventView.tsx new file mode 100644 index 000000000..70ba0635e --- /dev/null +++ b/web/src/views/events/MobileEventView.tsx @@ -0,0 +1,234 @@ +import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer"; +import ActivityIndicator from "@/components/ui/activity-indicator"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { ReviewSegment, ReviewSeverity } from "@/types/review"; +import axios from "axios"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { MdCircle } from "react-icons/md"; +import useSWR from "swr"; +import useSWRInfinite from "swr/infinite"; + +const API_LIMIT = 250; + +export default function MobileEventView() { + const { data: config } = useSWR("config"); + const [severity, setSeverity] = useState("alert"); + const contentRef = useRef(null); + + // review paging + + const reviewSearchParams = {}; + const reviewSegmentFetcher = useCallback((key: any) => { + const [path, params] = Array.isArray(key) ? key : [key, undefined]; + return axios.get(path, { params }).then((res) => res.data); + }, []); + + const getKey = useCallback( + (index: number, prevData: ReviewSegment[]) => { + if (index > 0) { + const lastDate = prevData[prevData.length - 1].start_time; + const pagedParams = reviewSearchParams + ? { before: lastDate, limit: API_LIMIT } + : { + ...reviewSearchParams, + before: lastDate, + limit: API_LIMIT, + }; + return ["review", pagedParams]; + } + + const params = reviewSearchParams + ? { limit: API_LIMIT } + : { ...reviewSearchParams, limit: API_LIMIT }; + return ["review", params]; + }, + [reviewSearchParams] + ); + + const { + data: reviewPages, + mutate: updateSegments, + size, + setSize, + isValidating, + } = useSWRInfinite(getKey, reviewSegmentFetcher); + + const reviewItems = useMemo(() => { + const all: ReviewSegment[] = []; + const alerts: ReviewSegment[] = []; + const detections: ReviewSegment[] = []; + const motion: ReviewSegment[] = []; + + reviewPages?.forEach((page) => { + page.forEach((segment) => { + all.push(segment); + + switch (segment.severity) { + case "alert": + alerts.push(segment); + break; + case "detection": + detections.push(segment); + break; + default: + motion.push(segment); + break; + } + }); + }); + + return { + all: all, + alert: alerts, + detection: detections, + significant_motion: motion, + }; + }, [reviewPages]); + + const isDone = useMemo( + () => (reviewPages?.at(-1)?.length ?? 0) < API_LIMIT, + [reviewPages] + ); + + // review interaction + + const pagingObserver = useRef(); + const lastReviewRef = useCallback( + (node: HTMLElement | null) => { + if (isValidating) return; + if (pagingObserver.current) pagingObserver.current.disconnect(); + try { + pagingObserver.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !isDone) { + setSize(size + 1); + } + }); + if (node) pagingObserver.current.observe(node); + } catch (e) { + // no op + } + }, + [isValidating, isDone] + ); + + // review status + + const setReviewed = useCallback( + async (id: string) => { + const resp = await axios.post(`review/${id}/viewed`); + + if (resp.status == 200) { + updateSegments(); + } + }, + [updateSegments] + ); + + // preview videos + + const previewTimes = useMemo(() => { + if ( + !reviewPages || + reviewPages.length == 0 || + reviewPages.at(-1)!!.length == 0 + ) { + return undefined; + } + + const startDate = new Date(); + startDate.setMinutes(0, 0, 0); + + const endDate = new Date(reviewPages.at(-1)!!.at(-1)!!.end_time); + endDate.setHours(0, 0, 0, 0); + return { + start: startDate.getTime() / 1000, + end: endDate.getTime() / 1000, + }; + }, [reviewPages]); + const { data: allPreviews } = useSWR( + previewTimes + ? `preview/all/start/${previewTimes.start}/end/${previewTimes.end}` + : null, + { revalidateOnFocus: false } + ); + + if (!config) { + return ; + } + + return ( +
+ setSeverity(value)} + > + + + Alerts + + + + Detections + + + + Motion + + + +
+ {reviewItems[severity]?.map((value, segIdx) => { + const lastRow = segIdx == reviewItems[severity].length - 1; + const relevantPreview = Object.values(allPreviews || []).find( + (preview) => + preview.camera == value.camera && + preview.start < value.start_time && + preview.end > value.end_time + ); + + return ( +
+
+ setReviewed(value.id)} + /> +
+ {lastRow && !isDone && } +
+ ); + })} +
+
+ ); +}