Compare commits

...

21 Commits

Author SHA1 Message Date
Bobberty b310459cac
Merge 3df84a11cd into 93a9ad9af3 2024-04-22 12:44:30 +00:00
Scott Lamb 93a9ad9af3 attempt at iPhone support (#121) 2024-04-16 21:20:07 -07:00
Scott Lamb 9acb095a5d prep v0.7.14 2024-04-16 21:07:34 -07:00
Scott Lamb 8b5f2b4b0d work on Firefox!
Fixes #286.

I dug into Firefox code a bit to understand this but got a lost. But
I guess there are different policies for what's accessible via
`video.src = ""` than for `<video src="">`. This works for now, matches
what other players such as `mpegts.js` do, and is closer to what Safari
MME (required on iPhone) needs anyway. (With MME, apparently you have to
use `video.srcObject`, or the `MediaSource`'s `sourceopen` event will
never fire.)
2024-04-16 17:08:16 -07:00
Scott Lamb a65994ba71 match VS Code extension rename 2024-04-16 16:44:36 -07:00
Scott Lamb ef98f60241 mention michioxd's #315 changes 2024-04-16 16:43:57 -07:00
Scott Lamb 7f4b04ee8a list ui selector layout fixes
* The `DisplaySelector` wasn't getting the correct flex layout.
  Before this was done by a manual style on a `Paper` element. That
  broke when adding the inner `<CardContent>` to because that's the
  container that needs the `display: "flex"`. But really, it's clearer
  to do this with `<FormGroup>` anyway, so do that.

* Switch from `<Card><CardContent>` to `<Paper sx={{ padding: ... }}>`.
  The card content has extra padding (16 px in general, 24 at the bottom
  of the last element to fit with an action). I'm not quite sure the
  best way to remove it, and the simpler `<Paper>` seems fine for this
  use anyway.
2024-04-15 21:46:22 -07:00
michioxd 9ede361b25 switch to `pnpm` 2024-04-13 21:53:59 -07:00
michioxd 8036aa40b7 prettify code 2024-04-13 21:53:59 -07:00
michioxd a787703a31 added `jsdom` to dev deps 2024-04-13 21:53:59 -07:00
michioxd 3f4cee7ead Switch to `pnpm` 2024-04-13 21:53:59 -07:00
michioxd c67a5ffba5 Added more dark background for select camera and fullscreen button. 2024-04-13 21:53:59 -07:00
michioxd 305deaa1e7 Set default theme mode to based on system color scheme 2024-04-13 21:53:59 -07:00
michioxd 29cafc2f82 Fixed code that didn't meet the eslint rule. 2024-04-13 21:53:59 -07:00
michioxd 60c6247ef9 Fixed `TypeError: window.matchMedia is not a function` during testing via vitest. 2024-04-13 21:53:59 -07:00
michioxd 317b8e9484 Resolved know problem. 2024-04-13 21:53:59 -07:00
michioxd 91e02eba7a Add copyright/license header for each components 2024-04-13 21:53:59 -07:00
michioxd 5b5822900d Update lock file for `npm` 2024-04-13 21:53:59 -07:00
michioxd b46d3acabb force color white in camera selector due to light theme 2024-04-13 21:53:59 -07:00
michioxd 6e81b27d1a Extra change for Moonfire WebUI 2024-04-13 21:53:59 -07:00
Bobberty 3df84a11cd
Create haproxy-moonfire-tls-client.conf
Added a folder for config examples.  And added a config example for using HAProxy using IPv6, TLS, and Client Certs.  This also provides for local users without client certs.
2021-07-22 11:13:13 -04:00
28 changed files with 9244 additions and 16361 deletions

View File

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

View File

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

View File

@ -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": [

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,89 @@
#
# This is an example file for providing moonfire-nvr TLS connection support using IPv6.
# Additionally, this example provides client certificate authentication.
# This example will allow users on the same /64 subnet to access moonfire-nvr
# without a client certificate.
#
# For this to work properly, the server requires a FQDN assigned to the IPv6
# address for the server certificate.
#
# Please replace MYSERVERCERT, MYROOT_CA, MYSUBJECTDNCHECK as apropiate.
# And ensure rights are proper for accessing the certificaates.
#
# For my usage, I utilize a DDNS service and a Cert/Key management system.
#
# The IPv6 local check depends on the network prefix with a /64. Replace
# MYIPv6NetworkSubnet with the local network prefix.
#
# This is running on an RPI4/RaspberryPI OS running multiple cameras at a remote site.
# Source build without Docker.
#
# Note: I have modified the Systemd unit file to reflect binding to [::1]:8080
#
# As with anything else, this may be a starting place. Improvements are a neccessity for life.
#
global
log /dev/log local0
log /dev/log local1 notice
chroot /var/lib/haproxy
stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
stats timeout 30s
user haproxy
group haproxy
daemon
# Default SSL material locations
ca-base /etc/ssl/certs
crt-base /etc/ssl/private
# Default ciphers to use on SSL-enabled listening sockets.
# For more information, see ciphers(1SSL). This list is from:
# https://hynek.me/articles/hardening-your-web-servers-ssl-ciphers/
# An alternative list with additional directives can be obtained from
# https://mozilla.github.io/server-side-tls/ssl-config-generator/?server=haproxy
ssl-default-bind-ciphers ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!MD5:!DSS
ssl-default-bind-options no-sslv3
defaults
log global
mode http
option httplog
option dontlognull
timeout connect 5000
timeout client 50000
timeout server 50000
errorfile 400 /etc/haproxy/errors/400.http
errorfile 403 /etc/haproxy/errors/403.http
errorfile 408 /etc/haproxy/errors/408.http
errorfile 500 /etc/haproxy/errors/500.http
errorfile 502 /etc/haproxy/errors/502.http
errorfile 503 /etc/haproxy/errors/503.http
errorfile 504 /etc/haproxy/errors/504.http
frontend https
bind :::443 v6only ssl crt *MYSERVERCERT*.pem verify optional ca-file *MYROOT_CA*.pem crt-ignore-err all ca-ignore-err all
http-request add-header X-Forwarded-Proto https
# Testing for Client Certificate used
acl clientssl ssl_c_used
acl clientssl ssl_c_s_dn(OU) "*MYSUBJECTDNCHECK*"
# Testing for local IPv6
acl LocalIPv6 src *MYIPv6NetworkSubnet*::/64
# Standard Interface test
use_backend moonfire if LocalIPv6
use_backend moonfire if clientssl
# Fail if not local and no client cert provided
http-request deny if !LocalIPv6 !clientssl
backend moonfire
http-request add-header X-Forwarded-Host %[req.hdr(Host)]
server ipv6 [::1]:8080

View File

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

16034
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

8490
ui/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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={

View File

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

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

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

View File

@ -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";
@ -12,7 +12,20 @@ import Alert from "@mui/material/Alert";
import useResizeObserver from "@react-hook/resize-observer";
import { fillAspect } from "../aspect";
/// The media source API to use:
/// * Essentially everything but iPhone supports `MediaSource`.
/// (All major desktop browsers; Android browsers; and Safari on iPad are
/// fine.)
/// * Safari/macOS and Safari/iPhone on iOS 17+ support `ManagedMediaSource`.
/// * Safari/iPhone with older iOS does not support anything close to
/// `MediaSource`.
export const MediaSourceApi: typeof MediaSource | undefined =
(self as any).ManagedMediaSource ?? self.MediaSource;
interface LiveCameraProps {
/// Caller should provide a failure path when `MediaSourceApi` is undefined
/// and pass it back here otherwise.
mediaSourceApi: typeof MediaSource;
camera: Camera | null;
chooser: JSX.Element;
}
@ -60,18 +73,45 @@ type PlaybackState =
*/
class LiveCameraDriver {
constructor(
mediaSourceApi: typeof MediaSource,
camera: Camera,
setPlaybackState: (state: PlaybackState) => void,
setAspect: (aspect: [number, number]) => void,
videoRef: React.RefObject<HTMLVideoElement>
video: HTMLVideoElement
) {
this.mediaSourceApi = mediaSourceApi;
this.src = new mediaSourceApi();
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 appears necessary for the `ManagedMediaSource` API to function
// on Safari/iOS.
video["disableRemotePlayback"] = true;
video.src = this.objectUrl = URL.createObjectURL(this.src);
video.load();
}
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 = "";
URL.revokeObjectURL(this.objectUrl);
v.load();
};
onMediaSourceOpen = () => {
this.startStream("sourceopen");
};
@ -150,7 +190,7 @@ class LiveCameraDriver {
return;
}
const part = result.part;
if (!MediaSource.isTypeSupported(part.mimeType)) {
if (!this.mediaSourceApi.isTypeSupported(part.mimeType)) {
this.error(`unsupported mime type ${part.mimeType}`);
return;
}
@ -252,12 +292,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 +312,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,15 +351,16 @@ class LiveCameraDriver {
camera: Camera;
setPlaybackState: (state: PlaybackState) => void;
setAspect: (aspect: [number, number]) => void;
videoRef: React.RefObject<HTMLVideoElement>;
video: HTMLVideoElement;
src = new MediaSource();
mediaSourceApi: typeof MediaSource;
src: MediaSource;
buf: BufferState = { state: "closed" };
queue: Part[] = [];
queuedBytes: number = 0;
/// The object URL for the HTML video element, not the WebSocket URL.
url = URL.createObjectURL(this.src);
objectUrl: string;
ws?: WebSocket;
}
@ -321,9 +371,8 @@ 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 LiveCamera = ({ mediaSourceApi, camera, chooser }: LiveCameraProps) => {
const [aspect, setAspect] = React.useState<[number, number]>([16, 9]);
const videoRef = React.useRef<HTMLVideoElement>(null);
const boxRef = React.useRef<HTMLElement>(null);
@ -339,27 +388,23 @@ 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(
mediaSourceApi,
camera,
setPlaybackState,
setAspect,
videoRef
video
);
setDriver(d);
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]);
}, [mediaSourceApi, camera]);
// Display circular progress after 100 ms of waiting.
const [showProgress, setShowProgress] = React.useState(false);
@ -372,22 +417,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 +475,7 @@ const LiveCamera = ({ camera, chooser }: LiveCameraProps) => {
<Alert severity="error">{playbackState.message}</Alert>
</div>
)}
{videoElement}
<video ref={videoRef} muted autoPlay />
</Box>
);
};

View File

@ -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",
},

View File

@ -5,11 +5,11 @@
import Container from "@mui/material/Container";
import ErrorIcon from "@mui/icons-material/Error";
import { Camera } from "../types";
import LiveCamera from "./LiveCamera";
import LiveCamera, { MediaSourceApi } 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,10 +20,24 @@ 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
)
);
if ("MediaSource" in window === false) {
useEffect(() => {
if (searchParams.has("layout"))
localStorage.setItem(
"multiviewLayoutIndex",
searchParams.get("layout") || "0"
);
}, [searchParams]);
const mediaSourceApi = MediaSourceApi;
if (mediaSourceApi === undefined) {
return (
<Frame>
<Container>
@ -59,7 +73,11 @@ const Live = ({ cameras, Frame }: LiveProps) => {
layoutIndex={multiviewLayoutIndex}
cameras={cameras}
renderCamera={(camera: Camera | null, chooser: JSX.Element) => (
<LiveCamera camera={camera} chooser={chooser} />
<LiveCamera
mediaSourceApi={mediaSourceApi}
camera={camera}
chooser={chooser}
/>
)}
/>
</Frame>

View File

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

View File

@ -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>
</>
);
}

View File

@ -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);

View File

@ -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;
}

View File

@ -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>
);

View File

@ -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(),
})),
});

View File

@ -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": {