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.
This commit is contained in:
Scott Lamb 2022-03-07 11:52:07 -08:00
parent 782eb2f0d8
commit 08109d61ce
4 changed files with 228 additions and 220 deletions

View File

@ -2,6 +2,22 @@
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. // 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 // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
/**
* @fileoverview Main application
*
* This defines `<Frame>` 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
* `<Frame>` with its own `children` and optionally `activityMenuPart` filled
* in.
*/
import Container from "@mui/material/Container"; import Container from "@mui/material/Container";
import React, { useEffect, useReducer, useState } from "react"; import React, { useEffect, useReducer, useState } from "react";
import * as api from "./api"; import * as api from "./api";
@ -10,15 +26,8 @@ import Login from "./Login";
import { useSnackbars } from "./snackbars"; import { useSnackbars } from "./snackbars";
import ListActivity from "./List"; import ListActivity from "./List";
import AppBar from "@mui/material/AppBar"; import AppBar from "@mui/material/AppBar";
import { import { Routes, Route, Link } from "react-router-dom";
Routes, import LiveActivity from "./Live";
Route,
Link,
useSearchParams,
useResolvedPath,
useMatch,
} from "react-router-dom";
import LiveActivity, { MultiviewChooser } from "./Live";
import Drawer from "@mui/material/Drawer"; import Drawer from "@mui/material/Drawer";
import List from "@mui/material/List"; import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem"; import ListItem from "@mui/material/ListItem";
@ -26,8 +35,6 @@ import ListItemText from "@mui/material/ListItemText";
import ListIcon from "@mui/icons-material/List"; import ListIcon from "@mui/icons-material/List";
import Videocam from "@mui/icons-material/Videocam"; import Videocam from "@mui/icons-material/Videocam";
import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemIcon from "@mui/material/ListItemIcon";
import FilterList from "@mui/icons-material/FilterList";
import IconButton from "@mui/material/IconButton";
export type LoginState = export type LoginState =
| "unknown" | "unknown"
@ -36,22 +43,13 @@ export type LoginState =
| "server-requires-login" | "server-requires-login"
| "user-requested-login"; | "user-requested-login";
type Activity = "list" | "live"; export interface FrameProps {
activityMenuPart?: JSX.Element;
children?: React.ReactNode;
}
function App() { function App() {
const [showMenu, toggleShowMenu] = useReducer((m: boolean) => !m, false); 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<Activity>(match ? "live" : "list");
const [multiviewLayoutIndex, setMultiviewLayoutIndex] = useState(
Number.parseInt(searchParams.get("layout") || "0", 10)
);
const [toplevel, setToplevel] = useState<api.ToplevelResponse | null>(null); const [toplevel, setToplevel] = useState<api.ToplevelResponse | null>(null);
const [timeZoneName, setTimeZoneName] = useState<string | null>(null); const [timeZoneName, setTimeZoneName] = useState<string | null>(null);
const [fetchSeq, setFetchSeq] = useState(0); const [fetchSeq, setFetchSeq] = useState(0);
@ -60,11 +58,6 @@ function App() {
const needNewFetch = () => setFetchSeq((seq) => seq + 1); const needNewFetch = () => setFetchSeq((seq) => seq + 1);
const snackbars = useSnackbars(); const snackbars = useSnackbars();
const clickActivity = (activity: Activity) => {
toggleShowMenu();
setActivity(activity);
};
const onLoginSuccess = () => { const onLoginSuccess = () => {
setLoginState("logged-in"); setLoginState("logged-in");
needNewFetch(); needNewFetch();
@ -91,34 +84,6 @@ function App() {
} }
}; };
function fetchedToplevel(toplevel: api.ToplevelResponse | null) {
if (toplevel !== null && toplevel.cameras.length > 0) {
return (
<>
<Route
path=""
element={
<ListActivity
toplevel={toplevel}
showSelectors={showListSelectors}
timeZoneName={timeZoneName!}
/>
}
/>
<Route
path="live"
element={
<LiveActivity
cameras={toplevel.cameras}
layoutIndex={multiviewLayoutIndex}
/>
}
/>
</>
);
}
}
useEffect(() => { useEffect(() => {
const abort = new AbortController(); const abort = new AbortController();
const doFetch = async (signal: AbortSignal) => { const doFetch = async (signal: AbortSignal) => {
@ -149,34 +114,8 @@ function App() {
abort.abort(); abort.abort();
}; };
}, [fetchSeq]); }, [fetchSeq]);
let activityMenu = null;
if (error === null && toplevel !== null && toplevel.cameras.length > 0) { const Frame = ({ activityMenuPart, children }: FrameProps): JSX.Element => {
switch (activity) {
case "list":
activityMenu = (
<IconButton
aria-label="selectors"
onClick={toggleShowListSelectors}
color="inherit"
size="small"
>
<FilterList />
</IconButton>
);
break;
case "live":
activityMenu = (
<MultiviewChooser
layoutIndex={multiviewLayoutIndex}
onChoice={(value) => {
setMultiviewLayoutIndex(value);
setSearchParams({ layout: value.toString() });
}}
/>
);
break;
}
}
return ( return (
<> <>
<AppBar position="static"> <AppBar position="static">
@ -187,7 +126,7 @@ function App() {
}} }}
logout={logout} logout={logout}
menuClick={toggleShowMenu} menuClick={toggleShowMenu}
activityMenuPart={activityMenu} activityMenuPart={activityMenuPart}
/> />
</AppBar> </AppBar>
<Drawer <Drawer
@ -202,7 +141,7 @@ function App() {
<ListItem <ListItem
button button
key="list" key="list"
onClick={() => clickActivity("list")} onClick={toggleShowMenu}
component={Link} component={Link}
to="/" to="/"
> >
@ -214,7 +153,7 @@ function App() {
<ListItem <ListItem
button button
key="live" key="live"
onClick={() => clickActivity("live")} onClick={toggleShowMenu}
component={Link} component={Link}
to="/live" to="/live"
> >
@ -247,9 +186,32 @@ function App() {
</p> </p>
</Container> </Container>
)} )}
<Routes>{fetchedToplevel(toplevel)}</Routes> {children}
</> </>
); );
};
if (toplevel == null) {
return <Frame />;
}
return (
<Routes>
<Route
path=""
element={
<ListActivity
toplevel={toplevel}
timeZoneName={timeZoneName!}
Frame={Frame}
/>
}
/>
<Route
path="live"
element={<LiveActivity cameras={toplevel.cameras} Frame={Frame} />}
/>
</Routes>
);
} }
export default App; export default App;

View File

@ -31,7 +31,7 @@ interface Props {
requestLogin: () => void; requestLogin: () => void;
logout: () => void; logout: () => void;
menuClick?: () => void; menuClick?: () => void;
activityMenuPart: JSX.Element | null; activityMenuPart?: JSX.Element;
} }
// https://material-ui.com/components/app-bar/ // https://material-ui.com/components/app-bar/

View File

@ -11,7 +11,7 @@ import Table from "@mui/material/Table";
import TableContainer from "@mui/material/TableContainer"; import TableContainer from "@mui/material/TableContainer";
import utcToZonedTime from "date-fns-tz/utcToZonedTime"; import utcToZonedTime from "date-fns-tz/utcToZonedTime";
import format from "date-fns/format"; 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 * as api from "../api";
import { Stream } from "../types"; import { Stream } from "../types";
import DisplaySelector, { DEFAULT_DURATION } from "./DisplaySelector"; import DisplaySelector, { DEFAULT_DURATION } from "./DisplaySelector";
@ -22,6 +22,9 @@ import { useLayoutEffect } from "react";
import { fillAspect } from "../aspect"; import { fillAspect } from "../aspect";
import useResizeObserver from "@react-hook/resize-observer"; import useResizeObserver from "@react-hook/resize-observer";
import { useSearchParams } from "react-router-dom"; 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) => ({ const useStyles = makeStyles((theme: Theme) => ({
root: { root: {
@ -98,7 +101,7 @@ const FullScreenVideo = ({ src, aspect }: FullScreenVideoProps) => {
interface Props { interface Props {
timeZoneName: string; timeZoneName: string;
toplevel: api.ToplevelResponse; toplevel: api.ToplevelResponse;
showSelectors: boolean; Frame: (props: FrameProps) => JSX.Element;
} }
/// Parsed URL search parameters. /// Parsed URL search parameters.
@ -221,7 +224,7 @@ const calcSelectedStreams = (
return streams; return streams;
}; };
const Main = ({ toplevel, timeZoneName, showSelectors }: Props) => { const Main = ({ toplevel, timeZoneName, Frame }: Props) => {
const classes = useStyles(); const classes = useStyles();
const { const {
@ -235,6 +238,11 @@ const Main = ({ toplevel, timeZoneName, showSelectors }: Props) => {
setTimestampTrack, setTimestampTrack,
} = useParsedSearchParams(); } = useParsedSearchParams();
const [showSelectors, toggleShowSelectors] = useReducer(
(m: boolean) => !m,
true
);
// The time range to examine, or null if one hasn't yet been selected. Note // The time range to examine, or null if one hasn't yet been selected. Note
// this is derived from state held within TimerangeSelector. // this is derived from state held within TimerangeSelector.
const [range90k, setRange90k] = useState<[number, number] | null>(null); const [range90k, setRange90k] = useState<[number, number] | null>(null);
@ -282,6 +290,18 @@ const Main = ({ toplevel, timeZoneName, showSelectors }: Props) => {
</TableContainer> </TableContainer>
); );
return ( return (
<Frame
activityMenuPart={
<IconButton
aria-label="selectors"
onClick={toggleShowSelectors}
color="inherit"
size="small"
>
<FilterList />
</IconButton>
}
>
<div className={classes.root}> <div className={classes.root}>
<Box <Box
className={classes.selectors} className={classes.selectors}
@ -326,6 +346,7 @@ const Main = ({ toplevel, timeZoneName, showSelectors }: Props) => {
</Modal> </Modal>
)} )}
</div> </div>
</Frame>
); );
}; };

View File

@ -6,16 +6,26 @@ import Container from "@mui/material/Container";
import ErrorIcon from "@mui/icons-material/Error"; import ErrorIcon from "@mui/icons-material/Error";
import { Camera } from "../types"; import { Camera } from "../types";
import LiveCamera from "./LiveCamera"; 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 { export interface LiveProps {
cameras: Camera[]; 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) { if ("MediaSource" in window === false) {
return ( return (
<Frame>
<Container> <Container>
<ErrorIcon <ErrorIcon
sx={{ sx={{
@ -25,20 +35,35 @@ const Live = ({ cameras, layoutIndex }: LiveProps) => {
}} }}
/> />
Live view doesn't work yet on your browser. See{" "} Live view doesn't work yet on your browser. See{" "}
<a href="https://github.com/scottlamb/moonfire-nvr/issues/121">#121</a>. <a href="https://github.com/scottlamb/moonfire-nvr/issues/121">
#121
</a>
.
</Container> </Container>
</Frame>
); );
} }
return ( return (
<Frame
activityMenuPart={
<MultiviewChooser
layoutIndex={multiviewLayoutIndex}
onChoice={(value) => {
setMultiviewLayoutIndex(value);
setSearchParams({ layout: value.toString() });
}}
/>
}
>
<Multiview <Multiview
layoutIndex={layoutIndex} layoutIndex={multiviewLayoutIndex}
cameras={cameras} cameras={cameras}
renderCamera={(camera: Camera | null, chooser: JSX.Element) => ( renderCamera={(camera: Camera | null, chooser: JSX.Element) => (
<LiveCamera camera={camera} chooser={chooser} /> <LiveCamera camera={camera} chooser={chooser} />
)} )}
/> />
</Frame>
); );
}; };
export { MultiviewChooser } from "./Multiview";
export default Live; export default Live;