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.
// 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 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<Activity>(match ? "live" : "list");
const [multiviewLayoutIndex, setMultiviewLayoutIndex] = useState(
Number.parseInt(searchParams.get("layout") || "0", 10)
);
const [toplevel, setToplevel] = useState<api.ToplevelResponse | null>(null);
const [timeZoneName, setTimeZoneName] = useState<string | null>(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 (
<>
<Route
path=""
element={
<ListActivity
toplevel={toplevel}
showSelectors={showListSelectors}
timeZoneName={timeZoneName!}
/>
}
/>
<Route
path="live"
element={
<LiveActivity
cameras={toplevel.cameras}
layoutIndex={multiviewLayoutIndex}
/>
}
/>
</>
);
}
}
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 = (
<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() });
const Frame = ({ activityMenuPart, children }: FrameProps): JSX.Element => {
return (
<>
<AppBar position="static">
<MoonfireMenu
loginState={loginState}
requestLogin={() => {
setLoginState("user-requested-login");
}}
logout={logout}
menuClick={toggleShowMenu}
activityMenuPart={activityMenuPart}
/>
);
break;
}
</AppBar>
<Drawer
variant="temporary"
open={showMenu}
onClose={toggleShowMenu}
ModalProps={{
keepMounted: true,
}}
>
<List>
<ListItem
button
key="list"
onClick={toggleShowMenu}
component={Link}
to="/"
>
<ListItemIcon>
<ListIcon />
</ListItemIcon>
<ListItemText primary="List view" />
</ListItem>
<ListItem
button
key="live"
onClick={toggleShowMenu}
component={Link}
to="/live"
>
<ListItemIcon>
<Videocam />
</ListItemIcon>
<ListItemText primary="Live view (experimental)" />
</ListItem>
</List>
</Drawer>
<Login
onSuccess={onLoginSuccess}
open={
loginState === "server-requires-login" ||
loginState === "user-requested-login"
}
handleClose={() => {
setLoginState((s) =>
s === "user-requested-login" ? "not-logged-in" : s
);
}}
/>
{error !== null && (
<Container>
<h2>Error querying server</h2>
<pre>{error.message}</pre>
<p>
You may find more information in the Javascript console. Try
reloading the page once you believe the problem is resolved.
</p>
</Container>
)}
{children}
</>
);
};
if (toplevel == null) {
return <Frame />;
}
return (
<>
<AppBar position="static">
<MoonfireMenu
loginState={loginState}
requestLogin={() => {
setLoginState("user-requested-login");
}}
logout={logout}
menuClick={toggleShowMenu}
activityMenuPart={activityMenu}
/>
</AppBar>
<Drawer
variant="temporary"
open={showMenu}
onClose={toggleShowMenu}
ModalProps={{
keepMounted: true,
}}
>
<List>
<ListItem
button
key="list"
onClick={() => clickActivity("list")}
component={Link}
to="/"
>
<ListItemIcon>
<ListIcon />
</ListItemIcon>
<ListItemText primary="List view" />
</ListItem>
<ListItem
button
key="live"
onClick={() => clickActivity("live")}
component={Link}
to="/live"
>
<ListItemIcon>
<Videocam />
</ListItemIcon>
<ListItemText primary="Live view (experimental)" />
</ListItem>
</List>
</Drawer>
<Login
onSuccess={onLoginSuccess}
open={
loginState === "server-requires-login" ||
loginState === "user-requested-login"
<Routes>
<Route
path=""
element={
<ListActivity
toplevel={toplevel}
timeZoneName={timeZoneName!}
Frame={Frame}
/>
}
handleClose={() => {
setLoginState((s) =>
s === "user-requested-login" ? "not-logged-in" : s
);
}}
/>
{error !== null && (
<Container>
<h2>Error querying server</h2>
<pre>{error.message}</pre>
<p>
You may find more information in the Javascript console. Try
reloading the page once you believe the problem is resolved.
</p>
</Container>
)}
<Routes>{fetchedToplevel(toplevel)}</Routes>
</>
<Route
path="live"
element={<LiveActivity cameras={toplevel.cameras} Frame={Frame} />}
/>
</Routes>
);
}

View File

@ -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/

View File

@ -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) => {
</TableContainer>
);
return (
<div className={classes.root}>
<Box
className={classes.selectors}
sx={{ display: showSelectors ? "block" : "none" }}
>
<StreamMultiSelector
toplevel={toplevel}
selected={selectedStreamIds}
setSelected={setSelectedStreamIds}
/>
<TimerangeSelector
selectedStreams={selectedStreams}
range90k={range90k}
setRange90k={setRange90k}
timeZoneName={timeZoneName}
/>
<DisplaySelector
split90k={split90k}
setSplit90k={setSplit90k}
trimStartAndEnd={trimStartAndEnd}
setTrimStartAndEnd={setTrimStartAndEnd}
timestampTrack={timestampTrack}
setTimestampTrack={setTimestampTrack}
/>
</Box>
{videoLists.length > 0 && recordingsTable}
{activeRecording != null && (
<Modal open onClose={closeModal} className={classes.videoModal}>
<FullScreenVideo
src={api.recordingUrl(
activeRecording[0].camera.uuid,
activeRecording[0].streamType,
activeRecording[1],
timestampTrack,
trimStartAndEnd ? range90k! : undefined
)}
aspect={[
activeRecording[2].aspectWidth,
activeRecording[2].aspectHeight,
]}
<Frame
activityMenuPart={
<IconButton
aria-label="selectors"
onClick={toggleShowSelectors}
color="inherit"
size="small"
>
<FilterList />
</IconButton>
}
>
<div className={classes.root}>
<Box
className={classes.selectors}
sx={{ display: showSelectors ? "block" : "none" }}
>
<StreamMultiSelector
toplevel={toplevel}
selected={selectedStreamIds}
setSelected={setSelectedStreamIds}
/>
</Modal>
)}
</div>
<TimerangeSelector
selectedStreams={selectedStreams}
range90k={range90k}
setRange90k={setRange90k}
timeZoneName={timeZoneName}
/>
<DisplaySelector
split90k={split90k}
setSplit90k={setSplit90k}
trimStartAndEnd={trimStartAndEnd}
setTrimStartAndEnd={setTrimStartAndEnd}
timestampTrack={timestampTrack}
setTimestampTrack={setTimestampTrack}
/>
</Box>
{videoLists.length > 0 && recordingsTable}
{activeRecording != null && (
<Modal open onClose={closeModal} className={classes.videoModal}>
<FullScreenVideo
src={api.recordingUrl(
activeRecording[0].camera.uuid,
activeRecording[0].streamType,
activeRecording[1],
timestampTrack,
trimStartAndEnd ? range90k! : undefined
)}
aspect={[
activeRecording[2].aspectWidth,
activeRecording[2].aspectHeight,
]}
/>
</Modal>
)}
</div>
</Frame>
);
};

View File

@ -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 (
<Container>
<ErrorIcon
sx={{
float: "left",
color: "secondary.main",
marginRight: "1em",
}}
/>
Live view doesn't work yet on your browser. See{" "}
<a href="https://github.com/scottlamb/moonfire-nvr/issues/121">#121</a>.
</Container>
<Frame>
<Container>
<ErrorIcon
sx={{
float: "left",
color: "secondary.main",
marginRight: "1em",
}}
/>
Live view doesn't work yet on your browser. See{" "}
<a href="https://github.com/scottlamb/moonfire-nvr/issues/121">
#121
</a>
.
</Container>
</Frame>
);
}
return (
<Multiview
layoutIndex={layoutIndex}
cameras={cameras}
renderCamera={(camera: Camera | null, chooser: JSX.Element) => (
<LiveCamera camera={camera} chooser={chooser} />
)}
/>
<Frame
activityMenuPart={
<MultiviewChooser
layoutIndex={multiviewLayoutIndex}
onChoice={(value) => {
setMultiviewLayoutIndex(value);
setSearchParams({ layout: value.toString() });
}}
/>
}
>
<Multiview
layoutIndex={multiviewLayoutIndex}
cameras={cameras}
renderCamera={(camera: Camera | null, chooser: JSX.Element) => (
<LiveCamera camera={camera} chooser={chooser} />
)}
/>
</Frame>
);
};
export { MultiviewChooser } from "./Multiview";
export default Live;