From 08109d61cede54c3cabe2cb77b06974df427339b Mon Sep 17 00:00:00 2001 From: Scott Lamb Date: Mon, 7 Mar 2022 11:52:07 -0800 Subject: [PATCH] clean up App.tsx This structure (described in https://github.com/scottlamb/moonfire-nvr/issues/202#issue-1161741347) has less activity-specific logic in App.tsx itself and avoids duplicate route handling. This fixes the `No routes matched location "/"` mentioned in #202. --- ui/src/App.tsx | 264 ++++++++++++++++++------------------------ ui/src/AppMenu.tsx | 2 +- ui/src/List/index.tsx | 113 ++++++++++-------- ui/src/Live/index.tsx | 69 +++++++---- 4 files changed, 228 insertions(+), 220 deletions(-) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d0f485b..b8b1e73 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -2,6 +2,22 @@ // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception +/** + * @fileoverview Main application + * + * This defines `` to lay out the visual structure of the application: + * + * - top menu bar with fixed components and a spot for activities to add + * their own elements + * - navigation drawer + * - main activity error + * + * It handles the login state and, once logged in, delegates to the appropriate + * activity based on the URL. Each activity is expected to return the supplied + * `` with its own `children` and optionally `activityMenuPart` filled + * in. + */ + import Container from "@mui/material/Container"; import React, { useEffect, useReducer, useState } from "react"; import * as api from "./api"; @@ -10,15 +26,8 @@ import Login from "./Login"; import { useSnackbars } from "./snackbars"; import ListActivity from "./List"; import AppBar from "@mui/material/AppBar"; -import { - Routes, - Route, - Link, - useSearchParams, - useResolvedPath, - useMatch, -} from "react-router-dom"; -import LiveActivity, { MultiviewChooser } from "./Live"; +import { Routes, Route, Link } from "react-router-dom"; +import LiveActivity from "./Live"; import Drawer from "@mui/material/Drawer"; import List from "@mui/material/List"; import ListItem from "@mui/material/ListItem"; @@ -26,8 +35,6 @@ import ListItemText from "@mui/material/ListItemText"; import ListIcon from "@mui/icons-material/List"; import Videocam from "@mui/icons-material/Videocam"; import ListItemIcon from "@mui/material/ListItemIcon"; -import FilterList from "@mui/icons-material/FilterList"; -import IconButton from "@mui/material/IconButton"; export type LoginState = | "unknown" @@ -36,22 +43,13 @@ export type LoginState = | "server-requires-login" | "user-requested-login"; -type Activity = "list" | "live"; +export interface FrameProps { + activityMenuPart?: JSX.Element; + children?: React.ReactNode; +} function App() { const [showMenu, toggleShowMenu] = useReducer((m: boolean) => !m, false); - const [searchParams, setSearchParams] = useSearchParams(); - - const [showListSelectors, toggleShowListSelectors] = useReducer( - (m: boolean) => !m, - true - ); - let resolved = useResolvedPath("live"); - let match = useMatch({ path: resolved.pathname, end: true }); - const [activity, setActivity] = useState(match ? "live" : "list"); - const [multiviewLayoutIndex, setMultiviewLayoutIndex] = useState( - Number.parseInt(searchParams.get("layout") || "0", 10) - ); const [toplevel, setToplevel] = useState(null); const [timeZoneName, setTimeZoneName] = useState(null); const [fetchSeq, setFetchSeq] = useState(0); @@ -60,11 +58,6 @@ function App() { const needNewFetch = () => setFetchSeq((seq) => seq + 1); const snackbars = useSnackbars(); - const clickActivity = (activity: Activity) => { - toggleShowMenu(); - setActivity(activity); - }; - const onLoginSuccess = () => { setLoginState("logged-in"); needNewFetch(); @@ -91,34 +84,6 @@ function App() { } }; - function fetchedToplevel(toplevel: api.ToplevelResponse | null) { - if (toplevel !== null && toplevel.cameras.length > 0) { - return ( - <> - - } - /> - - } - /> - - ); - } - } - useEffect(() => { const abort = new AbortController(); const doFetch = async (signal: AbortSignal) => { @@ -149,106 +114,103 @@ function App() { abort.abort(); }; }, [fetchSeq]); - let activityMenu = null; - if (error === null && toplevel !== null && toplevel.cameras.length > 0) { - switch (activity) { - case "list": - activityMenu = ( - - - - ); - break; - case "live": - activityMenu = ( - { - setMultiviewLayoutIndex(value); - setSearchParams({ layout: value.toString() }); + + const Frame = ({ activityMenuPart, children }: FrameProps): JSX.Element => { + return ( + <> + + { + setLoginState("user-requested-login"); }} + logout={logout} + menuClick={toggleShowMenu} + activityMenuPart={activityMenuPart} /> - ); - break; - } + + + + + + + + + + + + + + + + + + { + setLoginState((s) => + s === "user-requested-login" ? "not-logged-in" : s + ); + }} + /> + {error !== null && ( + +

Error querying server

+
{error.message}
+

+ You may find more information in the Javascript console. Try + reloading the page once you believe the problem is resolved. +

+
+ )} + {children} + + ); + }; + + if (toplevel == null) { + return ; } return ( - <> - - { - setLoginState("user-requested-login"); - }} - logout={logout} - menuClick={toggleShowMenu} - activityMenuPart={activityMenu} - /> - - - - clickActivity("list")} - component={Link} - to="/" - > - - - - - - clickActivity("live")} - component={Link} - to="/live" - > - - - - - - - - + } - handleClose={() => { - setLoginState((s) => - s === "user-requested-login" ? "not-logged-in" : s - ); - }} /> - {error !== null && ( - -

Error querying server

-
{error.message}
-

- You may find more information in the Javascript console. Try - reloading the page once you believe the problem is resolved. -

-
- )} - {fetchedToplevel(toplevel)} - + } + /> + ); } diff --git a/ui/src/AppMenu.tsx b/ui/src/AppMenu.tsx index a94143f..f51e96b 100644 --- a/ui/src/AppMenu.tsx +++ b/ui/src/AppMenu.tsx @@ -31,7 +31,7 @@ interface Props { requestLogin: () => void; logout: () => void; menuClick?: () => void; - activityMenuPart: JSX.Element | null; + activityMenuPart?: JSX.Element; } // https://material-ui.com/components/app-bar/ diff --git a/ui/src/List/index.tsx b/ui/src/List/index.tsx index be35f08..f75267d 100644 --- a/ui/src/List/index.tsx +++ b/ui/src/List/index.tsx @@ -11,7 +11,7 @@ import Table from "@mui/material/Table"; import TableContainer from "@mui/material/TableContainer"; import utcToZonedTime from "date-fns-tz/utcToZonedTime"; import format from "date-fns/format"; -import React, { useMemo, useState } from "react"; +import React, { useMemo, useReducer, useState } from "react"; import * as api from "../api"; import { Stream } from "../types"; import DisplaySelector, { DEFAULT_DURATION } from "./DisplaySelector"; @@ -22,6 +22,9 @@ import { useLayoutEffect } from "react"; import { fillAspect } from "../aspect"; import useResizeObserver from "@react-hook/resize-observer"; import { useSearchParams } from "react-router-dom"; +import { FrameProps } from "../App"; +import IconButton from "@mui/material/IconButton"; +import FilterList from "@mui/icons-material/FilterList"; const useStyles = makeStyles((theme: Theme) => ({ root: { @@ -98,7 +101,7 @@ const FullScreenVideo = ({ src, aspect }: FullScreenVideoProps) => { interface Props { timeZoneName: string; toplevel: api.ToplevelResponse; - showSelectors: boolean; + Frame: (props: FrameProps) => JSX.Element; } /// Parsed URL search parameters. @@ -221,7 +224,7 @@ const calcSelectedStreams = ( return streams; }; -const Main = ({ toplevel, timeZoneName, showSelectors }: Props) => { +const Main = ({ toplevel, timeZoneName, Frame }: Props) => { const classes = useStyles(); const { @@ -235,6 +238,11 @@ const Main = ({ toplevel, timeZoneName, showSelectors }: Props) => { setTimestampTrack, } = useParsedSearchParams(); + const [showSelectors, toggleShowSelectors] = useReducer( + (m: boolean) => !m, + true + ); + // The time range to examine, or null if one hasn't yet been selected. Note // this is derived from state held within TimerangeSelector. const [range90k, setRange90k] = useState<[number, number] | null>(null); @@ -282,50 +290,63 @@ const Main = ({ toplevel, timeZoneName, showSelectors }: Props) => { ); return ( -
- - - - - - {videoLists.length > 0 && recordingsTable} - {activeRecording != null && ( - - + + + } + > +
+ + - - )} -
+ + + + {videoLists.length > 0 && recordingsTable} + {activeRecording != null && ( + + + + )} +
+ ); }; diff --git a/ui/src/Live/index.tsx b/ui/src/Live/index.tsx index c97e7d4..9f3811d 100644 --- a/ui/src/Live/index.tsx +++ b/ui/src/Live/index.tsx @@ -6,39 +6,64 @@ import Container from "@mui/material/Container"; import ErrorIcon from "@mui/icons-material/Error"; import { Camera } from "../types"; import LiveCamera from "./LiveCamera"; -import Multiview from "./Multiview"; +import Multiview, { MultiviewChooser } from "./Multiview"; +import { FrameProps } from "../App"; +import { useSearchParams } from "react-router-dom"; +import { useState } from "react"; export interface LiveProps { cameras: Camera[]; - layoutIndex: number; + Frame: (props: FrameProps) => JSX.Element; } -const Live = ({ cameras, layoutIndex }: LiveProps) => { +const Live = ({ cameras, Frame }: LiveProps) => { + const [searchParams, setSearchParams] = useSearchParams(); + + const [multiviewLayoutIndex, setMultiviewLayoutIndex] = useState( + Number.parseInt(searchParams.get("layout") || "0", 10) + ); + if ("MediaSource" in window === false) { return ( - - - Live view doesn't work yet on your browser. See{" "} - #121. - + + + + Live view doesn't work yet on your browser. See{" "} + + #121 + + . + + ); } return ( - ( - - )} - /> + { + setMultiviewLayoutIndex(value); + setSearchParams({ layout: value.toString() }); + }} + /> + } + > + ( + + )} + /> + ); }; -export { MultiviewChooser } from "./Multiview"; export default Live;