Revamp mobile UI (#10103)

* Simplify nav components

* Allow ability to choose live layout on mobile

* Combine event views

* Undo vite

* Fix autoplay

* Remove import

* Show filters on mobile view

* Spacing

* Don't separate properties
This commit is contained in:
Nicolas Mowen
2024-02-27 14:39:05 -07:00
committed by GitHub
parent 622e9741c0
commit fd24007618
12 changed files with 230 additions and 485 deletions

View File

@@ -1,3 +1,4 @@
import Logo from "@/components/Logo";
import NewReviewData from "@/components/dynamic/NewReviewData";
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer";
@@ -8,11 +9,12 @@ import { useEventUtils } from "@/hooks/use-event-utils";
import { FrigateConfig } from "@/types/frigateConfig";
import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isDesktop, isMobile } from "react-device-detect";
import { LuFolderCheck } from "react-icons/lu";
import { MdCircle } from "react-icons/md";
import useSWR from "swr";
type DesktopEventViewProps = {
type EventViewProps = {
reviewPages?: ReviewSegment[][];
relevantPreviews?: Preview[];
timeRange: { before: number; after: number };
@@ -27,7 +29,7 @@ type DesktopEventViewProps = {
pullLatestData: () => void;
updateFilter: (filter: ReviewFilter) => void;
};
export default function DesktopEventView({
export default function EventView({
reviewPages,
relevantPreviews,
timeRange,
@@ -41,7 +43,7 @@ export default function DesktopEventView({
onSelectReview,
pullLatestData,
updateFilter,
}: DesktopEventViewProps) {
}: EventViewProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const contentRef = useRef<HTMLDivElement | null>(null);
const segmentDuration = 60;
@@ -127,10 +129,6 @@ export default function DesktopEventView({
const [minimap, setMinimap] = useState<string[]>([]);
const minimapObserver = useRef<IntersectionObserver | null>();
useEffect(() => {
if (!contentRef.current) {
return;
}
const visibleTimestamps = new Set<string>();
minimapObserver.current = new IntersectionObserver(
(entries) => {
@@ -150,7 +148,7 @@ export default function DesktopEventView({
setMinimap([...visibleTimestamps]);
});
},
{ root: contentRef.current, threshold: 0.1 }
{ root: contentRef.current, threshold: isDesktop ? 0.1 : 0.5 }
);
return () => {
@@ -169,12 +167,12 @@ export default function DesktopEventView({
// no op
}
},
[minimapObserver.current]
[minimapObserver]
);
const minimapBounds = useMemo(() => {
const data = {
start: Math.floor(Date.now() / 1000) - 35 * 60,
end: Math.floor(Date.now() / 1000) - 21 * 60,
start: 0,
end: 0,
};
const list = minimap.sort();
@@ -192,7 +190,8 @@ export default function DesktopEventView({
return (
<div className="flex flex-col w-full h-full">
<div className="flex justify-between mb-2">
<div className="relative flex justify-between mb-2">
<Logo className="absolute inset-y-0 inset-x-1/2 -translate-x-1/2 h-8" />
<ToggleGroup
type="single"
defaultValue="alert"
@@ -206,8 +205,8 @@ export default function DesktopEventView({
value="alert"
aria-label="Select alerts"
>
<MdCircle className="w-2 h-2 mr-[10px] text-severity_alert" />
Alerts
<MdCircle className="w-2 h-2 md:mr-[10px] text-severity_alert" />
<div className="hidden md:block">Alerts</div>
</ToggleGroupItem>
<ToggleGroupItem
className={`px-3 py-4 rounded-2xl ${
@@ -216,8 +215,8 @@ export default function DesktopEventView({
value="detection"
aria-label="Select detections"
>
<MdCircle className="w-2 h-2 mr-[10px] text-severity_detection" />
Detections
<MdCircle className="w-2 h-2 md:mr-[10px] text-severity_detection" />
<div className="hidden md:block">Detections</div>
</ToggleGroupItem>
<ToggleGroupItem
className={`px-3 py-4 rounded-2xl ${
@@ -226,8 +225,8 @@ export default function DesktopEventView({
value="significant_motion"
aria-label="Select motion"
>
<MdCircle className="w-2 h-2 mr-[10px] text-severity_motion" />
Motion
<MdCircle className="w-2 h-2 md:mr-[10px] text-severity_motion" />
<div className="hidden md:block">Motion</div>
</ToggleGroupItem>
</ToggleGroup>
<ReviewFilterGroup filter={filter} onUpdateFilter={updateFilter} />
@@ -276,7 +275,7 @@ export default function DesktopEventView({
data-segment-start={
alignDateToTimeline(value.start_time) - segmentDuration
}
className="outline outline-offset-1 outline-0 rounded-lg shadow-none transition-all duration-500"
className="outline outline-offset-1 outline-0 rounded-lg shadow-none transition-all duration-500 my-1 md:my-0"
>
<div className="aspect-video rounded-lg overflow-hidden">
<PreviewThumbnailPlayer
@@ -284,6 +283,9 @@ export default function DesktopEventView({
relevantPreview={relevantPreview}
setReviewed={markItemAsReviewed}
onClick={onSelectReview}
autoPlayback={
isMobile && minimapBounds.end == value.start_time
}
/>
</div>
{lastRow && !reachedEnd && <ActivityIndicator />}
@@ -295,7 +297,7 @@ export default function DesktopEventView({
)}
</div>
</div>
<div className="md:w-[100px] mt-2 overflow-y-auto no-scrollbar">
<div className="w-[44px] md:w-[100px] mt-2 overflow-y-auto no-scrollbar">
<EventReviewTimeline
segmentDuration={segmentDuration}
timestampSpread={15}

View File

@@ -1,248 +0,0 @@
import NewReviewData from "@/components/dynamic/NewReviewData";
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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { MdCircle } from "react-icons/md";
import useSWR from "swr";
type MobileEventViewProps = {
reviewPages?: ReviewSegment[][];
relevantPreviews?: Preview[];
reachedEnd: boolean;
isValidating: boolean;
severity: ReviewSeverity;
setSeverity: (severity: ReviewSeverity) => void;
loadNextPage: () => void;
markItemAsReviewed: (reviewId: string) => void;
pullLatestData: () => void;
};
export default function MobileEventView({
reviewPages,
relevantPreviews,
reachedEnd,
isValidating,
severity,
setSeverity,
loadNextPage,
markItemAsReviewed,
pullLatestData,
}: MobileEventViewProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const contentRef = useRef<HTMLDivElement | null>(null);
// review paging
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 currentItems = useMemo(() => {
const current = reviewItems[severity];
if (!current || current.length == 0) {
return null;
}
return current;
}, [reviewItems, severity]);
// review interaction
const pagingObserver = useRef<IntersectionObserver | null>();
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 && !reachedEnd) {
loadNextPage();
}
});
if (node) pagingObserver.current.observe(node);
} catch (e) {
// no op
}
},
[isValidating, reachedEnd]
);
const [minimap, setMinimap] = useState<string[]>([]);
const minimapObserver = useRef<IntersectionObserver | null>();
useEffect(() => {
const visibleTimestamps = new Set<string>();
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]);
});
},
{ threshold: 0.5 }
);
return () => {
minimapObserver.current?.disconnect();
};
}, []);
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: 0,
end: 0,
};
const list = minimap.sort();
if (list.length > 0) {
data.end = parseFloat(list.at(-1)!!);
data.start = parseFloat(list[0]);
}
return data;
}, [minimap]);
if (!config) {
return <ActivityIndicator />;
}
return (
<>
<ToggleGroup
type="single"
defaultValue="alert"
size="sm"
onValueChange={(value: ReviewSeverity) => setSeverity(value)}
>
<ToggleGroupItem
className={`px-3 py-4 rounded-2xl ${
severity == "alert" ? "" : "text-gray-500"
}`}
value="alert"
aria-label="Select alerts"
>
<MdCircle className="w-2 h-2 mr-[10px] text-severity_alert" />
Alerts
</ToggleGroupItem>
<ToggleGroupItem
className={`px-3 py-4 rounded-2xl ${
severity == "detection" ? "" : "text-gray-500"
}`}
value="detection"
aria-label="Select detections"
>
<MdCircle className="w-2 h-2 mr-[10px] text-severity_detection" />
Detections
</ToggleGroupItem>
<ToggleGroupItem
className={`px-3 py-4 rounded-2xl ${
severity == "significant_motion" ? "" : "text-gray-500"
}`}
value="significant_motion"
aria-label="Select motion"
>
<MdCircle className="w-2 h-2 mr-[10px] text-severity_motion" />
Motion
</ToggleGroupItem>
</ToggleGroup>
<NewReviewData
className="absolute w-full z-30"
contentRef={contentRef}
severity={severity}
pullLatestData={pullLatestData}
/>
<div
ref={contentRef}
className="w-full h-full grid grid-cols-1 sm:grid-cols-2 mt-2 gap-2 overflow-y-auto"
>
{currentItems ? (
currentItems.map((value, segIdx) => {
const lastRow = segIdx == currentItems.length - 1;
const relevantPreview = Object.values(relevantPreviews || []).find(
(preview) =>
preview.camera == value.camera &&
preview.start < value.start_time &&
preview.end > value.end_time
);
return (
<div
key={value.id}
ref={lastRow ? lastReviewRef : minimapRef}
data-start={value.start_time}
>
<div className="w-full aspect-video rounded-lg overflow-hidden">
<PreviewThumbnailPlayer
review={value}
relevantPreview={relevantPreview}
autoPlayback={minimapBounds.end == value.start_time}
setReviewed={markItemAsReviewed}
/>
</div>
{lastRow && !reachedEnd && <ActivityIndicator />}
</div>
);
})
) : (
<div ref={lastReviewRef} />
)}
</div>
</>
);
}