Compare commits
19 Commits
3f94a6e8c3
...
b6b174841a
Author | SHA1 | Date |
---|---|---|
dependabot[bot] | b6b174841a | |
Scott Lamb | 9acb095a5d | |
Scott Lamb | 8b5f2b4b0d | |
Scott Lamb | a65994ba71 | |
Scott Lamb | ef98f60241 | |
Scott Lamb | 7f4b04ee8a | |
michioxd | 9ede361b25 | |
michioxd | 8036aa40b7 | |
michioxd | a787703a31 | |
michioxd | 3f4cee7ead | |
michioxd | c67a5ffba5 | |
michioxd | 305deaa1e7 | |
michioxd | 29cafc2f82 | |
michioxd | 60c6247ef9 | |
michioxd | 317b8e9484 | |
michioxd | 91e02eba7a | |
michioxd | 5b5822900d | |
michioxd | b46d3acabb | |
michioxd | 6e81b27d1a |
|
@ -70,11 +70,13 @@ jobs:
|
|||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: cd ui && npm ci
|
||||
- run: cd ui && npm run build
|
||||
- run: cd ui && npm run test
|
||||
- run: cd ui && npm run lint
|
||||
- run: cd ui && npm run check-format
|
||||
# Install pnpm then use pnpm instead npm
|
||||
- run: npm i -g pnpm
|
||||
- run: cd ui && pnpm i --frozen-lockfile
|
||||
- run: cd ui && pnpm run build
|
||||
- run: cd ui && pnpm run test
|
||||
- run: cd ui && pnpm run lint
|
||||
- run: cd ui && pnpm run check-format
|
||||
license:
|
||||
name: Check copyright/license headers
|
||||
runs-on: ubuntu-20.04
|
||||
|
|
|
@ -28,9 +28,10 @@ jobs:
|
|||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- run: cd ui && npm ci
|
||||
- run: cd ui && npm run build
|
||||
- run: cd ui && npm run test
|
||||
- run: npm i -g pnpm
|
||||
- run: cd ui && pnpm i --frozen-lockfile
|
||||
- run: cd ui && pnpm run build
|
||||
- run: cd ui && pnpm run test
|
||||
# Upload the UI and changelog as *job* artifacts (not *release* artifacts), used below.
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
"esbenp.prettier-vscode",
|
||||
"rust-lang.rust-analyzer",
|
||||
"yzhang.markdown-all-in-one",
|
||||
"zixuanchen.vitest-explorer"
|
||||
"vitest.explorer"
|
||||
],
|
||||
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
|
||||
"unwantedRecommendations": [
|
||||
|
|
3
AUTHORS
3
AUTHORS
|
@ -1,3 +1,4 @@
|
|||
Scott Lamb <slamb@slamb.org>
|
||||
Dolf Starreveld <dolf@starreveld.com>
|
||||
Sky1e <me@skye-c.at>
|
||||
Sky1e <me@skye-c.at>
|
||||
michioxd <michio.haiyaku@gmail.com>
|
||||
|
|
15
CHANGELOG.md
15
CHANGELOG.md
|
@ -8,6 +8,21 @@ upgrades, e.g. `v0.6.x` -> `v0.7.x`. The config file format and
|
|||
[API](ref/api.md) currently have no stability guarantees, so they may change
|
||||
even on minor releases, e.g. `v0.7.5` -> `v0.7.6`.
|
||||
|
||||
## v0.7.14 (2024-04-16)
|
||||
|
||||
* Many UI improvements in [#315](https://github.com/scottlamb/moonfire-nvr/pull/315)
|
||||
from [@michioxd](https://github.com/michioxd). See the PR description for
|
||||
full details, including screenshots.
|
||||
* dark/light modes
|
||||
* redesigned login dialog
|
||||
* live view: new dual camera layout, more descriptive layout names,
|
||||
full screen option, re-open with last layout and camera selection
|
||||
* list view: filter button becomes outlined when enabled
|
||||
* Fix [#286](https://github.com/scottlamb/moonfire-nvr/issues/286):
|
||||
live view now works on Firefox! Formerly, it'd fail with messages such as
|
||||
`Security Error: Content at https://mydomain.com/ may not load data from blob:https://mydomain.com/44abc5dc-750d-48d1-817d-2e6a52445592`.
|
||||
|
||||
|
||||
## v0.7.13 (2024-02-12)
|
||||
|
||||
* seamlessly merge together recordings which have imperceptible changes in
|
||||
|
|
|
@ -84,17 +84,19 @@ $ sudo install -m 755 target/release/moonfire-nvr /usr/local/bin
|
|||
$ cd ..
|
||||
```
|
||||
|
||||
You can build the UI via `npm` and find it in the `ui/build` directory:
|
||||
You can build the UI via `pnpm` and find it in the `ui/build` directory:
|
||||
|
||||
```console
|
||||
$ cd ui
|
||||
$ npm install
|
||||
$ npm run build
|
||||
$ pnpm install
|
||||
$ pnpm run build
|
||||
$ sudo mkdir /usr/local/lib/moonfire-nvr
|
||||
$ cd ..
|
||||
$ sudo rsync --recursive --delete --chmod=D755,F644 ui/dist/ /usr/local/lib/moonfire-nvr/ui
|
||||
```
|
||||
|
||||
For more information about using `pnpm`, check out the [Developing UI Guide](./developing-ui.md#requirements).
|
||||
|
||||
If you wish to bundle the UI into the binary, you can build the UI first and then pass
|
||||
`--features=bundled-ui` when building the server. See also the
|
||||
[release workflow](../.github/workflows/release.yml) which statically links SQLite and
|
||||
|
|
|
@ -20,13 +20,21 @@ and more effort is expended on packing and minimizing the components of
|
|||
the application as represented in the various "bundles". Read more about
|
||||
this in the webpack documentation.
|
||||
|
||||
## Requirements
|
||||
|
||||
* Node.js v18+
|
||||
* `pnpm` installed
|
||||
|
||||
This guide below will use [`pnpm`](https://pnpm.io/) as package manager instead
|
||||
`npm`. So we highly recommended you to use `pnpm` in this project.
|
||||
|
||||
## Getting started
|
||||
|
||||
Checkout the branch you want to work on and type
|
||||
|
||||
```
|
||||
$ cd ui
|
||||
$ npm run dev
|
||||
```bash
|
||||
cd ui
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
This will pack and prepare a development setup. By default the development
|
||||
|
@ -55,8 +63,8 @@ Currently there's only one supported environment variable override defined in
|
|||
Thus one could connect to a remote Moonfire NVR by specifying its URL as
|
||||
follows:
|
||||
|
||||
```
|
||||
$ PROXY_TARGET=https://nvr.example.com/ npm run dev
|
||||
```bash
|
||||
PROXY_TARGET=https://nvr.example.com/ npm run dev
|
||||
```
|
||||
|
||||
This allows you to test a new UI against your stable, production Moonfire NVR
|
||||
|
|
|
@ -26,10 +26,10 @@ left, and pick the [latest tagged version](https://github.com/scottlamb/moonfire
|
|||
|
||||
Download the binary for your platform from the matching GitHub release.
|
||||
Install it as `/usr/local/bin/moonfire-nvr` and ensure it is executable, e.g.
|
||||
for version `v0.7.11`:
|
||||
for version `v0.7.14`:
|
||||
|
||||
```console
|
||||
$ VERSION=v0.7.11
|
||||
$ VERSION=v0.7.14
|
||||
$ ARCH=$(uname -m)
|
||||
$ curl -OL "https://github.com/scottlamb/moonfire-nvr/releases/download/$VERSION/moonfire-nvr-$VERSION-$ARCH"
|
||||
$ sudo install -m 755 "moonfire-nvr-$VERSION-$ARCH" /usr/local/bin/moonfire-nvr
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -4,21 +4,21 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.8.2",
|
||||
"@emotion/styled": "^11.8.1",
|
||||
"@fontsource/roboto": "^4.5.3",
|
||||
"@mui/icons-material": "^5.10.6",
|
||||
"@mui/lab": "^5.0.0-alpha.102",
|
||||
"@mui/material": "^5.10.8",
|
||||
"@mui/x-date-pickers": "^6.16.3",
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/styled": "^11.11.5",
|
||||
"@fontsource/roboto": "^4.5.8",
|
||||
"@mui/icons-material": "^5.15.15",
|
||||
"@mui/lab": "5.0.0-alpha.170",
|
||||
"@mui/material": "^5.15.15",
|
||||
"@mui/x-date-pickers": "^6.19.8",
|
||||
"@react-hook/resize-observer": "^1.2.6",
|
||||
"date-fns": "^2.28.0",
|
||||
"date-fns-tz": "^2.0.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"date-fns-tz": "^2.0.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.41.5",
|
||||
"react-hook-form-mui": "^6.5.2",
|
||||
"react-router-dom": "^6.2.2"
|
||||
"react-hook-form": "^7.51.2",
|
||||
"react-hook-form-mui": "^6.8.0",
|
||||
"react-router-dom": "^6.22.3"
|
||||
},
|
||||
"scripts": {
|
||||
"check-format": "prettier --check --ignore-path .gitignore .",
|
||||
|
@ -80,33 +80,36 @@
|
|||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.23.5",
|
||||
"@babel/preset-env": "^7.23.6",
|
||||
"@babel/preset-react": "^7.23.3",
|
||||
"@babel/preset-typescript": "^7.23.3",
|
||||
"@swc/core": "^1.3.100",
|
||||
"@testing-library/dom": "^8.11.3",
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@babel/core": "^7.24.4",
|
||||
"@babel/preset-env": "^7.24.4",
|
||||
"@babel/preset-react": "^7.24.1",
|
||||
"@babel/preset-typescript": "^7.24.1",
|
||||
"@swc/core": "^1.4.12",
|
||||
"@testing-library/dom": "^8.20.1",
|
||||
"@testing-library/jest-dom": "^6.4.2",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^14.4.3",
|
||||
"@types/node": "^18.8.1",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^18.19.29",
|
||||
"@types/react": "^18.2.74",
|
||||
"@types/react-dom": "^18.2.24",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-legacy": "^5.3.2",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react": "^7.34.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"eslint-plugin-vitest": "^0.3.18",
|
||||
"http-proxy-middleware": "^2.0.4",
|
||||
"msw": "^2.0.0",
|
||||
"prettier": "^2.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"eslint-plugin-vitest": "^0.3.26",
|
||||
"http-proxy-middleware": "^2.0.6",
|
||||
"jsdom": "^24.0.0",
|
||||
"msw": "^2.2.13",
|
||||
"prettier": "^2.8.8",
|
||||
"terser": "^5.30.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.1.0",
|
||||
"vite": "^5.0.12",
|
||||
"typescript": "^5.4.4",
|
||||
"vite": "^5.2.8",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vitest": "^1.0.4"
|
||||
"vitest": "^1.4.0"
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -19,25 +19,16 @@
|
|||
*/
|
||||
|
||||
import Container from "@mui/material/Container";
|
||||
import React, { useEffect, useReducer, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import * as api from "./api";
|
||||
import MoonfireMenu from "./AppMenu";
|
||||
import Login from "./Login";
|
||||
import { useSnackbars } from "./snackbars";
|
||||
import ListActivity from "./List";
|
||||
import AppBar from "@mui/material/AppBar";
|
||||
import { Routes, Route, Link, Navigate } from "react-router-dom";
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import LiveActivity from "./Live";
|
||||
import UsersActivity from "./Users";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import List from "@mui/material/List";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import ListIcon from "@mui/icons-material/List";
|
||||
import PeopleIcon from "@mui/icons-material/People";
|
||||
import Videocam from "@mui/icons-material/Videocam";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import ChangePassword from "./ChangePassword";
|
||||
import Header from "./components/Header";
|
||||
|
||||
export type LoginState =
|
||||
| "unknown"
|
||||
|
@ -52,7 +43,6 @@ export interface FrameProps {
|
|||
}
|
||||
|
||||
function App() {
|
||||
const [showMenu, toggleShowMenu] = useReducer((m: boolean) => !m, false);
|
||||
const [toplevel, setToplevel] = useState<api.ToplevelResponse | null>(null);
|
||||
const [timeZoneName, setTimeZoneName] = useState<string | null>(null);
|
||||
const [fetchSeq, setFetchSeq] = useState(0);
|
||||
|
@ -122,67 +112,14 @@ function App() {
|
|||
const Frame = ({ activityMenuPart, children }: FrameProps): JSX.Element => {
|
||||
return (
|
||||
<>
|
||||
<AppBar position="static">
|
||||
<MoonfireMenu
|
||||
loginState={loginState}
|
||||
requestLogin={() => {
|
||||
setLoginState("user-requested-login");
|
||||
}}
|
||||
logout={logout}
|
||||
changePassword={() => setChangePasswordOpen(true)}
|
||||
menuClick={toggleShowMenu}
|
||||
activityMenuPart={activityMenuPart}
|
||||
/>
|
||||
</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>
|
||||
{toplevel?.permissions.adminUsers && (
|
||||
<ListItem
|
||||
button
|
||||
key="users"
|
||||
onClick={toggleShowMenu}
|
||||
component={Link}
|
||||
to="/users"
|
||||
>
|
||||
<ListItemIcon>
|
||||
<PeopleIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Users" />
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</Drawer>
|
||||
<Header
|
||||
loginState={loginState}
|
||||
logout={logout}
|
||||
setChangePasswordOpen={setChangePasswordOpen}
|
||||
activityMenuPart={activityMenuPart}
|
||||
setLoginState={setLoginState}
|
||||
toplevel={toplevel}
|
||||
/>
|
||||
<Login
|
||||
onSuccess={onLoginSuccess}
|
||||
open={
|
||||
|
|
|
@ -14,6 +14,11 @@ import MenuIcon from "@mui/icons-material/Menu";
|
|||
import React from "react";
|
||||
import { LoginState } from "./App";
|
||||
import Box from "@mui/material/Box";
|
||||
import { CurrentMode, useThemeMode } from "./components/ThemeMode";
|
||||
import Brightness2 from "@mui/icons-material/Brightness2";
|
||||
import Brightness7 from "@mui/icons-material/Brightness7";
|
||||
import BrightnessAuto from "@mui/icons-material/BrightnessAuto";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
|
||||
interface Props {
|
||||
loginState: LoginState;
|
||||
|
@ -26,6 +31,7 @@ interface Props {
|
|||
|
||||
// https://material-ui.com/components/app-bar/
|
||||
function MoonfireMenu(props: Props) {
|
||||
const { choosenTheme, changeTheme } = useThemeMode();
|
||||
const theme = useTheme();
|
||||
const [accountMenuAnchor, setAccountMenuAnchor] =
|
||||
React.useState<null | HTMLElement>(null);
|
||||
|
@ -69,6 +75,17 @@ function MoonfireMenu(props: Props) {
|
|||
{props.activityMenuPart}
|
||||
</Box>
|
||||
)}
|
||||
<Tooltip title="Toggle theme">
|
||||
<IconButton onClick={changeTheme} color="inherit" size="small">
|
||||
{choosenTheme === CurrentMode.Light ? (
|
||||
<Brightness7 />
|
||||
) : choosenTheme === CurrentMode.Dark ? (
|
||||
<Brightness2 />
|
||||
) : (
|
||||
<BrightnessAuto />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{props.loginState !== "unknown" && props.loginState !== "logged-in" && (
|
||||
<Button color="inherit" onClick={props.requestLogin}>
|
||||
Log in
|
||||
|
|
|
@ -2,14 +2,15 @@
|
|||
// 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
|
||||
|
||||
import Card from "@mui/material/Card";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import InputLabel from "@mui/material/InputLabel";
|
||||
import FormControl from "@mui/material/FormControl";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Select from "@mui/material/Select";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import FormGroup from "@mui/material/FormGroup";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
interface Props {
|
||||
split90k?: number;
|
||||
|
@ -35,70 +36,70 @@ export const DEFAULT_DURATION = DURATIONS[0][1];
|
|||
const DisplaySelector = (props: Props) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
padding: theme.spacing(1),
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<FormControl fullWidth variant="outlined">
|
||||
<InputLabel id="split90k-label" shrink>
|
||||
Max video duration
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="split90k-label"
|
||||
label="Max video duration"
|
||||
id="split90k"
|
||||
size="small"
|
||||
value={props.split90k}
|
||||
onChange={(e) =>
|
||||
props.setSplit90k(
|
||||
typeof e.target.value === "string"
|
||||
? parseInt(e.target.value)
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
displayEmpty
|
||||
>
|
||||
{DURATIONS.map(([l, d]) => (
|
||||
<MenuItem key={l} value={d}>
|
||||
{l}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControlLabel
|
||||
title="Trim each segment of video so that it is fully
|
||||
<Paper style={{ padding: theme.spacing(1) }}>
|
||||
<FormGroup>
|
||||
<FormControl fullWidth variant="outlined">
|
||||
<InputLabel id="split90k-label" shrink>
|
||||
Max video duration
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="split90k-label"
|
||||
label="Max video duration"
|
||||
id="split90k"
|
||||
size="small"
|
||||
value={props.split90k}
|
||||
onChange={(e) =>
|
||||
props.setSplit90k(
|
||||
typeof e.target.value === "string"
|
||||
? parseInt(e.target.value)
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
displayEmpty
|
||||
>
|
||||
{DURATIONS.map(([l, d]) => (
|
||||
<MenuItem key={l} value={d}>
|
||||
{l}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControlLabel
|
||||
title="Trim each segment of video so that it is fully
|
||||
contained within the select time range. When this is not selected,
|
||||
all segments will overlap with the selected time range but may start
|
||||
and/or end outside it."
|
||||
control={
|
||||
<Checkbox
|
||||
checked={props.trimStartAndEnd}
|
||||
size="small"
|
||||
onChange={(event) => props.setTrimStartAndEnd(event.target.checked)}
|
||||
name="trim-start-and-end"
|
||||
color="secondary"
|
||||
/>
|
||||
}
|
||||
label="Trim start and end"
|
||||
/>
|
||||
<FormControlLabel
|
||||
title="Include a text track in each .mp4 with the
|
||||
control={
|
||||
<Checkbox
|
||||
checked={props.trimStartAndEnd}
|
||||
size="small"
|
||||
onChange={(event) =>
|
||||
props.setTrimStartAndEnd(event.target.checked)
|
||||
}
|
||||
name="trim-start-and-end"
|
||||
color="secondary"
|
||||
/>
|
||||
}
|
||||
label="Trim start and end"
|
||||
/>
|
||||
<FormControlLabel
|
||||
title="Include a text track in each .mp4 with the
|
||||
timestamp at which the video was recorded."
|
||||
control={
|
||||
<Checkbox
|
||||
checked={props.timestampTrack}
|
||||
size="small"
|
||||
onChange={(event) => props.setTimestampTrack(event.target.checked)}
|
||||
name="timestamp-track"
|
||||
color="secondary"
|
||||
/>
|
||||
}
|
||||
label="Timestamp track"
|
||||
/>
|
||||
</Card>
|
||||
control={
|
||||
<Checkbox
|
||||
checked={props.timestampTrack}
|
||||
size="small"
|
||||
onChange={(event) =>
|
||||
props.setTimestampTrack(event.target.checked)
|
||||
}
|
||||
name="timestamp-track"
|
||||
color="secondary"
|
||||
/>
|
||||
}
|
||||
label="Timestamp track"
|
||||
/>
|
||||
</FormGroup>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -3,11 +3,11 @@
|
|||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import Card from "@mui/material/Card";
|
||||
import { Camera, Stream, StreamType } from "../types";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { ToplevelResponse } from "../api";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
interface Props {
|
||||
toplevel: ToplevelResponse;
|
||||
|
@ -91,11 +91,7 @@ const StreamMultiSelector = ({ toplevel, selected, setSelected }: Props) => {
|
|||
);
|
||||
});
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
padding: theme.spacing(1),
|
||||
}}
|
||||
>
|
||||
<Paper sx={{ padding: theme.spacing(1) }}>
|
||||
<Box
|
||||
component="table"
|
||||
sx={{
|
||||
|
@ -125,7 +121,7 @@ const StreamMultiSelector = ({ toplevel, selected, setSelected }: Props) => {
|
|||
</thead>
|
||||
<tbody>{cameraRows}</tbody>
|
||||
</Box>
|
||||
</Card>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -59,8 +59,6 @@ import React, { useEffect } from "react";
|
|||
import { zonedTimeToUtc } from "date-fns-tz";
|
||||
import { addDays, addMilliseconds, differenceInMilliseconds } from "date-fns";
|
||||
import startOfDay from "date-fns/startOfDay";
|
||||
import Card from "@mui/material/Card";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import FormLabel from "@mui/material/FormLabel";
|
||||
import Radio from "@mui/material/Radio";
|
||||
|
@ -68,6 +66,8 @@ import RadioGroup from "@mui/material/RadioGroup";
|
|||
import { TimePicker, TimePickerProps } from "@mui/x-date-pickers/TimePicker";
|
||||
import Collapse from "@mui/material/Collapse";
|
||||
import Box from "@mui/material/Box";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
interface Props {
|
||||
selectedStreams: Set<Stream>;
|
||||
|
@ -140,7 +140,7 @@ const SmallStaticDatePicker = (props: StaticDatePickerProps<Date>) => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<StaticDatePicker {...props} />
|
||||
<StaticDatePicker {...props} sx={{ background: "transparent" }} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
@ -371,8 +371,8 @@ const TimerangeSelector = ({
|
|||
endDate = new Date(days.rangeMillis[1]);
|
||||
}
|
||||
return (
|
||||
<Card sx={{ padding: theme.spacing(1) }}>
|
||||
<div>
|
||||
<Paper sx={{ padding: theme.spacing(1) }}>
|
||||
<Box>
|
||||
<FormLabel component="legend">From</FormLabel>
|
||||
<SmallStaticDatePicker
|
||||
displayStaticWrapperAs="desktop"
|
||||
|
@ -398,9 +398,11 @@ const TimerangeSelector = ({
|
|||
}}
|
||||
disabled={days.allowed === null}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel component="legend">To</FormLabel>
|
||||
</Box>
|
||||
<Box>
|
||||
<FormLabel sx={{ mt: 1 }} component="legend">
|
||||
To
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
row
|
||||
value={days.endType}
|
||||
|
@ -447,8 +449,8 @@ const TimerangeSelector = ({
|
|||
}}
|
||||
disabled={days.allowed === null}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -240,6 +240,7 @@ const Main = ({ toplevel, timeZoneName, Frame }: Props) => {
|
|||
<TableContainer
|
||||
component={Paper}
|
||||
sx={{
|
||||
mx: 1,
|
||||
flexGrow: 1,
|
||||
width: "max-content",
|
||||
height: "max-content",
|
||||
|
@ -272,6 +273,7 @@ const Main = ({ toplevel, timeZoneName, Frame }: Props) => {
|
|||
aria-label="selectors"
|
||||
onClick={toggleShowSelectors}
|
||||
color="inherit"
|
||||
sx={showSelectors ? { border: `1px solid #eee` } : {}}
|
||||
size="small"
|
||||
>
|
||||
<FilterList />
|
||||
|
@ -287,12 +289,12 @@ const Main = ({ toplevel, timeZoneName, Frame }: Props) => {
|
|||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: showSelectors ? "block" : "none",
|
||||
width: "max-content",
|
||||
"& .MuiCard-root": {
|
||||
marginRight: theme.spacing(2),
|
||||
marginBottom: theme.spacing(2),
|
||||
},
|
||||
display: showSelectors ? "flex" : "none",
|
||||
maxWidth: { xs: "100%", sm: "300px", md: "300px" },
|
||||
|
||||
gap: 1,
|
||||
mb: 2,
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<StreamMultiSelector
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
// 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
|
||||
|
||||
import React, { SyntheticEvent } from "react";
|
||||
import React from "react";
|
||||
import { Camera } from "../types";
|
||||
import { Part, parsePart } from "./parser";
|
||||
import * as api from "../api";
|
||||
|
@ -63,15 +63,34 @@ class LiveCameraDriver {
|
|||
camera: Camera,
|
||||
setPlaybackState: (state: PlaybackState) => void,
|
||||
setAspect: (aspect: [number, number]) => void,
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
video: HTMLVideoElement
|
||||
) {
|
||||
this.camera = camera;
|
||||
this.setPlaybackState = setPlaybackState;
|
||||
this.setAspect = setAspect;
|
||||
this.videoRef = videoRef;
|
||||
this.video = video;
|
||||
video.addEventListener("pause", this.videoPause);
|
||||
video.addEventListener("play", this.videoPlay);
|
||||
video.addEventListener("playing", this.videoPlaying);
|
||||
video.addEventListener("timeupdate", this.videoTimeUpdate);
|
||||
video.addEventListener("waiting", this.videoWaiting);
|
||||
this.src.addEventListener("sourceopen", this.onMediaSourceOpen);
|
||||
this.video.src = this.url;
|
||||
}
|
||||
|
||||
unmount = () => {
|
||||
this.stopStream("unmount");
|
||||
const v = this.video;
|
||||
v.removeEventListener("pause", this.videoPause);
|
||||
v.removeEventListener("play", this.videoPlay);
|
||||
v.removeEventListener("playing", this.videoPlaying);
|
||||
v.removeEventListener("timeupdate", this.videoTimeUpdate);
|
||||
v.removeEventListener("waiting", this.videoWaiting);
|
||||
v.src = "";
|
||||
v.load();
|
||||
URL.revokeObjectURL(this.url);
|
||||
};
|
||||
|
||||
onMediaSourceOpen = () => {
|
||||
this.startStream("sourceopen");
|
||||
};
|
||||
|
@ -252,12 +271,11 @@ class LiveCameraDriver {
|
|||
if (
|
||||
this.buf.state !== "open" ||
|
||||
this.buf.busy ||
|
||||
this.buf.srcBuf.buffered.length === 0 ||
|
||||
this.videoRef.current === null
|
||||
this.buf.srcBuf.buffered.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const curTs = this.videoRef.current.currentTime;
|
||||
const curTs = this.video.currentTime;
|
||||
|
||||
// TODO: call out key frames in the part headers. The "- 5" here is a guess
|
||||
// to avoid removing anything from the current GOP.
|
||||
|
@ -273,13 +291,23 @@ class LiveCameraDriver {
|
|||
this.error(`SourceBuffer ${e.type}`);
|
||||
};
|
||||
|
||||
videoPlaying = (e: SyntheticEvent<HTMLVideoElement, Event>) => {
|
||||
videoPause = () => {
|
||||
this.stopStream("pause");
|
||||
};
|
||||
|
||||
videoPlay = () => {
|
||||
this.startStream("play");
|
||||
};
|
||||
|
||||
videoPlaying = () => {
|
||||
if (this.buf.state !== "error") {
|
||||
this.setPlaybackState({ state: "normal" });
|
||||
}
|
||||
};
|
||||
|
||||
videoWaiting = (e: SyntheticEvent<HTMLVideoElement, Event>) => {
|
||||
videoTimeUpdate = () => {};
|
||||
|
||||
videoWaiting = () => {
|
||||
if (this.buf.state !== "error") {
|
||||
this.setPlaybackState({ state: "waiting" });
|
||||
}
|
||||
|
@ -302,7 +330,7 @@ class LiveCameraDriver {
|
|||
camera: Camera;
|
||||
setPlaybackState: (state: PlaybackState) => void;
|
||||
setAspect: (aspect: [number, number]) => void;
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
video: HTMLVideoElement;
|
||||
|
||||
src = new MediaSource();
|
||||
buf: BufferState = { state: "closed" };
|
||||
|
@ -321,7 +349,6 @@ class LiveCameraDriver {
|
|||
* Note there's a significant setup cost to creating a LiveCamera, so the parent
|
||||
* should use React's <tt>key</tt> attribute to avoid unnecessarily mounting
|
||||
* and unmounting a camera.
|
||||
*
|
||||
*/
|
||||
const LiveCamera = ({ camera, chooser }: LiveCameraProps) => {
|
||||
const [aspect, setAspect] = React.useState<[number, number]>([16, 9]);
|
||||
|
@ -339,25 +366,15 @@ const LiveCamera = ({ camera, chooser }: LiveCameraProps) => {
|
|||
});
|
||||
|
||||
// Load the camera driver.
|
||||
const [driver, setDriver] = React.useState<LiveCameraDriver | null>(null);
|
||||
React.useEffect(() => {
|
||||
setPlaybackState({ state: "normal" });
|
||||
if (camera === null) {
|
||||
setDriver(null);
|
||||
const video = videoRef.current;
|
||||
if (camera === null || video === null) {
|
||||
return;
|
||||
}
|
||||
const d = new LiveCameraDriver(
|
||||
camera,
|
||||
setPlaybackState,
|
||||
setAspect,
|
||||
videoRef
|
||||
);
|
||||
setDriver(d);
|
||||
const d = new LiveCameraDriver(camera, setPlaybackState, setAspect, video);
|
||||
return () => {
|
||||
// Explictly stop the stream on unmount. There don't seem to be any DOM
|
||||
// event handlers that run in this case. (In particular, the MediaSource's
|
||||
// sourceclose doesn't run.)
|
||||
d.stopStream("unmount or camera change");
|
||||
d.unmount();
|
||||
};
|
||||
}, [camera]);
|
||||
|
||||
|
@ -372,22 +389,6 @@ const LiveCamera = ({ camera, chooser }: LiveCameraProps) => {
|
|||
return () => clearTimeout(timerId);
|
||||
}, [playbackState]);
|
||||
|
||||
const videoElement =
|
||||
driver === null ? (
|
||||
<video />
|
||||
) : (
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted
|
||||
autoPlay
|
||||
src={driver.url}
|
||||
onPause={() => driver.stopStream("pause")}
|
||||
onPlay={() => driver.startStream("play")}
|
||||
onPlaying={driver.videoPlaying}
|
||||
onTimeUpdate={driver.tryTrimBuffer}
|
||||
onWaiting={driver.videoWaiting}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Box
|
||||
ref={boxRef}
|
||||
|
@ -446,7 +447,7 @@ const LiveCamera = ({ camera, chooser }: LiveCameraProps) => {
|
|||
<Alert severity="error">{playbackState.message}</Alert>
|
||||
</div>
|
||||
)}
|
||||
{videoElement}
|
||||
<video ref={videoRef} muted autoPlay />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,22 +5,26 @@
|
|||
import Box from "@mui/material/Box";
|
||||
import Select, { SelectChangeEvent } from "@mui/material/Select";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import React, { useReducer } from "react";
|
||||
import React, { useCallback, useEffect, useReducer } from "react";
|
||||
import { Camera } from "../types";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Fullscreen from "@mui/icons-material/Fullscreen";
|
||||
|
||||
export interface Layout {
|
||||
className: string;
|
||||
cameras: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// These class names must match useStyles rules (below).
|
||||
const LAYOUTS: Layout[] = [
|
||||
{ className: "solo", cameras: 1 },
|
||||
{ className: "main-plus-five", cameras: 6 },
|
||||
{ className: "two-by-two", cameras: 4 },
|
||||
{ className: "three-by-three", cameras: 9 },
|
||||
{ className: "solo", cameras: 1, name: "1" },
|
||||
{ className: "dual", cameras: 2, name: "2" },
|
||||
{ className: "main-plus-five", cameras: 6, name: "Main + 5" },
|
||||
{ className: "two-by-two", cameras: 4, name: "2x2" },
|
||||
{ className: "three-by-three", cameras: 9, name: "3x3" },
|
||||
];
|
||||
const MAX_CAMERAS = 9;
|
||||
|
||||
|
@ -63,7 +67,7 @@ export const MultiviewChooser = (props: MultiviewChooserProps) => {
|
|||
>
|
||||
{LAYOUTS.map((e, i) => (
|
||||
<MenuItem key={e.className} value={i}>
|
||||
{e.className}
|
||||
{e.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
|
@ -106,18 +110,45 @@ function selectedReducer(old: SelectedCameras, op: SelectOp): SelectedCameras {
|
|||
*/
|
||||
const Multiview = (props: MultiviewProps) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const theme = useTheme();
|
||||
|
||||
const [selected, updateSelected] = useReducer(
|
||||
selectedReducer,
|
||||
searchParams.has("cams")
|
||||
? JSON.parse(searchParams.get("cams") || "")
|
||||
: localStorage.getItem("camsSelected") !== null
|
||||
? JSON.parse(localStorage.getItem("camsSelected") || "")
|
||||
: Array(MAX_CAMERAS).fill(null)
|
||||
);
|
||||
|
||||
/**
|
||||
* Save previously selected cameras to local storage.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (searchParams.has("cams"))
|
||||
localStorage.setItem("camsSelected", searchParams.get("cams") || "");
|
||||
}, [searchParams]);
|
||||
|
||||
const outerRef = React.useRef<HTMLDivElement>(null);
|
||||
const layout = LAYOUTS[props.layoutIndex];
|
||||
|
||||
/**
|
||||
* Toggle full screen.
|
||||
*/
|
||||
const handleFullScreen = useCallback(() => {
|
||||
if (outerRef.current) {
|
||||
const elem = outerRef.current;
|
||||
if (document.fullscreenElement) {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
} else {
|
||||
if (elem.requestFullscreen) {
|
||||
elem.requestFullscreen();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [outerRef]);
|
||||
|
||||
const monoviews = selected.slice(0, layout.cameras).map((e, i) => {
|
||||
// When a camera is selected, use the camera's index as the key.
|
||||
// This allows swapping cameras' positions without tearing down their
|
||||
|
@ -153,7 +184,6 @@ const Multiview = (props: MultiviewProps) => {
|
|||
sx={{
|
||||
flex: "1 0 0",
|
||||
color: "white",
|
||||
marginTop: theme.spacing(2),
|
||||
overflow: "hidden",
|
||||
|
||||
// TODO: this mid-level div can probably be removed.
|
||||
|
@ -165,6 +195,27 @@ const Multiview = (props: MultiviewProps) => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Toggle full screen">
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{
|
||||
position: "fixed",
|
||||
background: "rgba(50,50,50,0.4) !important",
|
||||
transition: "0.2s",
|
||||
opacity: "0.4",
|
||||
bottom: 10,
|
||||
right: 10,
|
||||
zIndex: 9,
|
||||
color: "#fff",
|
||||
":hover": {
|
||||
opacity: "1",
|
||||
},
|
||||
}}
|
||||
onClick={handleFullScreen}
|
||||
>
|
||||
<Fullscreen />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<div className="mid">
|
||||
<Box
|
||||
className={layout.className}
|
||||
|
@ -184,6 +235,18 @@ const Multiview = (props: MultiviewProps) => {
|
|||
gridTemplateColumns: "100%",
|
||||
gridTemplateRows: "100%",
|
||||
},
|
||||
"&.dual": {
|
||||
gridTemplateColumns: {
|
||||
xs: "100%",
|
||||
sm: "100%",
|
||||
md: "repeat(2, calc(100% / 2))",
|
||||
},
|
||||
gridTemplateRows: {
|
||||
xs: "50%",
|
||||
sm: "50%",
|
||||
md: "repeat(1, calc(100% / 1))",
|
||||
},
|
||||
},
|
||||
"&.two-by-two": {
|
||||
gridTemplateColumns: "repeat(2, calc(100% / 2))",
|
||||
gridTemplateRows: "repeat(2, calc(100% / 2))",
|
||||
|
@ -229,8 +292,11 @@ const Monoview = (props: MonoviewProps) => {
|
|||
displayEmpty
|
||||
size="small"
|
||||
sx={{
|
||||
transform: "scale(0.8)",
|
||||
// Restyle to fit over the video (or black).
|
||||
backgroundColor: "rgba(255, 255, 255, 0.5)",
|
||||
backgroundColor: "rgba(50, 50, 50, 0.6)",
|
||||
boxShadow: "0 0 10px rgba(0, 0, 0, 0.4)",
|
||||
color: "#fff",
|
||||
"& svg": {
|
||||
color: "inherit",
|
||||
},
|
||||
|
|
|
@ -9,7 +9,7 @@ import LiveCamera from "./LiveCamera";
|
|||
import Multiview, { MultiviewChooser } from "./Multiview";
|
||||
import { FrameProps } from "../App";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface LiveProps {
|
||||
cameras: Camera[];
|
||||
|
@ -20,9 +20,22 @@ const Live = ({ cameras, Frame }: LiveProps) => {
|
|||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [multiviewLayoutIndex, setMultiviewLayoutIndex] = useState(
|
||||
Number.parseInt(searchParams.get("layout") || "0", 10)
|
||||
Number.parseInt(
|
||||
searchParams.get("layout") ||
|
||||
localStorage.getItem("multiviewLayoutIndex") ||
|
||||
"0",
|
||||
10
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.has("layout"))
|
||||
localStorage.setItem(
|
||||
"multiviewLayoutIndex",
|
||||
searchParams.get("layout") || "0"
|
||||
);
|
||||
}, [searchParams]);
|
||||
|
||||
if ("MediaSource" in window === false) {
|
||||
return (
|
||||
<Frame>
|
||||
|
|
|
@ -2,18 +2,21 @@
|
|||
// 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
|
||||
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import FormHelperText from "@mui/material/FormHelperText";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
|
||||
import LoadingButton from "@mui/lab/LoadingButton";
|
||||
import React, { useEffect } from "react";
|
||||
import * as api from "./api";
|
||||
import { useSnackbars } from "./snackbars";
|
||||
import Box from "@mui/material/Box/Box";
|
||||
import DialogContent from "@mui/material/DialogContent/DialogContent";
|
||||
import InputAdornment from "@mui/material/InputAdornment/InputAdornment";
|
||||
import Typography from "@mui/material/Typography/Typography";
|
||||
import AccountCircle from "@mui/icons-material/AccountCircle";
|
||||
import Lock from "@mui/icons-material/Lock";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
@ -47,7 +50,6 @@ interface Props {
|
|||
* <tt>--allow-unauthenticated-permissions</tt>), the caller may ignore this.
|
||||
*/
|
||||
const Login = ({ open, onSuccess, handleClose }: Props) => {
|
||||
const theme = useTheme();
|
||||
const snackbars = useSnackbars();
|
||||
|
||||
// This is a simple uncontrolled form; use refs.
|
||||
|
@ -111,38 +113,52 @@ const Login = ({ open, onSuccess, handleClose }: Props) => {
|
|||
fullWidth={true}
|
||||
>
|
||||
<DialogTitle id="login-title">
|
||||
<Avatar sx={{ backgroundColor: theme.palette.secondary.main }}>
|
||||
<LockOutlinedIcon />
|
||||
</Avatar>
|
||||
Log in
|
||||
Welcome back!
|
||||
<Typography variant="body2">Please login to Moonfire NVR.</Typography>
|
||||
</DialogTitle>
|
||||
|
||||
<form onSubmit={onSubmit}>
|
||||
<TextField
|
||||
id="username"
|
||||
label="Username"
|
||||
variant="filled"
|
||||
required
|
||||
autoComplete="username"
|
||||
fullWidth
|
||||
error={error != null}
|
||||
inputRef={usernameRef}
|
||||
/>
|
||||
<TextField
|
||||
id="password"
|
||||
label="Password"
|
||||
variant="filled"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
fullWidth
|
||||
error={error != null}
|
||||
inputRef={passwordRef}
|
||||
/>
|
||||
|
||||
{/* reserve space for an error; show when there's something to see */}
|
||||
<FormHelperText>{error == null ? " " : error}</FormHelperText>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<TextField
|
||||
id="username"
|
||||
label="Username"
|
||||
variant="outlined"
|
||||
required
|
||||
autoComplete="username"
|
||||
fullWidth
|
||||
error={error != null}
|
||||
inputRef={usernameRef}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<AccountCircle />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
id="password"
|
||||
label="Password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
fullWidth
|
||||
error={error != null}
|
||||
inputRef={passwordRef}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Lock />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* reserve space for an error; show when there's something to see */}
|
||||
<FormHelperText>{error == null ? " " : error}</FormHelperText>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
|
|
|
@ -0,0 +1,100 @@
|
|||
// This file is part of Moonfire NVR, a security camera network video recorder.
|
||||
// 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
|
||||
|
||||
import AppBar from "@mui/material/AppBar";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import List from "@mui/material/List";
|
||||
import ListItemButton from "@mui/material/ListItemButton";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import ListIcon from "@mui/icons-material/List";
|
||||
import PeopleIcon from "@mui/icons-material/People";
|
||||
import Videocam from "@mui/icons-material/Videocam";
|
||||
import * as api from "../api";
|
||||
|
||||
import MoonfireMenu from "../AppMenu";
|
||||
import { useReducer } from "react";
|
||||
import { LoginState } from "../App";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export default function Header({
|
||||
loginState,
|
||||
logout,
|
||||
setChangePasswordOpen,
|
||||
activityMenuPart,
|
||||
setLoginState,
|
||||
toplevel,
|
||||
}: {
|
||||
loginState: LoginState;
|
||||
logout: () => void;
|
||||
setChangePasswordOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
activityMenuPart?: JSX.Element;
|
||||
setLoginState: React.Dispatch<React.SetStateAction<LoginState>>;
|
||||
toplevel: api.ToplevelResponse | null;
|
||||
}) {
|
||||
const [showMenu, toggleShowMenu] = useReducer((m: boolean) => !m, false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppBar position="sticky">
|
||||
<MoonfireMenu
|
||||
loginState={loginState}
|
||||
requestLogin={() => {
|
||||
setLoginState("user-requested-login");
|
||||
}}
|
||||
logout={logout}
|
||||
changePassword={() => setChangePasswordOpen(true)}
|
||||
menuClick={toggleShowMenu}
|
||||
activityMenuPart={activityMenuPart}
|
||||
/>
|
||||
</AppBar>
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={showMenu}
|
||||
onClose={toggleShowMenu}
|
||||
ModalProps={{
|
||||
keepMounted: true,
|
||||
}}
|
||||
>
|
||||
<List>
|
||||
<ListItemButton
|
||||
key="list"
|
||||
onClick={toggleShowMenu}
|
||||
component={Link}
|
||||
to="/"
|
||||
>
|
||||
<ListItemIcon>
|
||||
<ListIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="List view" />
|
||||
</ListItemButton>
|
||||
<ListItemButton
|
||||
key="live"
|
||||
onClick={toggleShowMenu}
|
||||
component={Link}
|
||||
to="/live"
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Videocam />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Live view (experimental)" />
|
||||
</ListItemButton>
|
||||
{toplevel?.permissions.adminUsers && (
|
||||
<ListItemButton
|
||||
key="users"
|
||||
onClick={toggleShowMenu}
|
||||
component={Link}
|
||||
to="/users"
|
||||
>
|
||||
<ListItemIcon>
|
||||
<PeopleIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Users" />
|
||||
</ListItemButton>
|
||||
)}
|
||||
</List>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
// This file is part of Moonfire NVR, a security camera network video recorder.
|
||||
// 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
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { useColorScheme } from "@mui/material/styles";
|
||||
import React, { createContext } from "react";
|
||||
|
||||
export enum CurrentMode {
|
||||
Auto = 0,
|
||||
Light = 1,
|
||||
Dark = 2,
|
||||
}
|
||||
|
||||
interface ThemeProps {
|
||||
changeTheme: () => void;
|
||||
currentTheme: "dark" | "light";
|
||||
choosenTheme: CurrentMode;
|
||||
}
|
||||
|
||||
export const ThemeContext = createContext<ThemeProps>({
|
||||
currentTheme: window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light",
|
||||
changeTheme: () => {},
|
||||
choosenTheme: CurrentMode.Auto,
|
||||
});
|
||||
|
||||
const ThemeMode = ({ children }: { children: JSX.Element }): JSX.Element => {
|
||||
const { mode, setMode } = useColorScheme();
|
||||
|
||||
const useMediaQuery = (query: string) => {
|
||||
const [matches, setMatches] = React.useState(
|
||||
() => window.matchMedia(query).matches
|
||||
);
|
||||
React.useEffect(() => {
|
||||
const m = window.matchMedia(query);
|
||||
const l = () => setMatches(m.matches);
|
||||
m.addEventListener("change", l);
|
||||
return () => m.removeEventListener("change", l);
|
||||
}, [query]);
|
||||
return matches;
|
||||
};
|
||||
|
||||
const detectedSystemColorScheme = useMediaQuery(
|
||||
"(prefers-color-scheme: dark)"
|
||||
)
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
const changeTheme = React.useCallback(() => {
|
||||
setMode(mode === "dark" ? "light" : mode === "light" ? "system" : "dark");
|
||||
}, [mode, setMode]);
|
||||
|
||||
const currentTheme =
|
||||
mode === "system"
|
||||
? detectedSystemColorScheme
|
||||
: mode ?? detectedSystemColorScheme;
|
||||
const choosenTheme =
|
||||
mode === "dark"
|
||||
? CurrentMode.Dark
|
||||
: mode === "light"
|
||||
? CurrentMode.Light
|
||||
: CurrentMode.Auto;
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ changeTheme, currentTheme, choosenTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeMode;
|
||||
|
||||
export const useThemeMode = () => React.useContext(ThemeContext);
|
|
@ -4,9 +4,23 @@
|
|||
* SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
|
||||
*/
|
||||
|
||||
:root {
|
||||
--mui-palette-AppBar-darkBg: #000 !important;
|
||||
--mui-palette-primary-main: #000 !important;
|
||||
--mui-palette-secondary-main: #e65100 !important;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
[data-mui-color-scheme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
|
|
@ -3,7 +3,10 @@
|
|||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
|
||||
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||
import {
|
||||
Experimental_CssVarsProvider,
|
||||
experimental_extendTheme,
|
||||
} from "@mui/material/styles";
|
||||
import StyledEngineProvider from "@mui/material/StyledEngineProvider";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||
import "@fontsource/roboto";
|
||||
|
@ -15,35 +18,43 @@ import { SnackbarProvider } from "./snackbars";
|
|||
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
|
||||
import "./index.css";
|
||||
import { HashRouter } from "react-router-dom";
|
||||
import ThemeMode from "./components/ThemeMode";
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: "#000000",
|
||||
},
|
||||
secondary: {
|
||||
main: "#e65100",
|
||||
const themeExtended = experimental_extendTheme({
|
||||
colorSchemes: {
|
||||
dark: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: "#000000",
|
||||
},
|
||||
secondary: {
|
||||
main: "#e65100",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.getElementById("root");
|
||||
const root = createRoot(container!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<StyledEngineProvider injectFirst>
|
||||
<CssBaseline />
|
||||
<ThemeProvider theme={theme}>
|
||||
<ErrorBoundary>
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
<SnackbarProvider autoHideDuration={5000}>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</SnackbarProvider>
|
||||
</LocalizationProvider>
|
||||
</ErrorBoundary>
|
||||
</ThemeProvider>
|
||||
{/* <ThemeProvider theme={theme}> */}
|
||||
<Experimental_CssVarsProvider defaultMode="system" theme={themeExtended}>
|
||||
<CssBaseline />
|
||||
<ThemeMode>
|
||||
<ErrorBoundary>
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
<SnackbarProvider autoHideDuration={5000}>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</SnackbarProvider>
|
||||
</LocalizationProvider>
|
||||
</ErrorBoundary>
|
||||
</ThemeMode>
|
||||
</Experimental_CssVarsProvider>
|
||||
{/* </ThemeProvider> */}
|
||||
</StyledEngineProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
|
@ -7,3 +7,18 @@
|
|||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import "@testing-library/jest-dom";
|
||||
import { vi } from "vitest";
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // deprecated
|
||||
removeListener: vi.fn(), // deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
|
|
@ -5,12 +5,19 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import viteCompression from "vite-plugin-compression";
|
||||
import viteLegacyPlugin from "@vitejs/plugin-legacy";
|
||||
|
||||
const target = process.env.PROXY_TARGET ?? "http://localhost:8080/";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), viteCompression()],
|
||||
plugins: [
|
||||
react(),
|
||||
viteCompression(),
|
||||
viteLegacyPlugin({
|
||||
targets: ["defaults", "fully supports es6-module"],
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
|
|
Loading…
Reference in New Issue