mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-01-24 13:13:16 -05:00
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:
parent
782eb2f0d8
commit
08109d61ce
264
ui/src/App.tsx
264
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 `<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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/
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user