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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 230 additions and 485 deletions

View File

@ -1,9 +1,7 @@
import Providers from "@/context/providers"; import Providers from "@/context/providers";
import { BrowserRouter, Routes, Route } from "react-router-dom"; import { BrowserRouter, Routes, Route } from "react-router-dom";
import { useState } from "react";
import Wrapper from "@/components/Wrapper"; import Wrapper from "@/components/Wrapper";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/navigation/Sidebar";
import Header from "@/components/Header";
import Live from "@/pages/Live"; import Live from "@/pages/Live";
import Export from "@/pages/Export"; import Export from "@/pages/Export";
import Storage from "@/pages/Storage"; import Storage from "@/pages/Storage";
@ -14,27 +12,22 @@ import NoMatch from "@/pages/NoMatch";
import Settings from "@/pages/Settings"; import Settings from "@/pages/Settings";
import UIPlayground from "./pages/UIPlayground"; import UIPlayground from "./pages/UIPlayground";
import Events from "./pages/Events"; import Events from "./pages/Events";
import { isDesktop } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import Statusbar from "./components/Statusbar"; import Statusbar from "./components/Statusbar";
import Bottombar from "./components/navigation/Bottombar";
function App() { function App() {
const [sheetOpen, setSheetOpen] = useState(false);
const toggleNavbar = () => {
setSheetOpen((prev) => !prev);
};
return ( return (
<Providers> <Providers>
<BrowserRouter> <BrowserRouter>
<Wrapper> <Wrapper>
<Header onToggleNavbar={toggleNavbar} />
<div className="w-full h-full pt-2 overflow-hidden"> <div className="w-full h-full pt-2 overflow-hidden">
<Sidebar sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} /> {isDesktop && <Sidebar />}
{isDesktop && <Statusbar />} {isDesktop && <Statusbar />}
{isMobile && <Bottombar />}
<div <div
id="pageRoot" id="pageRoot"
className="absolute left-0 md:left-16 top-16 md:top-2 right-0 bottom-0 md:bottom-8 overflow-hidden" className="absolute left-0 md:left-16 top-2 right-0 bottom-16 md:bottom-8 overflow-hidden"
> >
<Routes> <Routes>
<Route path="/" element={<Live />} /> <Route path="/" element={<Live />} />

View File

@ -1,63 +0,0 @@
import { Link } from "react-router-dom";
import Logo from "@/components/Logo";
import { LuMenu } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import { ENV } from "@/env";
import { NavLink } from "react-router-dom";
import { navbarLinks } from "@/pages/site-navigation";
import SettingsNavItems from "./settings/SettingsNavItems";
type HeaderProps = {
onToggleNavbar: () => void;
};
function HeaderNavigation() {
return (
<div className="hidden md:flex">
{navbarLinks.map((item) => {
let shouldRender = item.dev ? ENV !== "production" : true;
return (
shouldRender && (
<NavLink
key={item.id}
to={item.url}
className={({ isActive }) =>
`my-2 py-3 px-4 text-muted-foreground flex flex-row items-center text-center rounded-lg gap-2 hover:bg-border ${
isActive ? "font-bold bg-popover text-popover-foreground" : ""
}`
}
>
<div className="text-sm">{item.title}</div>
</NavLink>
)
);
})}
</div>
);
}
function Header({ onToggleNavbar }: HeaderProps) {
return (
<div className="flex gap-10 lg:gap-20 justify-between pt-2 mb-2 border-b-[1px] px-4 items-center md:hidden">
<div className="flex gap-4 items-center flex-shrink-0 m-1">
<Button
variant="ghost"
size="icon"
className="md:hidden"
onClick={onToggleNavbar}
>
<LuMenu />
</Button>
<Link to="/">
<div className="flex flex-row items-center">
<Logo className="w-10 mr-5" />
</div>
</Link>
<HeaderNavigation />
</div>
<SettingsNavItems className="flex flex-shrink-0 md:gap-2" />
</div>
);
}
export default Header;

View File

@ -1,95 +0,0 @@
import { IconType } from "react-icons";
import { NavLink } from "react-router-dom";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import Logo from "./Logo";
import { ENV } from "@/env";
import { navbarLinks } from "@/pages/site-navigation";
import SettingsNavItems from "./settings/SettingsNavItems";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
function Sidebar({
sheetOpen,
setSheetOpen,
}: {
sheetOpen: boolean;
setSheetOpen: (open: boolean) => void;
}) {
const sidebar = (
<aside className="w-[52px] z-10 h-screen sticky top-0 overflow-y-auto scrollbar-hidden py-4 flex flex-col justify-between">
<span tabIndex={0} className="sr-only" />
<div className="w-full flex flex-col gap-0 items-center">
<Logo className="w-8 h-8 mb-6" />
{navbarLinks.map((item) => (
<SidebarItem
key={item.id}
Icon={item.icon}
title={item.title}
url={item.url}
dev={item.dev}
onClick={() => setSheetOpen(false)}
/>
))}
</div>
<SettingsNavItems className="hidden md:flex flex-col items-center mb-8" />
</aside>
);
return (
<>
<div className="hidden md:block">{sidebar}</div>
<Sheet
open={sheetOpen}
modal={false}
onOpenChange={() => setSheetOpen(false)}
>
<SheetContent side="left" className="w-[90px]">
<div className="w-full flex flex-row justify-center"></div>
{sidebar}
</SheetContent>
</Sheet>
</>
);
}
type SidebarItemProps = {
Icon: IconType;
title: string;
url: string;
dev?: boolean;
onClick?: () => void;
};
function SidebarItem({ Icon, title, url, dev, onClick }: SidebarItemProps) {
const shouldRender = dev ? ENV !== "production" : true;
return (
shouldRender && (
<Tooltip>
<NavLink
to={url}
onClick={onClick}
className={({ isActive }) =>
`mx-[10px] mb-6 flex flex-col justify-center items-center rounded-lg ${
isActive
? "font-bold text-primary-foreground bg-primary"
: "text-muted-foreground bg-muted"
}`
}
>
<TooltipTrigger>
<Icon className="w-5 h-5 m-[6px]" />
</TooltipTrigger>
</NavLink>
<TooltipContent side="right">
<p>{title}</p>
</TooltipContent>
</Tooltip>
)
);
}
export default Sidebar;

View File

@ -15,6 +15,7 @@ import { Calendar } from "../ui/calendar";
import { ReviewFilter } from "@/types/review"; import { ReviewFilter } from "@/types/review";
import { getEndOfDayTimestamp } from "@/utils/dateUtil"; import { getEndOfDayTimestamp } from "@/utils/dateUtil";
import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { isMobile } from "react-device-detect";
const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"]; const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"];
@ -122,11 +123,17 @@ function CamerasFilterButton({
}} }}
> >
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button className="mx-1 capitalize" variant="secondary"> <Button
<LuVideo className=" mr-[10px]" /> size={isMobile ? "sm" : "default"}
{selectedCameras == undefined className="mx-1 capitalize"
? "All Cameras" variant="secondary"
: `${selectedCameras.length} Cameras`} >
<LuVideo className="md:mr-[10px]" />
<div className="hidden md:block">
{selectedCameras == undefined
? "All Cameras"
: `${selectedCameras.length} Cameras`}
</div>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
@ -205,9 +212,15 @@ function CalendarFilterButton({
}} }}
> >
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button className="mx-1" variant="secondary"> <Button
<LuCalendar className=" mr-[10px]" /> size={isMobile ? "sm" : "default"}
{day == undefined ? "Last 24 Hours" : selectedDate} className="mx-1"
variant="secondary"
>
<LuCalendar className="md:mr-[10px]" />
<div className="hidden md:block">
{day == undefined ? "Last 24 Hours" : selectedDate}
</div>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent> <PopoverContent>
@ -241,9 +254,13 @@ function GeneralFilterButton({
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button className="mx-1" variant="secondary"> <Button
<LuFilter className=" mr-[10px]" /> size={isMobile ? "sm" : "default"}
Filter className="mx-1"
variant="secondary"
>
<LuFilter className="md:mr-[10px]" />
<div className="hidden md:block">Filter</div>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent side="left" asChild> <PopoverContent side="left" asChild>

View File

@ -0,0 +1,26 @@
import { navbarLinks } from "@/pages/site-navigation";
import NavItem from "./NavItem";
import SettingsNavItems from "../settings/SettingsNavItems";
function Bottombar() {
return (
<div className="absolute h-16 left-4 bottom-0 right-4 flex flex-row items-center justify-between">
{navbarLinks.map((item) => (
<NavItem
className=""
variant="secondary"
key={item.id}
Icon={item.icon}
title={item.title}
url={item.url}
dev={item.dev}
/>
))}
<SettingsNavItems className="flex flex-shrink-0 justify-between gap-4" />
</div>
);
}
//
export default Bottombar;

View File

@ -0,0 +1,64 @@
import { IconType } from "react-icons";
import { NavLink } from "react-router-dom";
import { ENV } from "@/env";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const variants = {
primary: {
active: "font-bold text-primary-foreground bg-primary",
inactive: "text-muted-foreground bg-muted",
},
secondary: {
active: "font-bold text-primary",
inactive: "text-muted-foreground",
},
};
type NavItemProps = {
className: string;
variant?: "primary" | "secondary";
Icon: IconType;
title: string;
url: string;
dev?: boolean;
onClick?: () => void;
};
export default function NavItem({
className,
variant = "primary",
Icon,
title,
url,
dev,
onClick,
}: NavItemProps) {
const shouldRender = dev ? ENV !== "production" : true;
return (
shouldRender && (
<Tooltip>
<NavLink
to={url}
onClick={onClick}
className={({ isActive }) =>
`${className} flex flex-col justify-center items-center rounded-lg ${
variants[variant][isActive ? "active" : "inactive"]
}`
}
>
<TooltipTrigger>
<Icon className="w-5 h-5 m-[6px]" />
</TooltipTrigger>
</NavLink>
<TooltipContent side="right">
<p>{title}</p>
</TooltipContent>
</Tooltip>
)
);
}

View File

@ -0,0 +1,28 @@
import Logo from "../Logo";
import { navbarLinks } from "@/pages/site-navigation";
import SettingsNavItems from "../settings/SettingsNavItems";
import NavItem from "./NavItem";
function Sidebar() {
return (
<aside className="w-[52px] z-10 h-screen sticky top-0 overflow-y-auto scrollbar-hidden py-4 flex flex-col justify-between">
<span tabIndex={0} className="sr-only" />
<div className="w-full flex flex-col gap-0 items-center">
<Logo className="w-8 h-8 mb-6" />
{navbarLinks.map((item) => (
<NavItem
className="mx-[10px] mb-6"
key={item.id}
Icon={item.icon}
title={item.title}
url={item.url}
dev={item.dev}
/>
))}
</div>
<SettingsNavItems className="hidden md:flex flex-col items-center mb-8" />
</aside>
);
}
export default Sidebar;

View File

@ -12,6 +12,7 @@ import useCameraActivity from "@/hooks/use-camera-activity";
import { useRecordingsState } from "@/api/ws"; import { useRecordingsState } from "@/api/ws";
import { LivePlayerMode } from "@/types/live"; import { LivePlayerMode } from "@/types/live";
import useCameraLiveMode from "@/hooks/use-camera-live-mode"; import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import { isDesktop } from "react-device-detect";
type LivePlayerProps = { type LivePlayerProps = {
className?: string; className?: string;
@ -153,7 +154,7 @@ export default function LivePlayer({
className={`bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500`} className={`bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500`}
> >
<MdLeakAdd className="w-4 h-4 text-motion" /> <MdLeakAdd className="w-4 h-4 text-motion" />
<div className="ml-1 text-white text-xs">Motion</div> <div className="hidden md:block ml-1 text-white text-xs">Motion</div>
</Chip> </Chip>
{cameraConfig.audio.enabled_in_config && ( {cameraConfig.audio.enabled_in_config && (
@ -162,19 +163,21 @@ export default function LivePlayer({
className={`bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500`} className={`bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500`}
> >
<BsSoundwave className="w-4 h-4 text-audio" /> <BsSoundwave className="w-4 h-4 text-audio" />
<div className="ml-1 text-white text-xs">Sound</div> <div className="hidden md:block ml-1 text-white text-xs">Sound</div>
</Chip> </Chip>
)} )}
</div> </div>
<Chip className="absolute right-2 top-2 bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500"> {isDesktop && (
{recording == "ON" && ( <Chip className="absolute right-2 top-2 bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500">
<MdCircle className="w-2 h-2 drop-shadow-md shadow-danger text-danger" /> {recording == "ON" && (
)} <MdCircle className="w-2 h-2 drop-shadow-md shadow-danger text-danger" />
<div className="ml-1 capitalize text-white text-xs"> )}
{cameraConfig.name.replaceAll("_", " ")} <div className="ml-1 capitalize text-white text-xs">
</div> {cameraConfig.name.replaceAll("_", " ")}
</Chip> </div>
</Chip>
)}
</div> </div>
); );
} }

View File

@ -1,12 +1,10 @@
import useApiFilter from "@/hooks/use-api-filter"; import useApiFilter from "@/hooks/use-api-filter";
import useOverlayState from "@/hooks/use-overlay-state"; import useOverlayState from "@/hooks/use-overlay-state";
import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review"; import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review";
import DesktopEventView from "@/views/events/DesktopEventView";
import DesktopRecordingView from "@/views/events/DesktopRecordingView"; import DesktopRecordingView from "@/views/events/DesktopRecordingView";
import MobileEventView from "@/views/events/MobileEventView"; import EventView from "@/views/events/EventView";
import axios from "axios"; import axios from "axios";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { isMobile } from "react-device-detect";
import useSWR from "swr"; import useSWR from "swr";
import useSWRInfinite from "swr/infinite"; import useSWRInfinite from "swr/infinite";
@ -209,24 +207,8 @@ export default function Events() {
/> />
); );
} else { } else {
if (isMobile) {
return (
<MobileEventView
reviewPages={reviewPages}
relevantPreviews={allPreviews}
reachedEnd={isDone}
isValidating={isValidating}
severity={severity}
setSeverity={setSeverity}
loadNextPage={onLoadNextPage}
markItemAsReviewed={markItemAsReviewed}
pullLatestData={reloadData}
/>
);
}
return ( return (
<DesktopEventView <EventView
reviewPages={reviewPages} reviewPages={reviewPages}
relevantPreviews={allPreviews} relevantPreviews={allPreviews}
timeRange={selectedTimeRange} timeRange={selectedTimeRange}

View File

@ -1,17 +1,26 @@
import { useFrigateEvents } from "@/api/ws"; import { useFrigateEvents } from "@/api/ws";
import Logo from "@/components/Logo";
import { AnimatedEventThumbnail } from "@/components/image/AnimatedEventThumbnail"; import { AnimatedEventThumbnail } from "@/components/image/AnimatedEventThumbnail";
import LivePlayer from "@/components/player/LivePlayer"; import LivePlayer from "@/components/player/LivePlayer";
import { Button } from "@/components/ui/button";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { Event as FrigateEvent } from "@/types/event"; import { Event as FrigateEvent } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { isSafari } from "react-device-detect"; import { isDesktop, isMobile, isSafari } from "react-device-detect";
import { CiGrid2H, CiGrid31 } from "react-icons/ci";
import useSWR from "swr"; import useSWR from "swr";
function Live() { function Live() {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
// layout
const [layout, setLayout] = useState<"grid" | "list">(
isDesktop ? "grid" : "list"
);
// recent events // recent events
const { payload: eventUpdate } = useFrigateEvents(); const { payload: eventUpdate } = useFrigateEvents();
const { data: allEvents, mutate: updateEvents } = useSWR<FrigateEvent[]>([ const { data: allEvents, mutate: updateEvents } = useSWR<FrigateEvent[]>([
@ -80,6 +89,31 @@ function Live() {
return ( return (
<div className="w-full h-full overflow-y-scroll px-2"> <div className="w-full h-full overflow-y-scroll px-2">
{isMobile && (
<div className="relative h-9 flex items-center justify-between">
<Logo className="absolute inset-y-0 inset-x-1/2 -translate-x-1/2 h-8" />
<div />
<div className="flex items-center gap-1">
<Button
className={layout == "grid" ? "text-blue-600 bg-blue-200" : ""}
size="xs"
variant="secondary"
onClick={() => setLayout("grid")}
>
<CiGrid31 className="m-1" />
</Button>
<Button
className={layout == "list" ? "text-blue-600 bg-blue-200" : ""}
size="xs"
variant="secondary"
onClick={() => setLayout("list")}
>
<CiGrid2H className="m-1" />
</Button>
</div>
</div>
)}
{events && events.length > 0 && ( {events && events.length > 0 && (
<ScrollArea> <ScrollArea>
<TooltipProvider> <TooltipProvider>
@ -93,21 +127,23 @@ function Live() {
</ScrollArea> </ScrollArea>
)} )}
<div className="mt-4 md:grid md:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4 gap-4"> <div
className={`mt-4 grid ${layout == "grid" ? "grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4" : ""} gap-2 md:gap-4`}
>
{cameras.map((camera) => { {cameras.map((camera) => {
let grow; let grow;
let aspectRatio = camera.detect.width / camera.detect.height; let aspectRatio = camera.detect.width / camera.detect.height;
if (aspectRatio > 2) { if (aspectRatio > 2) {
grow = "md:col-span-2 aspect-wide"; grow = `${layout == "grid" ? "col-span-2" : ""} aspect-wide`;
} else if (aspectRatio < 1) { } else if (aspectRatio < 1) {
grow = `md:row-span-2 aspect-tall md:h-full`; grow = `${layout == "grid" ? "row-span-2 aspect-tall md:h-full" : ""} aspect-tall`;
} else { } else {
grow = "aspect-video"; grow = "aspect-video";
} }
return ( return (
<LivePlayer <LivePlayer
key={camera.name} key={camera.name}
className={`mb-2 md:mb-0 rounded-2xl bg-black ${grow}`} className={`rounded-2xl bg-black ${grow}`}
windowVisible={windowVisible} windowVisible={windowVisible}
cameraConfig={camera} cameraConfig={camera}
preferredLiveMode={isSafari ? "webrtc" : "mse"} preferredLiveMode={isSafari ? "webrtc" : "mse"}

View File

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