From fd240076180589af946c5f14a92899155fe63e02 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 27 Feb 2024 14:39:05 -0700 Subject: [PATCH] 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 --- web/src/App.tsx | 19 +- web/src/components/Header.tsx | 63 ----- web/src/components/Sidebar.tsx | 95 ------- .../components/filter/ReviewFilterGroup.tsx | 39 ++- web/src/components/navigation/Bottombar.tsx | 26 ++ web/src/components/navigation/NavItem.tsx | 64 +++++ web/src/components/navigation/Sidebar.tsx | 28 ++ web/src/components/player/LivePlayer.tsx | 23 +- web/src/pages/Events.tsx | 22 +- web/src/pages/Live.tsx | 46 +++- .../{DesktopEventView.tsx => EventView.tsx} | 42 +-- web/src/views/events/MobileEventView.tsx | 248 ------------------ 12 files changed, 230 insertions(+), 485 deletions(-) delete mode 100644 web/src/components/Header.tsx delete mode 100644 web/src/components/Sidebar.tsx create mode 100644 web/src/components/navigation/Bottombar.tsx create mode 100644 web/src/components/navigation/NavItem.tsx create mode 100644 web/src/components/navigation/Sidebar.tsx rename web/src/views/events/{DesktopEventView.tsx => EventView.tsx} (88%) delete mode 100644 web/src/views/events/MobileEventView.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 182b8fffe..9b5c2b401 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,9 +1,7 @@ import Providers from "@/context/providers"; import { BrowserRouter, Routes, Route } from "react-router-dom"; -import { useState } from "react"; import Wrapper from "@/components/Wrapper"; -import Sidebar from "@/components/Sidebar"; -import Header from "@/components/Header"; +import Sidebar from "@/components/navigation/Sidebar"; import Live from "@/pages/Live"; import Export from "@/pages/Export"; import Storage from "@/pages/Storage"; @@ -14,27 +12,22 @@ import NoMatch from "@/pages/NoMatch"; import Settings from "@/pages/Settings"; import UIPlayground from "./pages/UIPlayground"; import Events from "./pages/Events"; -import { isDesktop } from "react-device-detect"; +import { isDesktop, isMobile } from "react-device-detect"; import Statusbar from "./components/Statusbar"; +import Bottombar from "./components/navigation/Bottombar"; function App() { - const [sheetOpen, setSheetOpen] = useState(false); - - const toggleNavbar = () => { - setSheetOpen((prev) => !prev); - }; - return ( -
- + {isDesktop && } {isDesktop && } + {isMobile && }
} /> diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx deleted file mode 100644 index 0d67be7ed..000000000 --- a/web/src/components/Header.tsx +++ /dev/null @@ -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 ( -
- {navbarLinks.map((item) => { - let shouldRender = item.dev ? ENV !== "production" : true; - return ( - shouldRender && ( - - `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" : "" - }` - } - > -
{item.title}
-
- ) - ); - })} -
- ); -} - -function Header({ onToggleNavbar }: HeaderProps) { - return ( -
-
- - -
- -
- - -
- -
- ); -} - -export default Header; diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx deleted file mode 100644 index 92a44b520..000000000 --- a/web/src/components/Sidebar.tsx +++ /dev/null @@ -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 = ( - - ); - - return ( - <> -
{sidebar}
- setSheetOpen(false)} - > - -
- {sidebar} -
-
- - ); -} - -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 && ( - - - `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" - }` - } - > - - - - - -

{title}

-
-
- ) - ); -} - -export default Sidebar; diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index eb71daa22..1eb169c59 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -15,6 +15,7 @@ import { Calendar } from "../ui/calendar"; import { ReviewFilter } from "@/types/review"; import { getEndOfDayTimestamp } from "@/utils/dateUtil"; import { useFormattedTimestamp } from "@/hooks/use-date-utils"; +import { isMobile } from "react-device-detect"; const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"]; @@ -122,11 +123,17 @@ function CamerasFilterButton({ }} > - @@ -205,9 +212,15 @@ function CalendarFilterButton({ }} > - @@ -241,9 +254,13 @@ function GeneralFilterButton({ return ( - diff --git a/web/src/components/navigation/Bottombar.tsx b/web/src/components/navigation/Bottombar.tsx new file mode 100644 index 000000000..d2ba42c63 --- /dev/null +++ b/web/src/components/navigation/Bottombar.tsx @@ -0,0 +1,26 @@ +import { navbarLinks } from "@/pages/site-navigation"; +import NavItem from "./NavItem"; +import SettingsNavItems from "../settings/SettingsNavItems"; + +function Bottombar() { + return ( +
+ {navbarLinks.map((item) => ( + + ))} + +
+ ); +} + +// + +export default Bottombar; diff --git a/web/src/components/navigation/NavItem.tsx b/web/src/components/navigation/NavItem.tsx new file mode 100644 index 000000000..70dc405b9 --- /dev/null +++ b/web/src/components/navigation/NavItem.tsx @@ -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 && ( + + + `${className} flex flex-col justify-center items-center rounded-lg ${ + variants[variant][isActive ? "active" : "inactive"] + }` + } + > + + + + + +

{title}

+
+
+ ) + ); +} diff --git a/web/src/components/navigation/Sidebar.tsx b/web/src/components/navigation/Sidebar.tsx new file mode 100644 index 000000000..131963118 --- /dev/null +++ b/web/src/components/navigation/Sidebar.tsx @@ -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 ( + + ); +} + +export default Sidebar; diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 10133244a..ec0e3c211 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -12,6 +12,7 @@ import useCameraActivity from "@/hooks/use-camera-activity"; import { useRecordingsState } from "@/api/ws"; import { LivePlayerMode } from "@/types/live"; import useCameraLiveMode from "@/hooks/use-camera-live-mode"; +import { isDesktop } from "react-device-detect"; type LivePlayerProps = { className?: string; @@ -153,7 +154,7 @@ export default function LivePlayer({ className={`bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500`} > -
Motion
+
Motion
{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`} > -
Sound
+
Sound
)}
- - {recording == "ON" && ( - - )} -
- {cameraConfig.name.replaceAll("_", " ")} -
-
+ {isDesktop && ( + + {recording == "ON" && ( + + )} +
+ {cameraConfig.name.replaceAll("_", " ")} +
+
+ )}
); } diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index e7f0b456d..f3d185c16 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -1,12 +1,10 @@ import useApiFilter from "@/hooks/use-api-filter"; import useOverlayState from "@/hooks/use-overlay-state"; import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review"; -import DesktopEventView from "@/views/events/DesktopEventView"; import DesktopRecordingView from "@/views/events/DesktopRecordingView"; -import MobileEventView from "@/views/events/MobileEventView"; +import EventView from "@/views/events/EventView"; import axios from "axios"; import { useCallback, useMemo, useState } from "react"; -import { isMobile } from "react-device-detect"; import useSWR from "swr"; import useSWRInfinite from "swr/infinite"; @@ -209,24 +207,8 @@ export default function Events() { /> ); } else { - if (isMobile) { - return ( - - ); - } - return ( - ("config"); + // layout + + const [layout, setLayout] = useState<"grid" | "list">( + isDesktop ? "grid" : "list" + ); + // recent events const { payload: eventUpdate } = useFrigateEvents(); const { data: allEvents, mutate: updateEvents } = useSWR([ @@ -80,6 +89,31 @@ function Live() { return (
+ {isMobile && ( +
+ +
+
+ + +
+
+ )} + {events && events.length > 0 && ( @@ -93,21 +127,23 @@ function Live() { )} -
+
{cameras.map((camera) => { let grow; let aspectRatio = camera.detect.width / camera.detect.height; if (aspectRatio > 2) { - grow = "md:col-span-2 aspect-wide"; + grow = `${layout == "grid" ? "col-span-2" : ""} aspect-wide`; } 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 { grow = "aspect-video"; } return ( 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("config"); const contentRef = useRef(null); const segmentDuration = 60; @@ -127,10 +129,6 @@ export default function DesktopEventView({ const [minimap, setMinimap] = useState([]); const minimapObserver = useRef(); useEffect(() => { - if (!contentRef.current) { - return; - } - const visibleTimestamps = new Set(); 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 (
-
+
+ - - Alerts + +
Alerts
- - Detections + +
Detections
- - Motion + +
Motion
@@ -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" >
{lastRow && !reachedEnd && } @@ -295,7 +297,7 @@ export default function DesktopEventView({ )}
-
+
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("config"); - const contentRef = useRef(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(); - 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([]); - const minimapObserver = useRef(); - useEffect(() => { - 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]); - }); - }, - { 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 ; - } - - return ( - <> - setSeverity(value)} - > - - - Alerts - - - - Detections - - - - Motion - - - - - -
- {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 ( -
-
- -
- {lastRow && !reachedEnd && } -
- ); - }) - ) : ( -
- )} -
- - ); -}