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

6048
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,9 @@ import MenuIcon from "@mui/icons-material/Menu";
import React from "react";
import { LoginState } from "./App";
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 {
loginState: LoginState;
@ -26,6 +29,7 @@ interface Props {
// https://material-ui.com/components/app-bar/
function MoonfireMenu(props: Props) {
const { getTheme, changeTheme } = useThemeMode();
const theme = useTheme();
const [accountMenuAnchor, setAccountMenuAnchor] =
React.useState<null | HTMLElement>(null);
@ -69,6 +73,15 @@ function MoonfireMenu(props: Props) {
{props.activityMenuPart}
</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" && (
<Button color="inherit" onClick={props.requestLogin}>
Log in

View File

@ -8,8 +8,8 @@ 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 { CardContent } from "@mui/material";
interface Props {
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.
*/
const DisplaySelector = (props: Props) => {
const theme = useTheme();
return (
<Card
sx={{
padding: theme.spacing(1),
display: "flex",
flexDirection: "column",
}}
>
<CardContent>
<FormControl fullWidth variant="outlined">
<InputLabel id="split90k-label" shrink>
Max video duration
@ -97,7 +96,8 @@ const DisplaySelector = (props: Props) => {
/>
}
label="Timestamp track"
/>
/>
</CardContent>
</Card>
);
};

View File

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

View File

@ -60,7 +60,6 @@ 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 +67,7 @@ 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 { CardContent } from "@mui/material";
interface Props {
selectedStreams: Set<Stream>;
@ -140,7 +140,7 @@ const SmallStaticDatePicker = (props: StaticDatePickerProps<Date>) => {
},
}}
>
<StaticDatePicker {...props} />
<StaticDatePicker {...props} sx={{ background: "transparent" }} />
</Box>
);
};
@ -326,7 +326,6 @@ const TimerangeSelector = ({
timeZoneName,
setRange90k,
}: Props) => {
const theme = useTheme();
const [days, updateDays] = React.useReducer(daysStateReducer, {
allowed: null,
rangeMillis: null,
@ -371,8 +370,9 @@ const TimerangeSelector = ({
endDate = new Date(days.rangeMillis[1]);
}
return (
<Card sx={{ padding: theme.spacing(1) }}>
<div>
<Card>
<CardContent>
<Box>
<FormLabel component="legend">From</FormLabel>
<SmallStaticDatePicker
displayStaticWrapperAs="desktop"
@ -398,9 +398,9 @@ 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,7 +447,8 @@ const TimerangeSelector = ({
}}
disabled={days.allowed === null}
/>
</div>
</Box>
</CardContent>
</Card>
);
};

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

@ -5,22 +5,25 @@
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, Tooltip } from "@mui/material";
import { Fullscreen } from "@mui/icons-material";
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 +66,7 @@ export const MultiviewChooser = (props: MultiviewChooserProps) => {
>
{LAYOUTS.map((e, i) => (
<MenuItem key={e.className} value={i}>
{e.className}
{e.name}
</MenuItem>
))}
</Select>
@ -106,18 +109,62 @@ 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") || "")
? 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;
//@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) => {
// When a camera is selected, use the camera's index as the key.
// This allows swapping cameras' positions without tearing down their
@ -153,7 +200,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 +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">
<Box
className={layout.className}
@ -184,6 +239,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 +296,9 @@ 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(255, 255, 255, 0.3)",
"& svg": {
color: "inherit",
},

View File

@ -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[];
@ -19,10 +19,17 @@ export interface LiveProps {
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>

View File

@ -2,18 +2,17 @@
// 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, DialogContent, InputAdornment, Typography } from "@mui/material";
import { AccountCircle, Lock } from "@mui/icons-material";
interface Props {
open: boolean;
@ -47,7 +46,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 +109,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,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
*/
: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,7 @@
// 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 +15,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",
const themeExtended = experimental_extendTheme({
colorSchemes: {
dark: {
palette: {
primary: {
main: "#000000"
},
secondary: {
main: "#e65100"
}
}
},
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 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

@ -5,12 +5,15 @@
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": {