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 &&
}
+
+ );
+ })}
+
+
+ );
+}