Extra change for Moonfire WebUI
This commit is contained in:
parent
dbf6c2f476
commit
6e81b27d1a
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -19,25 +19,16 @@
|
|||
*/
|
||||
|
||||
import Container from "@mui/material/Container";
|
||||
import React, { useEffect, useReducer, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import * as api from "./api";
|
||||
import MoonfireMenu from "./AppMenu";
|
||||
import Login from "./Login";
|
||||
import { useSnackbars } from "./snackbars";
|
||||
import ListActivity from "./List";
|
||||
import AppBar from "@mui/material/AppBar";
|
||||
import { Routes, Route, Link, Navigate } from "react-router-dom";
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import LiveActivity from "./Live";
|
||||
import UsersActivity from "./Users";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import List from "@mui/material/List";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import ListIcon from "@mui/icons-material/List";
|
||||
import PeopleIcon from "@mui/icons-material/People";
|
||||
import Videocam from "@mui/icons-material/Videocam";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import ChangePassword from "./ChangePassword";
|
||||
import Header from "./components/Header";
|
||||
|
||||
export type LoginState =
|
||||
| "unknown"
|
||||
|
@ -52,7 +43,6 @@ export interface FrameProps {
|
|||
}
|
||||
|
||||
function App() {
|
||||
const [showMenu, toggleShowMenu] = useReducer((m: boolean) => !m, false);
|
||||
const [toplevel, setToplevel] = useState<api.ToplevelResponse | null>(null);
|
||||
const [timeZoneName, setTimeZoneName] = useState<string | null>(null);
|
||||
const [fetchSeq, setFetchSeq] = useState(0);
|
||||
|
@ -122,67 +112,14 @@ function App() {
|
|||
const Frame = ({ activityMenuPart, children }: FrameProps): JSX.Element => {
|
||||
return (
|
||||
<>
|
||||
<AppBar position="static">
|
||||
<MoonfireMenu
|
||||
loginState={loginState}
|
||||
requestLogin={() => {
|
||||
setLoginState("user-requested-login");
|
||||
}}
|
||||
logout={logout}
|
||||
changePassword={() => setChangePasswordOpen(true)}
|
||||
menuClick={toggleShowMenu}
|
||||
activityMenuPart={activityMenuPart}
|
||||
/>
|
||||
</AppBar>
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={showMenu}
|
||||
onClose={toggleShowMenu}
|
||||
ModalProps={{
|
||||
keepMounted: true,
|
||||
}}
|
||||
>
|
||||
<List>
|
||||
<ListItem
|
||||
button
|
||||
key="list"
|
||||
onClick={toggleShowMenu}
|
||||
component={Link}
|
||||
to="/"
|
||||
>
|
||||
<ListItemIcon>
|
||||
<ListIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="List view" />
|
||||
</ListItem>
|
||||
<ListItem
|
||||
button
|
||||
key="live"
|
||||
onClick={toggleShowMenu}
|
||||
component={Link}
|
||||
to="/live"
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Videocam />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Live view (experimental)" />
|
||||
</ListItem>
|
||||
{toplevel?.permissions.adminUsers && (
|
||||
<ListItem
|
||||
button
|
||||
key="users"
|
||||
onClick={toggleShowMenu}
|
||||
component={Link}
|
||||
to="/users"
|
||||
>
|
||||
<ListItemIcon>
|
||||
<PeopleIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Users" />
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</Drawer>
|
||||
<Header
|
||||
loginState={loginState}
|
||||
logout={logout}
|
||||
setChangePasswordOpen={setChangePasswordOpen}
|
||||
activityMenuPart={activityMenuPart}
|
||||
setLoginState={setLoginState}
|
||||
toplevel={toplevel}
|
||||
/>
|
||||
<Login
|
||||
onSuccess={onLoginSuccess}
|
||||
open={
|
||||
|
|
|
@ -14,6 +14,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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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);
|
|
@ -4,9 +4,23 @@
|
|||
* SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
|
||||
*/
|
||||
|
||||
:root {
|
||||
--mui-palette-AppBar-darkBg: #000 !important;
|
||||
--mui-palette-primary-main: #000 !important;
|
||||
--mui-palette-secondary-main: #e65100 !important;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
[data-mui-color-scheme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
|
@ -3,7 +3,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>
|
||||
);
|
||||
|
|
|
@ -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": {
|
||||
|
|
Loading…
Reference in New Issue