Extra change for Moonfire WebUI

This commit is contained in:
michioxd 2024-04-05 14:41:19 +07:00 committed by Scott Lamb
parent dbf6c2f476
commit 6e81b27d1a
16 changed files with 6450 additions and 205 deletions

View File

@ -4,21 +4,21 @@
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@emotion/react": "^11.8.2", "@emotion/react": "^11.11.4",
"@emotion/styled": "^11.8.1", "@emotion/styled": "^11.11.5",
"@fontsource/roboto": "^4.5.3", "@fontsource/roboto": "^4.5.8",
"@mui/icons-material": "^5.10.6", "@mui/icons-material": "^5.15.15",
"@mui/lab": "^5.0.0-alpha.102", "@mui/lab": "5.0.0-alpha.170",
"@mui/material": "^5.10.8", "@mui/material": "^5.15.15",
"@mui/x-date-pickers": "^6.16.3", "@mui/x-date-pickers": "^6.19.8",
"@react-hook/resize-observer": "^1.2.6", "@react-hook/resize-observer": "^1.2.6",
"date-fns": "^2.28.0", "date-fns": "^2.30.0",
"date-fns-tz": "^2.0.0", "date-fns-tz": "^2.0.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.41.5", "react-hook-form": "^7.51.2",
"react-hook-form-mui": "^6.5.2", "react-hook-form-mui": "^6.8.0",
"react-router-dom": "^6.2.2" "react-router-dom": "^6.22.3"
}, },
"scripts": { "scripts": {
"check-format": "prettier --check --ignore-path .gitignore .", "check-format": "prettier --check --ignore-path .gitignore .",
@ -80,33 +80,35 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.23.5", "@babel/core": "^7.24.4",
"@babel/preset-env": "^7.23.6", "@babel/preset-env": "^7.24.4",
"@babel/preset-react": "^7.23.3", "@babel/preset-react": "^7.24.1",
"@babel/preset-typescript": "^7.23.3", "@babel/preset-typescript": "^7.24.1",
"@swc/core": "^1.3.100", "@swc/core": "^1.4.12",
"@testing-library/dom": "^8.11.3", "@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "^6.1.5", "@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.4.3", "@testing-library/user-event": "^14.5.2",
"@types/node": "^18.8.1", "@types/node": "^18.19.29",
"@types/react": "^18.0.26", "@types/react": "^18.2.74",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.2.24",
"@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.14.0", "@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react-swc": "^3.5.0", "@vitejs/plugin-legacy": "^5.3.2",
"eslint": "^8.55.0", "@vitejs/plugin-react-swc": "^3.6.0",
"eslint-plugin-react": "^7.33.2", "eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-react-refresh": "^0.4.6",
"eslint-plugin-vitest": "^0.3.18", "eslint-plugin-vitest": "^0.3.26",
"http-proxy-middleware": "^2.0.4", "http-proxy-middleware": "^2.0.6",
"msw": "^2.0.0", "msw": "^2.2.13",
"prettier": "^2.6.0", "prettier": "^2.8.8",
"terser": "^5.30.3",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.1.0", "typescript": "^5.4.4",
"vite": "^5.0.12", "vite": "^5.2.8",
"vite-plugin-compression": "^0.5.1", "vite-plugin-compression": "^0.5.1",
"vitest": "^1.0.4" "vitest": "^1.4.0"
} }
} }

6048
ui/pnpm-lock.yaml generated 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 Container from "@mui/material/Container";
import React, { useEffect, useReducer, useState } from "react"; import React, { useEffect, useState } from "react";
import * as api from "./api"; import * as api from "./api";
import MoonfireMenu from "./AppMenu";
import Login from "./Login"; import Login from "./Login";
import { useSnackbars } from "./snackbars"; import { useSnackbars } from "./snackbars";
import ListActivity from "./List"; import ListActivity from "./List";
import AppBar from "@mui/material/AppBar"; import { Routes, Route, Navigate } from "react-router-dom";
import { Routes, Route, Link, Navigate } from "react-router-dom";
import LiveActivity from "./Live"; import LiveActivity from "./Live";
import UsersActivity from "./Users"; 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 ChangePassword from "./ChangePassword";
import Header from "./components/Header";
export type LoginState = export type LoginState =
| "unknown" | "unknown"
@ -52,7 +43,6 @@ export interface FrameProps {
} }
function App() { function App() {
const [showMenu, toggleShowMenu] = useReducer((m: boolean) => !m, false);
const [toplevel, setToplevel] = useState<api.ToplevelResponse | null>(null); const [toplevel, setToplevel] = useState<api.ToplevelResponse | null>(null);
const [timeZoneName, setTimeZoneName] = useState<string | null>(null); const [timeZoneName, setTimeZoneName] = useState<string | null>(null);
const [fetchSeq, setFetchSeq] = useState(0); const [fetchSeq, setFetchSeq] = useState(0);
@ -122,67 +112,14 @@ function App() {
const Frame = ({ activityMenuPart, children }: FrameProps): JSX.Element => { const Frame = ({ activityMenuPart, children }: FrameProps): JSX.Element => {
return ( return (
<> <>
<AppBar position="static"> <Header
<MoonfireMenu
loginState={loginState} loginState={loginState}
requestLogin={() => {
setLoginState("user-requested-login");
}}
logout={logout} logout={logout}
changePassword={() => setChangePasswordOpen(true)} setChangePasswordOpen={setChangePasswordOpen}
menuClick={toggleShowMenu}
activityMenuPart={activityMenuPart} activityMenuPart={activityMenuPart}
setLoginState={setLoginState}
toplevel={toplevel}
/> />
</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>
<Login <Login
onSuccess={onLoginSuccess} onSuccess={onLoginSuccess}
open={ open={

View File

@ -14,6 +14,9 @@ import MenuIcon from "@mui/icons-material/Menu";
import React from "react"; import React from "react";
import { LoginState } from "./App"; import { LoginState } from "./App";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import { useThemeMode } from "./components/ThemeMode";
import { Brightness2, Brightness7, BrightnessAuto } from "@mui/icons-material";
import { Tooltip } from "@mui/material";
interface Props { interface Props {
loginState: LoginState; loginState: LoginState;
@ -26,6 +29,7 @@ interface Props {
// https://material-ui.com/components/app-bar/ // https://material-ui.com/components/app-bar/
function MoonfireMenu(props: Props) { function MoonfireMenu(props: Props) {
const { getTheme, changeTheme } = useThemeMode();
const theme = useTheme(); const theme = useTheme();
const [accountMenuAnchor, setAccountMenuAnchor] = const [accountMenuAnchor, setAccountMenuAnchor] =
React.useState<null | HTMLElement>(null); React.useState<null | HTMLElement>(null);
@ -69,6 +73,15 @@ function MoonfireMenu(props: Props) {
{props.activityMenuPart} {props.activityMenuPart}
</Box> </Box>
)} )}
<Tooltip title="Toggle theme">
<IconButton
onClick={changeTheme}
color="inherit"
size="small"
>
{getTheme === 1 ? <Brightness7 /> : getTheme === 2 ? <Brightness2 /> : <BrightnessAuto />}
</IconButton>
</Tooltip>
{props.loginState !== "unknown" && props.loginState !== "logged-in" && ( {props.loginState !== "unknown" && props.loginState !== "logged-in" && (
<Button color="inherit" onClick={props.requestLogin}> <Button color="inherit" onClick={props.requestLogin}>
Log in Log in

View File

@ -8,8 +8,8 @@ import InputLabel from "@mui/material/InputLabel";
import FormControl from "@mui/material/FormControl"; import FormControl from "@mui/material/FormControl";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import Select from "@mui/material/Select"; import Select from "@mui/material/Select";
import { useTheme } from "@mui/material/styles";
import FormControlLabel from "@mui/material/FormControlLabel"; import FormControlLabel from "@mui/material/FormControlLabel";
import { CardContent } from "@mui/material";
interface Props { interface Props {
split90k?: number; split90k?: number;
@ -33,15 +33,14 @@ export const DEFAULT_DURATION = DURATIONS[0][1];
* Returns a card for setting options relating to how videos are displayed. * Returns a card for setting options relating to how videos are displayed.
*/ */
const DisplaySelector = (props: Props) => { const DisplaySelector = (props: Props) => {
const theme = useTheme();
return ( return (
<Card <Card
sx={{ sx={{
padding: theme.spacing(1),
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}} }}
> >
<CardContent>
<FormControl fullWidth variant="outlined"> <FormControl fullWidth variant="outlined">
<InputLabel id="split90k-label" shrink> <InputLabel id="split90k-label" shrink>
Max video duration Max video duration
@ -98,6 +97,7 @@ const DisplaySelector = (props: Props) => {
} }
label="Timestamp track" label="Timestamp track"
/> />
</CardContent>
</Card> </Card>
); );
}; };

View File

@ -6,8 +6,8 @@ import Box from "@mui/material/Box";
import Card from "@mui/material/Card"; import Card from "@mui/material/Card";
import { Camera, Stream, StreamType } from "../types"; import { Camera, Stream, StreamType } from "../types";
import Checkbox from "@mui/material/Checkbox"; import Checkbox from "@mui/material/Checkbox";
import { useTheme } from "@mui/material/styles";
import { ToplevelResponse } from "../api"; import { ToplevelResponse } from "../api";
import { CardContent } from "@mui/material";
interface Props { interface Props {
toplevel: ToplevelResponse; toplevel: ToplevelResponse;
@ -17,7 +17,6 @@ interface Props {
/** Returns a table which allows selecting zero or more streams. */ /** Returns a table which allows selecting zero or more streams. */
const StreamMultiSelector = ({ toplevel, selected, setSelected }: Props) => { const StreamMultiSelector = ({ toplevel, selected, setSelected }: Props) => {
const theme = useTheme();
const setStream = (s: Stream, checked: boolean) => { const setStream = (s: Stream, checked: boolean) => {
const updated = new Set(selected); const updated = new Set(selected);
if (checked) { if (checked) {
@ -92,10 +91,8 @@ const StreamMultiSelector = ({ toplevel, selected, setSelected }: Props) => {
}); });
return ( return (
<Card <Card
sx={{
padding: theme.spacing(1),
}}
> >
<CardContent>
<Box <Box
component="table" component="table"
sx={{ sx={{
@ -125,6 +122,7 @@ const StreamMultiSelector = ({ toplevel, selected, setSelected }: Props) => {
</thead> </thead>
<tbody>{cameraRows}</tbody> <tbody>{cameraRows}</tbody>
</Box> </Box>
</CardContent>
</Card> </Card>
); );
}; };

View File

@ -60,7 +60,6 @@ import { zonedTimeToUtc } from "date-fns-tz";
import { addDays, addMilliseconds, differenceInMilliseconds } from "date-fns"; import { addDays, addMilliseconds, differenceInMilliseconds } from "date-fns";
import startOfDay from "date-fns/startOfDay"; import startOfDay from "date-fns/startOfDay";
import Card from "@mui/material/Card"; import Card from "@mui/material/Card";
import { useTheme } from "@mui/material/styles";
import FormControlLabel from "@mui/material/FormControlLabel"; import FormControlLabel from "@mui/material/FormControlLabel";
import FormLabel from "@mui/material/FormLabel"; import FormLabel from "@mui/material/FormLabel";
import Radio from "@mui/material/Radio"; import Radio from "@mui/material/Radio";
@ -68,6 +67,7 @@ import RadioGroup from "@mui/material/RadioGroup";
import { TimePicker, TimePickerProps } from "@mui/x-date-pickers/TimePicker"; import { TimePicker, TimePickerProps } from "@mui/x-date-pickers/TimePicker";
import Collapse from "@mui/material/Collapse"; import Collapse from "@mui/material/Collapse";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import { CardContent } from "@mui/material";
interface Props { interface Props {
selectedStreams: Set<Stream>; selectedStreams: Set<Stream>;
@ -140,7 +140,7 @@ const SmallStaticDatePicker = (props: StaticDatePickerProps<Date>) => {
}, },
}} }}
> >
<StaticDatePicker {...props} /> <StaticDatePicker {...props} sx={{ background: "transparent" }} />
</Box> </Box>
); );
}; };
@ -326,7 +326,6 @@ const TimerangeSelector = ({
timeZoneName, timeZoneName,
setRange90k, setRange90k,
}: Props) => { }: Props) => {
const theme = useTheme();
const [days, updateDays] = React.useReducer(daysStateReducer, { const [days, updateDays] = React.useReducer(daysStateReducer, {
allowed: null, allowed: null,
rangeMillis: null, rangeMillis: null,
@ -371,8 +370,9 @@ const TimerangeSelector = ({
endDate = new Date(days.rangeMillis[1]); endDate = new Date(days.rangeMillis[1]);
} }
return ( return (
<Card sx={{ padding: theme.spacing(1) }}> <Card>
<div> <CardContent>
<Box>
<FormLabel component="legend">From</FormLabel> <FormLabel component="legend">From</FormLabel>
<SmallStaticDatePicker <SmallStaticDatePicker
displayStaticWrapperAs="desktop" displayStaticWrapperAs="desktop"
@ -398,9 +398,9 @@ const TimerangeSelector = ({
}} }}
disabled={days.allowed === null} disabled={days.allowed === null}
/> />
</div> </Box>
<div> <Box>
<FormLabel component="legend">To</FormLabel> <FormLabel sx={{ mt: 1 }} component="legend">To</FormLabel>
<RadioGroup <RadioGroup
row row
value={days.endType} value={days.endType}
@ -447,7 +447,8 @@ const TimerangeSelector = ({
}} }}
disabled={days.allowed === null} disabled={days.allowed === null}
/> />
</div> </Box>
</CardContent>
</Card> </Card>
); );
}; };

View File

@ -240,6 +240,7 @@ const Main = ({ toplevel, timeZoneName, Frame }: Props) => {
<TableContainer <TableContainer
component={Paper} component={Paper}
sx={{ sx={{
mx: 1,
flexGrow: 1, flexGrow: 1,
width: "max-content", width: "max-content",
height: "max-content", height: "max-content",
@ -272,6 +273,7 @@ const Main = ({ toplevel, timeZoneName, Frame }: Props) => {
aria-label="selectors" aria-label="selectors"
onClick={toggleShowSelectors} onClick={toggleShowSelectors}
color="inherit" color="inherit"
sx={showSelectors ? { border: `1px solid #eee` } : {}}
size="small" size="small"
> >
<FilterList /> <FilterList />
@ -287,12 +289,12 @@ const Main = ({ toplevel, timeZoneName, Frame }: Props) => {
> >
<Box <Box
sx={{ sx={{
display: showSelectors ? "block" : "none", display: showSelectors ? "flex" : "none",
width: "max-content", maxWidth: { xs: "100%", sm: "300px", md: "300px" },
"& .MuiCard-root": {
marginRight: theme.spacing(2), gap: 1,
marginBottom: theme.spacing(2), mb: 2,
}, flexDirection: "column",
}} }}
> >
<StreamMultiSelector <StreamMultiSelector

View File

@ -5,22 +5,25 @@
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Select, { SelectChangeEvent } from "@mui/material/Select"; import Select, { SelectChangeEvent } from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import React, { useReducer } from "react"; import React, { useCallback, useEffect, useReducer } from "react";
import { Camera } from "../types"; import { Camera } from "../types";
import { useTheme } from "@mui/material/styles";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { IconButton, Tooltip } from "@mui/material";
import { Fullscreen } from "@mui/icons-material";
export interface Layout { export interface Layout {
className: string; className: string;
cameras: number; cameras: number;
name: string
} }
// These class names must match useStyles rules (below). // These class names must match useStyles rules (below).
const LAYOUTS: Layout[] = [ const LAYOUTS: Layout[] = [
{ className: "solo", cameras: 1 }, { className: "solo", cameras: 1, name: "1" },
{ className: "main-plus-five", cameras: 6 }, { className: "dual", cameras: 2, name: "2" },
{ className: "two-by-two", cameras: 4 }, { className: "main-plus-five", cameras: 6, name: "Main + 5" },
{ className: "three-by-three", cameras: 9 }, { className: "two-by-two", cameras: 4, name: "2x2" },
{ className: "three-by-three", cameras: 9, name: "3x3" }
]; ];
const MAX_CAMERAS = 9; const MAX_CAMERAS = 9;
@ -63,7 +66,7 @@ export const MultiviewChooser = (props: MultiviewChooserProps) => {
> >
{LAYOUTS.map((e, i) => ( {LAYOUTS.map((e, i) => (
<MenuItem key={e.className} value={i}> <MenuItem key={e.className} value={i}>
{e.className} {e.name}
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
@ -106,18 +109,62 @@ function selectedReducer(old: SelectedCameras, op: SelectOp): SelectedCameras {
*/ */
const Multiview = (props: MultiviewProps) => { const Multiview = (props: MultiviewProps) => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const theme = useTheme();
const [selected, updateSelected] = useReducer( const [selected, updateSelected] = useReducer(
selectedReducer, selectedReducer,
searchParams.has("cams") searchParams.has("cams")
? JSON.parse(searchParams.get("cams") || "") ? JSON.parse(searchParams.get("cams") || "") :
localStorage.getItem("camsSelected") !== null ?
JSON.parse(localStorage.getItem("camsSelected") || "")
: Array(MAX_CAMERAS).fill(null) : 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 outerRef = React.useRef<HTMLDivElement>(null);
const layout = LAYOUTS[props.layoutIndex]; const layout = LAYOUTS[props.layoutIndex];
/**
* Toggle full screen.
*/
const handleFullScreen = useCallback(() => {
if (outerRef.current) {
const elem = outerRef.current;
//@ts-expect-error another browser (if not bruh)
if (document.fullscreenElement || document.webkitFullscreenElement || document.msFullscreenElement) {
if (document.exitFullscreen) {
document.exitFullscreen();
//@ts-expect-error another browser (if not bruh)
} else if (document.webkitExitFullscreen) {
//@ts-expect-error another browser (if not bruh)
document.webkitExitFullscreen();
//@ts-expect-error another browser (if not bruh)
} else if (document.msExitFullscreen) {
//@ts-expect-error another browser (if not bruh)
document.msExitFullscreen();
}
} else {
if (elem.requestFullscreen) {
elem.requestFullscreen();
//@ts-expect-error another browser (if not bruh)
} else if (elem.webkitRequestFullscreen) {
//@ts-expect-error another browser (if not bruh)
elem.webkitRequestFullscreen();
//@ts-expect-error another browser (if not bruh)
} else if (elem.msRequestFullscreen) {
//@ts-expect-error another browser (if not bruh)
elem.msRequestFullscreen();
}
}
}
}, [outerRef]);
const monoviews = selected.slice(0, layout.cameras).map((e, i) => { const monoviews = selected.slice(0, layout.cameras).map((e, i) => {
// When a camera is selected, use the camera's index as the key. // When a camera is selected, use the camera's index as the key.
// This allows swapping cameras' positions without tearing down their // This allows swapping cameras' positions without tearing down their
@ -153,7 +200,6 @@ const Multiview = (props: MultiviewProps) => {
sx={{ sx={{
flex: "1 0 0", flex: "1 0 0",
color: "white", color: "white",
marginTop: theme.spacing(2),
overflow: "hidden", overflow: "hidden",
// TODO: this mid-level div can probably be removed. // TODO: this mid-level div can probably be removed.
@ -165,6 +211,15 @@ const Multiview = (props: MultiviewProps) => {
}, },
}} }}
> >
<Tooltip title="Toggle full screen">
<IconButton size="small" sx={{
position: 'fixed', background: 'rgba(255,255,255,0.3) !important', transition: '0.2s', opacity: '0.1', bottom: 10, right: 10, zIndex: 9, color: "#fff", ":hover": {
opacity: '1'
}
}} onClick={handleFullScreen}>
<Fullscreen />
</IconButton>
</Tooltip>
<div className="mid"> <div className="mid">
<Box <Box
className={layout.className} className={layout.className}
@ -184,6 +239,18 @@ const Multiview = (props: MultiviewProps) => {
gridTemplateColumns: "100%", gridTemplateColumns: "100%",
gridTemplateRows: "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": { "&.two-by-two": {
gridTemplateColumns: "repeat(2, calc(100% / 2))", gridTemplateColumns: "repeat(2, calc(100% / 2))",
gridTemplateRows: "repeat(2, calc(100% / 2))", gridTemplateRows: "repeat(2, calc(100% / 2))",
@ -229,8 +296,9 @@ const Monoview = (props: MonoviewProps) => {
displayEmpty displayEmpty
size="small" size="small"
sx={{ sx={{
transform: "scale(0.8)",
// Restyle to fit over the video (or black). // Restyle to fit over the video (or black).
backgroundColor: "rgba(255, 255, 255, 0.5)", backgroundColor: "rgba(255, 255, 255, 0.3)",
"& svg": { "& svg": {
color: "inherit", color: "inherit",
}, },

View File

@ -9,7 +9,7 @@ import LiveCamera from "./LiveCamera";
import Multiview, { MultiviewChooser } from "./Multiview"; import Multiview, { MultiviewChooser } from "./Multiview";
import { FrameProps } from "../App"; import { FrameProps } from "../App";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { useState } from "react"; import { useEffect, useState } from "react";
export interface LiveProps { export interface LiveProps {
cameras: Camera[]; cameras: Camera[];
@ -19,10 +19,17 @@ export interface LiveProps {
const Live = ({ cameras, Frame }: LiveProps) => { const Live = ({ cameras, Frame }: LiveProps) => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [multiviewLayoutIndex, setMultiviewLayoutIndex] = useState( 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) { if ("MediaSource" in window === false) {
return ( return (
<Frame> <Frame>

View File

@ -2,18 +2,17 @@
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
import Avatar from "@mui/material/Avatar";
import Dialog from "@mui/material/Dialog"; import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions"; import DialogActions from "@mui/material/DialogActions";
import DialogTitle from "@mui/material/DialogTitle"; import DialogTitle from "@mui/material/DialogTitle";
import FormHelperText from "@mui/material/FormHelperText"; import FormHelperText from "@mui/material/FormHelperText";
import { useTheme } from "@mui/material/styles";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import LoadingButton from "@mui/lab/LoadingButton"; import LoadingButton from "@mui/lab/LoadingButton";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import * as api from "./api"; import * as api from "./api";
import { useSnackbars } from "./snackbars"; import { useSnackbars } from "./snackbars";
import { Box, DialogContent, InputAdornment, Typography } from "@mui/material";
import { AccountCircle, Lock } from "@mui/icons-material";
interface Props { interface Props {
open: boolean; open: boolean;
@ -47,7 +46,6 @@ interface Props {
* <tt>--allow-unauthenticated-permissions</tt>), the caller may ignore this. * <tt>--allow-unauthenticated-permissions</tt>), the caller may ignore this.
*/ */
const Login = ({ open, onSuccess, handleClose }: Props) => { const Login = ({ open, onSuccess, handleClose }: Props) => {
const theme = useTheme();
const snackbars = useSnackbars(); const snackbars = useSnackbars();
// This is a simple uncontrolled form; use refs. // This is a simple uncontrolled form; use refs.
@ -111,38 +109,52 @@ const Login = ({ open, onSuccess, handleClose }: Props) => {
fullWidth={true} fullWidth={true}
> >
<DialogTitle id="login-title"> <DialogTitle id="login-title">
<Avatar sx={{ backgroundColor: theme.palette.secondary.main }}> Welcome back!
<LockOutlinedIcon /> <Typography variant="body2">Please login to Moonfire NVR.</Typography>
</Avatar>
Log in
</DialogTitle> </DialogTitle>
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField <TextField
id="username" id="username"
label="Username" label="Username"
variant="filled" variant="outlined"
required required
autoComplete="username" autoComplete="username"
fullWidth fullWidth
error={error != null} error={error != null}
inputRef={usernameRef} inputRef={usernameRef}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<AccountCircle />
</InputAdornment>
),
}}
/> />
<TextField <TextField
id="password" id="password"
label="Password" label="Password"
variant="filled" variant="outlined"
type="password" type="password"
required required
autoComplete="current-password" autoComplete="current-password"
fullWidth fullWidth
error={error != null} error={error != null}
inputRef={passwordRef} inputRef={passwordRef}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Lock />
</InputAdornment>
),
}}
/> />
{/* reserve space for an error; show when there's something to see */} {/* reserve space for an error; show when there's something to see */}
<FormHelperText>{error == null ? " " : error}</FormHelperText> <FormHelperText>{error == null ? " " : error}</FormHelperText>
</Box>
</DialogContent>
<DialogActions> <DialogActions>
<LoadingButton <LoadingButton
type="submit" type="submit"

View File

@ -0,0 +1,85 @@
import { AppBar, Drawer, List, ListItemButton, ListItemIcon, ListItemText } from "@mui/material";
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,47 @@
import { useColorScheme } from "@mui/material";
import React, { createContext } from "react";
interface ThemeProps {
changeTheme: () => void,
currentTheme?: 'dark' | 'light',
getTheme: 0 | 1 | 2,
systemColor: 'dark' | 'light'
}
export const ThemeContext = createContext<ThemeProps>({
currentTheme: window.matchMedia("(prefers-color-scheme: dark)").matches ? 'dark' : 'light',
changeTheme: () => { },
getTheme: 0,
systemColor: window.matchMedia("(prefers-color-scheme: dark)").matches ? 'dark' : 'light'
});
const ThemeMode = ({ children }: { children: JSX.Element }): JSX.Element => {
const { mode, setMode } = useColorScheme();
const [detectedSystemColorScheme, setDetectedSystemColorScheme] = React.useState<'dark' | 'light'>(
window.matchMedia("(prefers-color-scheme: dark)").matches ? 'dark' : 'light'
);
React.useEffect(() => {
window.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", (e) => {
setDetectedSystemColorScheme(e.matches ? 'dark' : 'light');
});
}, []);
const changeTheme = React.useCallback(() => {
setMode(mode === 'dark' ? 'light' : mode === 'light' ? 'system' : 'dark')
}, [mode]);
const currentTheme = mode === 'system' ? detectedSystemColorScheme : mode;
const getTheme = mode === 'dark' ? 2 : mode === 'light' ? 1 : 0;
return (
<ThemeContext.Provider value={{ changeTheme, currentTheme, getTheme, systemColor: detectedSystemColorScheme }}>
{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 * 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, html,
body, body,
#root { #root {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
a {
color: inherit;
}
[data-mui-color-scheme="dark"] {
color-scheme: dark;
}

View File

@ -3,7 +3,7 @@
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
import CssBaseline from "@mui/material/CssBaseline"; 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 StyledEngineProvider from "@mui/material/StyledEngineProvider";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import "@fontsource/roboto"; import "@fontsource/roboto";
@ -15,25 +15,31 @@ import { SnackbarProvider } from "./snackbars";
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
import "./index.css"; import "./index.css";
import { HashRouter } from "react-router-dom"; import { HashRouter } from "react-router-dom";
import ThemeMode from "./components/ThemeMode";
const theme = createTheme({ const themeExtended = experimental_extendTheme({
colorSchemes: {
dark: {
palette: { palette: {
primary: { primary: {
main: "#000000", main: "#000000"
}, },
secondary: { secondary: {
main: "#e65100", main: "#e65100"
}
}
}, },
}, }
}); })
const container = document.getElementById("root"); const container = document.getElementById("root");
const root = createRoot(container!); const root = createRoot(container!);
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<StyledEngineProvider injectFirst> <StyledEngineProvider injectFirst>
{/* <ThemeProvider theme={theme}> */}
<Experimental_CssVarsProvider theme={themeExtended}>
<CssBaseline /> <CssBaseline />
<ThemeProvider theme={theme}> <ThemeMode>
<ErrorBoundary> <ErrorBoundary>
<LocalizationProvider dateAdapter={AdapterDateFns}> <LocalizationProvider dateAdapter={AdapterDateFns}>
<SnackbarProvider autoHideDuration={5000}> <SnackbarProvider autoHideDuration={5000}>
@ -43,7 +49,9 @@ root.render(
</SnackbarProvider> </SnackbarProvider>
</LocalizationProvider> </LocalizationProvider>
</ErrorBoundary> </ErrorBoundary>
</ThemeProvider> </ThemeMode>
</Experimental_CssVarsProvider>
{/* </ThemeProvider> */}
</StyledEngineProvider> </StyledEngineProvider>
</React.StrictMode> </React.StrictMode>
); );

View File

@ -5,12 +5,15 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc"; import react from "@vitejs/plugin-react-swc";
import viteCompression from "vite-plugin-compression"; import viteCompression from "vite-plugin-compression";
import viteLegacyPlugin from "@vitejs/plugin-legacy";
const target = process.env.PROXY_TARGET ?? "http://localhost:8080/"; const target = process.env.PROXY_TARGET ?? "http://localhost:8080/";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), viteCompression()], plugins: [react(), viteCompression(), viteLegacyPlugin({
targets: ['defaults', 'fully supports es6-module'],
})],
server: { server: {
proxy: { proxy: {
"/api": { "/api": {