Merge remote-tracking branch 'origin/v3.4' into dev

This commit is contained in:
proddy
2022-01-23 17:56:52 +01:00
parent 02e2b51814
commit 77e1898512
538 changed files with 32282 additions and 38655 deletions

View File

@@ -1,59 +0,0 @@
import React, { FC } from 'react';
import { makeStyles } from '@material-ui/styles';
import { Paper, Typography, Box, CssBaseline } from '@material-ui/core';
import WarningIcon from '@material-ui/icons/Warning';
const styles = makeStyles({
siteErrorPage: {
display: 'flex',
height: '100vh',
justifyContent: 'center',
flexDirection: 'column'
},
siteErrorPagePanel: {
textAlign: 'center',
padding: '280px 0 40px 0',
backgroundImage: 'url("/app/icon.png")',
backgroundRepeat: 'no-repeat',
backgroundPosition: '50% 40px',
backgroundSize: '200px auto',
width: '100%'
}
});
interface ApplicationErrorProps {
error?: string;
}
const ApplicationError: FC<ApplicationErrorProps> = ({ error }) => {
const classes = styles();
return (
<div className={classes.siteErrorPage}>
<CssBaseline />
<Paper className={classes.siteErrorPagePanel} elevation={10}>
<Box
display="flex"
flexDirection="row"
justifyContent="center"
alignItems="center"
mb={2}
>
<WarningIcon fontSize="large" color="error" />
<Box ml={2}>
<Typography variant="h4">Application error</Typography>
</Box>
</Box>
<Typography variant="subtitle1" gutterBottom>
Failed to configure the application, please refresh to try again.
</Typography>
{error && (
<Typography variant="subtitle2" gutterBottom>
Error: {error}
</Typography>
)}
</Paper>
</div>
);
};
export default ApplicationError;

View File

@@ -0,0 +1,26 @@
import { FC } from 'react';
import { Box, BoxProps } from '@mui/material';
const ButtonRow: FC<BoxProps> = ({ children, ...rest }) => {
return (
<Box
sx={{
'& button, & a, & .MuiCard-root': {
mt: 2,
mx: 0.6,
'&:last-child': {
mr: 0
},
'&:first-of-type': {
ml: 0
}
}
}}
{...rest}
>
{children}
</Box>
);
};
export default ButtonRow;

View File

@@ -1,11 +0,0 @@
import { Button, styled } from '@material-ui/core';
const ErrorButton = styled(Button)(({ theme }) => ({
color: theme.palette.getContrastText(theme.palette.error.main),
backgroundColor: theme.palette.error.main,
'&:hover': {
backgroundColor: theme.palette.error.dark
}
}));
export default ErrorButton;

View File

@@ -1,7 +0,0 @@
import { styled, Box } from '@material-ui/core';
const FormActions = styled(Box)(({ theme }) => ({
marginTop: theme.spacing(1)
}));
export default FormActions;

View File

@@ -1,13 +0,0 @@
import { Button, styled } from '@material-ui/core';
const FormButton = styled(Button)(({ theme }) => ({
margin: theme.spacing(0, 1),
'&:last-child': {
marginRight: 0
},
'&:first-child': {
marginLeft: 0
}
}));
export default FormButton;

View File

@@ -1,56 +0,0 @@
import { FC } from 'react';
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
import { Button, LinearProgress, Typography } from '@material-ui/core';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
loadingSettings: {
margin: theme.spacing(0.5)
},
loadingSettingsDetails: {
margin: theme.spacing(4),
textAlign: 'center'
},
button: {
marginRight: theme.spacing(2),
marginTop: theme.spacing(2)
}
})
);
interface FormLoaderProps {
errorMessage?: string;
loadData: () => void;
}
const FormLoader: FC<FormLoaderProps> = ({ errorMessage, loadData }) => {
const classes = useStyles();
if (errorMessage) {
return (
<div className={classes.loadingSettings}>
<Typography variant="h6" className={classes.loadingSettingsDetails}>
{errorMessage}
</Typography>
<Button
variant="contained"
color="secondary"
className={classes.button}
onClick={loadData}
>
Retry
</Button>
</div>
);
}
return (
<div className={classes.loadingSettings}>
<LinearProgress className={classes.loadingSettingsDetails} />
<Typography variant="h6" className={classes.loadingSettingsDetails}>
Loading&hellip;
</Typography>
</div>
);
};
export default FormLoader;

View File

@@ -1,32 +0,0 @@
import React from 'react';
import CircularProgress from '@material-ui/core/CircularProgress';
import { Typography, Theme } from '@material-ui/core';
import { makeStyles, createStyles } from '@material-ui/styles';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
fullScreenLoading: {
padding: theme.spacing(2),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
flexDirection: 'column'
},
progress: {
margin: theme.spacing(4)
}
})
);
const FullScreenLoading = () => {
const classes = useStyles();
return (
<div className={classes.fullScreenLoading}>
<CircularProgress className={classes.progress} size={100} />
<Typography variant="h4">Loading&hellip;</Typography>
</div>
);
};
export default FullScreenLoading;

View File

@@ -1,19 +0,0 @@
import { Avatar, makeStyles } from '@material-ui/core';
import { FC } from 'react';
interface HighlightAvatarProps {
color: string;
}
const useStyles = makeStyles({
root: (props: HighlightAvatarProps) => ({
backgroundColor: props.color
})
});
const HighlightAvatar: FC<HighlightAvatarProps> = (props) => {
const classes = useStyles(props);
return <Avatar className={classes.root}>{props.children}</Avatar>;
};
export default HighlightAvatar;

View File

@@ -1,390 +0,0 @@
import React, { RefObject, Fragment } from 'react';
import { Link, withRouter, RouteComponentProps } from 'react-router-dom';
import {
Drawer,
AppBar,
Toolbar,
Avatar,
Divider,
Button,
Box,
IconButton
} from '@material-ui/core';
import {
ClickAwayListener,
Popper,
Hidden,
Typography
} from '@material-ui/core';
import {
List,
ListItem,
ListItemIcon,
ListItemText,
ListItemAvatar
} from '@material-ui/core';
import { Card, CardContent, CardActions } from '@material-ui/core';
import {
withStyles,
createStyles,
Theme,
WithTheme,
WithStyles,
withTheme
} from '@material-ui/core/styles';
import SettingsEthernetIcon from '@material-ui/icons/SettingsEthernet';
import SettingsIcon from '@material-ui/icons/Settings';
import AccessTimeIcon from '@material-ui/icons/AccessTime';
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna';
import DeviceHubIcon from '@material-ui/icons/DeviceHub';
import LockIcon from '@material-ui/icons/Lock';
import MenuIcon from '@material-ui/icons/Menu';
import ProjectMenu from '../project/ProjectMenu';
import { PROJECT_NAME } from '../api';
import {
withAuthenticatedContext,
AuthenticatedContextProps
} from '../authentication';
import { withFeatures, WithFeaturesProps } from '../features/FeaturesContext';
const drawerWidth = 290;
const styles = (theme: Theme) =>
createStyles({
root: {
display: 'flex'
},
drawer: {
[theme.breakpoints.up('md')]: {
width: drawerWidth,
flexShrink: 0
}
},
title: {
flexGrow: 1
},
appBar: {
marginLeft: drawerWidth,
[theme.breakpoints.up('md')]: {
width: `calc(100% - ${drawerWidth}px)`
}
},
toolbarImage: {
[theme.breakpoints.up('xs')]: {
height: 24,
marginRight: theme.spacing(2)
},
[theme.breakpoints.up('sm')]: {
height: 36,
marginRight: theme.spacing(3)
}
},
menuButton: {
marginRight: theme.spacing(2),
[theme.breakpoints.up('md')]: {
display: 'none'
}
},
toolbar: theme.mixins.toolbar,
drawerPaper: {
width: drawerWidth
},
content: {
flexGrow: 1
},
authMenu: {
zIndex: theme.zIndex.tooltip,
maxWidth: 400
},
authMenuActions: {
padding: theme.spacing(2),
'& > * + *': {
marginLeft: theme.spacing(2)
}
}
});
interface MenuAppBarState {
mobileOpen: boolean;
authMenuOpen: boolean;
}
interface MenuAppBarProps
extends WithFeaturesProps,
AuthenticatedContextProps,
WithTheme,
WithStyles<typeof styles>,
RouteComponentProps {
sectionTitle: string;
}
class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
constructor(props: MenuAppBarProps) {
super(props);
this.state = {
mobileOpen: false,
authMenuOpen: false
};
}
anchorRef: RefObject<HTMLButtonElement> = React.createRef();
handleToggle = () => {
this.setState({ authMenuOpen: !this.state.authMenuOpen });
};
handleClose = (event: React.MouseEvent<Document>) => {
if (
this.anchorRef.current &&
this.anchorRef.current.contains(event.currentTarget)
) {
return;
}
this.setState({ authMenuOpen: false });
};
handleDrawerToggle = () => {
this.setState({ mobileOpen: !this.state.mobileOpen });
};
render() {
const {
classes,
theme,
children,
sectionTitle,
authenticatedContext,
features
} = this.props;
const { mobileOpen, authMenuOpen } = this.state;
const path = this.props.match.url;
const drawer = (
<div>
<Toolbar>
<Box display="flex">
<img
src="/app/icon.png"
className={classes.toolbarImage}
alt={PROJECT_NAME}
/>
</Box>
<Typography variant="h6" color="textPrimary">
{PROJECT_NAME}
</Typography>
<Divider absolute />
</Toolbar>
{features.project && (
<Fragment>
<ProjectMenu />
<Divider />
</Fragment>
)}
<List>
<ListItem
to="/network/"
selected={path.startsWith('/network/')}
button
component={Link}
>
<ListItemIcon>
<SettingsEthernetIcon />
</ListItemIcon>
<ListItemText primary="Network Connection" />
</ListItem>
<ListItem
to="/ap/"
selected={path.startsWith('/ap/')}
button
component={Link}
>
<ListItemIcon>
<SettingsInputAntennaIcon />
</ListItemIcon>
<ListItemText primary="Access Point" />
</ListItem>
{features.ntp && (
<ListItem
to="/ntp/"
selected={path.startsWith('/ntp/')}
button
component={Link}
>
<ListItemIcon>
<AccessTimeIcon />
</ListItemIcon>
<ListItemText primary="Network Time" />
</ListItem>
)}
{features.mqtt && (
<ListItem
to="/mqtt/"
selected={path.startsWith('/mqtt/')}
button
component={Link}
>
<ListItemIcon>
<DeviceHubIcon />
</ListItemIcon>
<ListItemText primary="MQTT" />
</ListItem>
)}
{features.security && (
<ListItem
to="/security/"
selected={path.startsWith('/security/')}
button
component={Link}
disabled={!authenticatedContext.me.admin}
>
<ListItemIcon>
<LockIcon />
</ListItemIcon>
<ListItemText primary="Security" />
</ListItem>
)}
<ListItem
to="/system/"
selected={path.startsWith('/system/')}
button
component={Link}
>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary="System" />
</ListItem>
</List>
</div>
);
const userMenu = (
<div>
<IconButton
ref={this.anchorRef}
aria-owns={authMenuOpen ? 'menu-list-grow' : undefined}
aria-haspopup="true"
onClick={this.handleToggle}
color="inherit"
>
<AccountCircleIcon />
</IconButton>
<Popper
open={authMenuOpen}
anchorEl={this.anchorRef.current}
transition
className={classes.authMenu}
>
<ClickAwayListener onClickAway={this.handleClose}>
<Card id="menu-list-grow">
<CardContent>
<List disablePadding>
<ListItem disableGutters>
<ListItemAvatar>
<Avatar>
<AccountCircleIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
'Signed in as: ' + authenticatedContext.me.username
}
secondary={
authenticatedContext.me.admin ? 'Admin User' : undefined
}
/>
</ListItem>
</List>
</CardContent>
<Divider />
<CardActions className={classes.authMenuActions}>
<Button
variant="contained"
fullWidth
color="primary"
onClick={authenticatedContext.signOut}
>
Sign Out
</Button>
</CardActions>
</Card>
</ClickAwayListener>
</Popper>
</div>
);
return (
<div className={classes.root}>
<AppBar position="fixed" className={classes.appBar} elevation={0}>
<Toolbar>
<IconButton
color="inherit"
aria-label="Open drawer"
edge="start"
onClick={this.handleDrawerToggle}
className={classes.menuButton}
>
<MenuIcon />
</IconButton>
<Typography
variant="h6"
color="inherit"
noWrap
className={classes.title}
>
{sectionTitle}
</Typography>
{features.security && userMenu}
</Toolbar>
</AppBar>
<nav className={classes.drawer}>
<Hidden mdUp implementation="css">
<Drawer
variant="temporary"
anchor={theme.direction === 'rtl' ? 'right' : 'left'}
open={mobileOpen}
onClose={this.handleDrawerToggle}
classes={{
paper: classes.drawerPaper
}}
ModalProps={{
keepMounted: true
}}
>
{drawer}
</Drawer>
</Hidden>
<Hidden smDown implementation="css">
<Drawer
classes={{
paper: classes.drawerPaper
}}
variant="permanent"
open
>
{drawer}
</Drawer>
</Hidden>
</nav>
<main className={classes.content}>
<div className={classes.toolbar} />
{children}
</main>
</div>
);
}
}
export default withRouter(
withTheme(
withFeatures(withAuthenticatedContext(withStyles(styles)(MenuAppBar)))
)
);

View File

@@ -0,0 +1,48 @@
import { FC } from 'react';
import { Box, BoxProps, SvgIconProps, Theme, Typography, useTheme } from '@mui/material';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined';
import ErrorIcon from '@mui/icons-material/Error';
type MessageBoxLevel = 'warning' | 'success' | 'info' | 'error';
export interface MessageBoxProps extends BoxProps {
level: MessageBoxLevel;
message: string;
}
const LEVEL_ICONS: { [type in MessageBoxLevel]: React.ComponentType<SvgIconProps> } = {
success: CheckCircleOutlineOutlinedIcon,
info: InfoOutlinedIcon,
warning: ReportProblemOutlinedIcon,
error: ErrorIcon
};
const LEVEL_BACKGROUNDS: { [type in MessageBoxLevel]: (theme: Theme) => string } = {
success: (theme: Theme) => theme.palette.success.dark,
info: (theme: Theme) => theme.palette.info.main,
warning: (theme: Theme) => theme.palette.warning.dark,
error: (theme: Theme) => theme.palette.error.dark
};
const MessageBox: FC<MessageBoxProps> = ({ level, message, sx, children, ...rest }) => {
const theme = useTheme();
const Icon = LEVEL_ICONS[level];
const backgroundColor = LEVEL_BACKGROUNDS[level](theme);
// const color = theme.palette.getContrastText(backgroundColor);
const color = 'white';
return (
<Box p={2} display="flex" alignItems="center" borderRadius={1} sx={{ backgroundColor, color, ...sx }} {...rest}>
<Icon />
<Typography sx={{ ml: 2, flexGrow: 1 }} variant="body1">
{message}
</Typography>
{children}
</Box>
);
};
export default MessageBox;

View File

@@ -1,64 +0,0 @@
import React from 'react';
import {
TextValidator,
ValidatorComponentProps
} from 'react-material-ui-form-validator';
import { withStyles, WithStyles, createStyles } from '@material-ui/core/styles';
import { InputAdornment, IconButton } from '@material-ui/core';
import { Visibility, VisibilityOff } from '@material-ui/icons';
const styles = createStyles({
input: {
'&::-ms-reveal': {
display: 'none'
}
}
});
type PasswordValidatorProps = WithStyles<typeof styles> &
Exclude<ValidatorComponentProps, 'type' | 'InputProps'>;
interface PasswordValidatorState {
showPassword: boolean;
}
class PasswordValidator extends React.Component<
PasswordValidatorProps,
PasswordValidatorState
> {
state = {
showPassword: false
};
toggleShowPassword = () => {
this.setState({
showPassword: !this.state.showPassword
});
};
render() {
const { classes, ...rest } = this.props;
return (
<TextValidator
{...rest}
type={this.state.showPassword ? 'text' : 'password'}
InputProps={{
classes,
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="Toggle password visibility"
onClick={this.toggleShowPassword}
>
{this.state.showPassword ? <Visibility /> : <VisibilityOff />}
</IconButton>
</InputAdornment>
)
}}
/>
);
}
}
export default withStyles(styles)(PasswordValidator);

View File

@@ -1,141 +0,0 @@
import React from 'react';
import { withSnackbar, WithSnackbarProps } from 'notistack';
import { redirectingAuthorizedFetch } from '../authentication';
export interface RestControllerProps<D> extends WithSnackbarProps {
handleValueChange: (
name: keyof D
) => (event: React.ChangeEvent<HTMLInputElement>) => void;
setData: (data: D, callback?: () => void) => void;
saveData: () => void;
loadData: () => void;
data?: D;
loading: boolean;
errorMessage?: string;
}
export const extractEventValue = (
event: React.ChangeEvent<HTMLInputElement>
) => {
switch (event.target.type) {
case 'number':
return event.target.valueAsNumber;
case 'checkbox':
return event.target.checked;
default:
return event.target.value;
}
};
interface RestControllerState<D> {
data?: D;
loading: boolean;
errorMessage?: string;
}
export function restController<D, P extends RestControllerProps<D>>(
endpointUrl: string,
RestController: React.ComponentType<P & RestControllerProps<D>>
) {
return withSnackbar(
class extends React.Component<
Omit<P, keyof RestControllerProps<D>> & WithSnackbarProps,
RestControllerState<D>
> {
state: RestControllerState<D> = {
data: undefined,
loading: false,
errorMessage: undefined
};
setData = (data: D, callback?: () => void) => {
this.setState(
{
data,
loading: false,
errorMessage: undefined
},
callback
);
};
loadData = () => {
this.setState({
data: undefined,
loading: true,
errorMessage: undefined
});
redirectingAuthorizedFetch(endpointUrl)
.then((response) => {
if (response.status === 200) {
return response.json();
}
throw Error('Invalid status code: ' + response.status);
})
.then((json) => {
this.setState({ data: json, loading: false });
})
.catch((error) => {
const errorMessage = error.message || 'Unknown error';
this.props.enqueueSnackbar('Problem fetching: ' + errorMessage, {
variant: 'error'
});
this.setState({ data: undefined, loading: false, errorMessage });
});
};
saveData = () => {
this.setState({ loading: true });
redirectingAuthorizedFetch(endpointUrl, {
method: 'POST',
body: JSON.stringify(this.state.data),
headers: {
'Content-Type': 'application/json'
}
})
.then((response) => {
if (response.status === 200) {
return response.json();
}
throw Error('Invalid status code: ' + response.status);
})
.then((json) => {
this.props.enqueueSnackbar('Update successful.', {
variant: 'success'
});
this.setState({ data: json, loading: false });
})
.catch((error) => {
const errorMessage = error.message || 'Unknown error';
this.props.enqueueSnackbar('Problem updating: ' + errorMessage, {
variant: 'error'
});
this.setState({ data: undefined, loading: false, errorMessage });
});
};
handleValueChange = (name: keyof D) => (
event: React.ChangeEvent<HTMLInputElement>
) => {
const data = { ...this.state.data!, [name]: extractEventValue(event) };
this.setState({ data });
};
render() {
return (
<RestController
{...this.state}
{...(this.props as P)}
handleValueChange={this.handleValueChange}
setData={this.setData}
saveData={this.saveData}
loadData={this.loadData}
/>
);
}
}
);
}

View File

@@ -1,64 +0,0 @@
import React from 'react';
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
import { Button, LinearProgress, Typography } from '@material-ui/core';
import { RestControllerProps } from '.';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
loadingSettings: {
margin: theme.spacing(0.5)
},
loadingSettingsDetails: {
margin: theme.spacing(4),
textAlign: 'center'
},
button: {
marginRight: theme.spacing(2),
marginTop: theme.spacing(2)
}
})
);
export type RestFormProps<D> = Omit<
RestControllerProps<D>,
'loading' | 'errorMessage'
> & { data: D };
interface RestFormLoaderProps<D> extends RestControllerProps<D> {
render: (props: RestFormProps<D>) => JSX.Element;
}
export default function RestFormLoader<D>(props: RestFormLoaderProps<D>) {
const { loading, errorMessage, loadData, render, data, ...rest } = props;
const classes = useStyles();
if (loading || !data) {
return (
<div className={classes.loadingSettings}>
<LinearProgress className={classes.loadingSettingsDetails} />
<Typography variant="h6" className={classes.loadingSettingsDetails}>
Loading&hellip;
</Typography>
</div>
);
}
if (errorMessage) {
return (
<div className={classes.loadingSettings}>
<Typography variant="h6" className={classes.loadingSettingsDetails}>
{errorMessage}
</Typography>
<Button
variant="contained"
color="secondary"
className={classes.button}
onClick={loadData}
>
Retry
</Button>
</div>
);
}
return render({ ...rest, loadData, data });
}

View File

@@ -1,16 +1,6 @@
import React from 'react';
import { FC } from 'react';
import { Typography, Paper } from '@material-ui/core';
import { createStyles, Theme, makeStyles } from '@material-ui/core/styles';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
content: {
padding: theme.spacing(2),
margin: theme.spacing(3)
}
})
);
import { Paper, Divider } from '@mui/material';
interface SectionContentProps {
title: string;
@@ -18,14 +8,11 @@ interface SectionContentProps {
id?: string;
}
const SectionContent: React.FC<SectionContentProps> = (props) => {
const { children, title, titleGutter, id } = props;
const classes = useStyles();
const SectionContent: FC<SectionContentProps> = (props) => {
const { children, title, id } = props;
return (
<Paper id={id} className={classes.content}>
<Typography variant="h6" gutterBottom={titleGutter}>
{title}
</Typography>
<Paper id={id} sx={{ p: 2, m: 2 }}>
<Divider sx={{ pb: 2, borderColor: 'primary.main', fontSize: 20, color: 'primary.main' }}>{title}</Divider>
{children}
</Paper>
);

View File

@@ -1,128 +0,0 @@
import React, { FC, Fragment } from 'react';
import { useDropzone, DropzoneState } from 'react-dropzone';
import { makeStyles, createStyles } from '@material-ui/styles';
import CloudUploadIcon from '@material-ui/icons/CloudUpload';
import CancelIcon from '@material-ui/icons/Cancel';
import {
Theme,
Box,
Typography,
LinearProgress,
Button
} from '@material-ui/core';
interface SingleUploadStyleProps extends DropzoneState {
uploading: boolean;
}
const progressPercentage = (progress: ProgressEvent) =>
Math.round((progress.loaded * 100) / progress.total);
const getBorderColor = (theme: Theme, props: SingleUploadStyleProps) => {
if (props.isDragAccept) {
return theme.palette.success.main;
}
if (props.isDragReject) {
return theme.palette.error.main;
}
if (props.isDragActive) {
return theme.palette.info.main;
}
return theme.palette.grey[700];
};
const useStyles = makeStyles((theme: Theme) =>
createStyles({
dropzone: {
padding: theme.spacing(8, 2),
borderWidth: 2,
borderRadius: 2,
borderStyle: 'dashed',
color: theme.palette.grey[700],
transition: 'border .24s ease-in-out',
cursor: (props: SingleUploadStyleProps) =>
props.uploading ? 'default' : 'pointer',
width: '100%',
borderColor: (props: SingleUploadStyleProps) =>
getBorderColor(theme, props)
}
})
);
export interface SingleUploadProps {
onDrop: (acceptedFiles: File[]) => void;
onCancel: () => void;
accept?: string | string[];
uploading: boolean;
progress?: ProgressEvent;
}
const SingleUpload: FC<SingleUploadProps> = ({
onDrop,
onCancel,
accept,
uploading,
progress
}) => {
const dropzoneState = useDropzone({
onDrop,
accept,
disabled: uploading,
multiple: false
});
const { getRootProps, getInputProps } = dropzoneState;
const classes = useStyles({ ...dropzoneState, uploading });
const renderProgressText = () => {
if (uploading) {
if (progress?.lengthComputable) {
return `Uploading: ${progressPercentage(progress)}%`;
}
return 'Uploading\u2026';
}
return 'Drop file or click here';
};
const renderProgress = (progress?: ProgressEvent) => (
<LinearProgress
variant={
!progress || progress.lengthComputable ? 'determinate' : 'indeterminate'
}
value={
!progress
? 0
: progress.lengthComputable
? progressPercentage(progress)
: 0
}
/>
);
return (
<div {...getRootProps({ className: classes.dropzone })}>
<input {...getInputProps()} />
<Box flexDirection="column" display="flex" alignItems="center">
<CloudUploadIcon fontSize="large" />
<Typography variant="h6">{renderProgressText()}</Typography>
{uploading && (
<Fragment>
<Box width="100%" p={2}>
{renderProgress(progress)}
</Box>
<Button
startIcon={<CancelIcon />}
variant="contained"
color="secondary"
onClick={onCancel}
>
Cancel
</Button>
</Fragment>
)}
</Box>
</div>
);
};
export default SingleUpload;

View File

@@ -1,158 +0,0 @@
import React from 'react';
import Sockette from 'sockette';
import throttle from 'lodash/throttle';
import { withSnackbar, WithSnackbarProps } from 'notistack';
import { addAccessTokenParameter } from '../authentication';
import { extractEventValue } from '.';
export interface WebSocketControllerProps<D> extends WithSnackbarProps {
handleValueChange: (
name: keyof D
) => (event: React.ChangeEvent<HTMLInputElement>) => void;
setData: (data: D, callback?: () => void) => void;
saveData: () => void;
saveDataAndClear(): () => void;
connected: boolean;
data?: D;
}
interface WebSocketControllerState<D> {
ws: Sockette;
connected: boolean;
clientId?: string;
data?: D;
}
enum WebSocketMessageType {
ID = 'id',
PAYLOAD = 'payload'
}
interface WebSocketIdMessage {
type: typeof WebSocketMessageType.ID;
id: string;
}
interface WebSocketPayloadMessage<D> {
type: typeof WebSocketMessageType.PAYLOAD;
origin_id: string;
payload: D;
}
export type WebSocketMessage<D> =
| WebSocketIdMessage
| WebSocketPayloadMessage<D>;
export function webSocketController<D, P extends WebSocketControllerProps<D>>(
wsUrl: string,
wsThrottle: number,
WebSocketController: React.ComponentType<P & WebSocketControllerProps<D>>
) {
return withSnackbar(
class extends React.Component<
Omit<P, keyof WebSocketControllerProps<D>> & WithSnackbarProps,
WebSocketControllerState<D>
> {
constructor(
props: Omit<P, keyof WebSocketControllerProps<D>> & WithSnackbarProps
) {
super(props);
this.state = {
ws: new Sockette(addAccessTokenParameter(wsUrl), {
onmessage: this.onMessage,
onopen: this.onOpen,
onclose: this.onClose
}),
connected: false
};
}
componentWillUnmount() {
this.state.ws.close();
}
onMessage = (event: MessageEvent) => {
const rawData = event.data;
if (typeof rawData === 'string' || rawData instanceof String) {
this.handleMessage(
JSON.parse(rawData as string) as WebSocketMessage<D>
);
}
};
handleMessage = (message: WebSocketMessage<D>) => {
const { clientId, data } = this.state;
switch (message.type) {
case WebSocketMessageType.ID:
this.setState({ clientId: message.id });
break;
case WebSocketMessageType.PAYLOAD:
if (clientId && (!data || clientId !== message.origin_id)) {
this.setState({ data: message.payload });
}
break;
}
};
onOpen = () => {
this.setState({ connected: true });
};
onClose = () => {
this.setState({
connected: false,
clientId: undefined,
data: undefined
});
};
setData = (data: D, callback?: () => void) => {
this.setState({ data }, callback);
};
saveData = throttle(() => {
const { ws, connected, data } = this.state;
if (connected) {
ws.json(data);
}
}, wsThrottle);
saveDataAndClear = throttle(() => {
const { ws, connected, data } = this.state;
if (connected) {
this.setState(
{
data: undefined
},
() => ws.json(data)
);
}
}, wsThrottle);
handleValueChange = (name: keyof D) => (
event: React.ChangeEvent<HTMLInputElement>
) => {
const data = { ...this.state.data!, [name]: extractEventValue(event) };
this.setState({ data });
};
render() {
return (
<WebSocketController
{...(this.props as P)}
handleValueChange={this.handleValueChange}
setData={this.setData}
saveData={this.saveData}
saveDataAndClear={this.saveDataAndClear}
connected={this.state.connected}
data={this.state.data}
/>
);
}
}
);
}

View File

@@ -1,43 +0,0 @@
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
import { LinearProgress, Typography } from '@material-ui/core';
import { WebSocketControllerProps } from '.';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
loadingSettings: {
margin: theme.spacing(0.5)
},
loadingSettingsDetails: {
margin: theme.spacing(4),
textAlign: 'center'
}
})
);
export type WebSocketFormProps<D> = Omit<
WebSocketControllerProps<D>,
'connected'
> & { data: D };
interface WebSocketFormLoaderProps<D> extends WebSocketControllerProps<D> {
render: (props: WebSocketFormProps<D>) => JSX.Element;
}
export default function WebSocketFormLoader<D>(
props: WebSocketFormLoaderProps<D>
) {
const { connected, render, data, ...rest } = props;
const classes = useStyles();
if (!connected || !data) {
return (
<div className={classes.loadingSettings}>
<LinearProgress className={classes.loadingSettingsDetails} />
<Typography variant="h6" className={classes.loadingSettingsDetails}>
Connecting to WebSocket...
</Typography>
</div>
);
}
return render({ ...rest, data });
}

View File

@@ -1,14 +0,0 @@
import { useLayoutEffect, useState } from 'react';
export function useWindowSize() {
const [size, setSize] = useState([0, 0]);
useLayoutEffect(() => {
function updateSize() {
setSize([window.innerWidth, window.innerHeight]);
}
window.addEventListener('resize', updateSize);
updateSize();
return () => window.removeEventListener('resize', updateSize);
}, []);
return size;
}

View File

@@ -1,20 +1,8 @@
export { default as BlockFormControlLabel } from './BlockFormControlLabel';
export { default as FormActions } from './FormActions';
export { default as FormButton } from './FormButton';
export { default as HighlightAvatar } from './HighlightAvatar';
export { default as MenuAppBar } from './MenuAppBar';
export { default as PasswordValidator } from './PasswordValidator';
export { default as RestFormLoader } from './RestFormLoader';
export { default as FormLoader } from './FormLoader';
export * from './inputs';
export * from './layout';
export * from './loading';
export * from './routing';
export * from './upload';
export { default as SectionContent } from './SectionContent';
export { default as WebSocketFormLoader } from './WebSocketFormLoader';
export { default as ErrorButton } from './ErrorButton';
export { default as SingleUpload } from './SingleUpload';
export * from './RestFormLoader';
export * from './RestController';
export * from './WebSocketFormLoader';
export * from './WebSocketController';
export * from './WindowSize';
export { default as ButtonRow } from './ButtonRow';
export { default as MessageBox } from './MessageBox';

View File

@@ -1,5 +1,5 @@
import { FC } from 'react';
import { FormControlLabel, FormControlLabelProps } from '@material-ui/core';
import { FormControlLabel, FormControlLabelProps } from '@mui/material';
const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => (
<div>

View File

@@ -0,0 +1,36 @@
import { FC, useState } from 'react';
import { IconButton, InputAdornment } from '@mui/material';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import ValidatedTextField, { ValidatedTextFieldProps } from './ValidatedTextField';
type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ InputProps, ...props }) => {
const [showPassword, setShowPassword] = useState<boolean>(false);
return (
<ValidatedTextField
{...props}
type={showPassword ? 'text' : 'password'}
InputProps={{
...InputProps,
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
</IconButton>
</InputAdornment>
)
}}
/>
);
};
export default ValidatedPasswordField;

View File

@@ -0,0 +1,24 @@
import { FC } from 'react';
import { ValidateFieldsError } from 'async-validator';
import { FormHelperText, TextField, TextFieldProps } from '@mui/material';
interface ValidatedFieldProps {
fieldErrors?: ValidateFieldsError;
name: string;
}
export type ValidatedTextFieldProps = ValidatedFieldProps & TextFieldProps;
const ValidatedTextField: FC<ValidatedTextFieldProps> = ({ fieldErrors, ...rest }) => {
const errors = fieldErrors && fieldErrors[rest.name];
const renderErrors = () => errors && errors.map((e, i) => <FormHelperText key={i}>{e.message}</FormHelperText>);
return (
<>
<TextField error={!!errors} {...rest} />
{renderErrors()}
</>
);
};
export default ValidatedTextField;

View File

@@ -0,0 +1,3 @@
export { default as BlockFormControlLabel } from './BlockFormControlLabel';
export { default as ValidatedPasswordField } from './ValidatedPasswordField';
export { default as ValidatedTextField } from './ValidatedTextField';

View File

@@ -0,0 +1,36 @@
import { FC, useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { Box, Toolbar } from '@mui/material';
import { PROJECT_NAME } from '../../api/env';
import LayoutDrawer from './LayoutDrawer';
import LayoutAppBar from './LayoutAppBar';
import { LayoutContext } from './context';
export const DRAWER_WIDTH = 240;
const Layout: FC = ({ children }) => {
const [mobileOpen, setMobileOpen] = useState(false);
const [title, setTitle] = useState(PROJECT_NAME);
const { pathname } = useLocation();
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
};
useEffect(() => setMobileOpen(false), [pathname]);
return (
<LayoutContext.Provider value={{ title, setTitle }}>
<LayoutAppBar title={title} onToggleDrawer={handleDrawerToggle} />
<LayoutDrawer mobileOpen={mobileOpen} onClose={handleDrawerToggle} />
<Box component="main" sx={{ marginLeft: { md: `${DRAWER_WIDTH}px` } }}>
<Toolbar />
{children}
</Box>
</LayoutContext.Provider>
);
};
export default Layout;

View File

@@ -0,0 +1,51 @@
import { FC, useContext } from 'react';
import { AppBar, Box, IconButton, Toolbar, Typography } from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import LayoutAuthMenu from './LayoutAuthMenu';
import { FeaturesContext } from '../../contexts/features';
export const DRAWER_WIDTH = 240;
interface LayoutAppBarProps {
title: string;
onToggleDrawer: () => void;
}
const LayoutAppBar: FC<LayoutAppBarProps> = ({ title, onToggleDrawer }) => {
const { features } = useContext(FeaturesContext);
return (
<AppBar
position="fixed"
sx={{
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
ml: { md: `${DRAWER_WIDTH}px` },
boxShadow: 'none',
backgroundColor: '#2e586a'
// color: "#2196f3",
}}
>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={onToggleDrawer}
sx={{ mr: 2, display: { md: 'none' } }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div">
{title}
</Typography>
<Box flexGrow={1} />
{features.security && <LayoutAuthMenu />}
</Toolbar>
</AppBar>
);
};
export default LayoutAppBar;

View File

@@ -0,0 +1,73 @@
import { FC, useState, useContext } from 'react';
import { Box, Button, Divider, IconButton, Popover, Typography, Avatar, styled, TypographyProps } from '@mui/material';
import PersonIcon from '@mui/icons-material/Person';
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import { AuthenticatedContext } from '../../contexts/authentication';
const ItemTypography = styled(Typography)<TypographyProps>({
maxWidth: '250px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
});
const LayoutAuthMenu: FC = () => {
const { me, signOut } = useContext(AuthenticatedContext);
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const open = Boolean(anchorEl);
const id = anchorEl ? 'app-menu-popover' : undefined;
return (
<>
<IconButton id="open-auth-menu" sx={{ padding: 0 }} aria-describedby={id} color="inherit" onClick={handleClick}>
<AccountCircleIcon />
</IconButton>
<Popover
id="app-menu-popover"
sx={{ mt: 1 }}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center'
}}
>
<Box display="flex" flexDirection="row" alignItems="center" p={2}>
<Avatar sx={{ width: 80, height: 80 }}>
<PersonIcon fontSize="large" />
</Avatar>
<Box pl={2}>
<ItemTypography variant="h6">{me.username}</ItemTypography>
<ItemTypography variant="body1">{me.admin ? 'Admin User' : 'Guest User'}</ItemTypography>
</Box>
</Box>
<Divider />
<Box p={1.5}>
<Button variant="outlined" fullWidth color="primary" onClick={() => signOut(true)}>
Sign Out
</Button>
</Box>
</Popover>
</>
);
};
export default LayoutAuthMenu;

View File

@@ -0,0 +1,73 @@
import { FC } from 'react';
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
import { PROJECT_NAME } from '../../api/env';
import LayoutMenu from './LayoutMenu';
import { DRAWER_WIDTH } from './Layout';
const LayoutDrawerLogo = styled('img')(({ theme }) => ({
[theme.breakpoints.down('sm')]: {
height: 24,
marginRight: theme.spacing(2)
},
[theme.breakpoints.up('sm')]: {
height: 36,
marginRight: theme.spacing(2)
}
}));
interface LayoutDrawerProps {
mobileOpen: boolean;
onClose: () => void;
}
const LayoutDrawer: FC<LayoutDrawerProps> = ({ mobileOpen, onClose }) => {
const drawer = (
<>
<Toolbar disableGutters>
<Box display="flex" alignItems="center" px={2}>
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
<Typography variant="h6" color="textPrimary">
{PROJECT_NAME}
</Typography>
</Box>
<Divider absolute />
</Toolbar>
<Divider />
<LayoutMenu />
</>
);
return (
<Box component="nav" sx={{ width: { md: DRAWER_WIDTH }, flexShrink: { md: 0 } }}>
<Drawer
variant="temporary"
open={mobileOpen}
onClose={onClose}
ModalProps={{
keepMounted: true // Better open performance on mobile.
}}
sx={{
display: { xs: 'block', md: 'none' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: DRAWER_WIDTH }
}}
>
{drawer}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', md: 'block' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: DRAWER_WIDTH }
}}
open
>
{drawer}
</Drawer>
</Box>
);
};
export default LayoutDrawer;

View File

@@ -0,0 +1,42 @@
import { FC, useContext } from 'react';
import { Divider, List } from '@mui/material';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import SettingsIcon from '@mui/icons-material/Settings';
import LockIcon from '@mui/icons-material/Lock';
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
import { FeaturesContext } from '../../contexts/features';
import ProjectMenu from '../../project/ProjectMenu';
import LayoutMenuItem from './LayoutMenuItem';
import { AuthenticatedContext } from '../../contexts/authentication';
const LayoutMenu: FC = () => {
const { features } = useContext(FeaturesContext);
const authenticatedContext = useContext(AuthenticatedContext);
return (
<>
{features.project && (
<List disablePadding component="nav">
<ProjectMenu />
<Divider />
</List>
)}
<List disablePadding component="nav">
<LayoutMenuItem icon={SettingsEthernetIcon} label="Network Connection" to="/network" />
<LayoutMenuItem icon={SettingsInputAntennaIcon} label="Access Point" to="/ap" />
{features.ntp && <LayoutMenuItem icon={AccessTimeIcon} label="Network Time" to="/ntp" />}
{features.mqtt && <LayoutMenuItem icon={DeviceHubIcon} label="MQTT" to="/mqtt" />}
<LayoutMenuItem icon={LockIcon} label="Security" to="/security" disabled={!authenticatedContext.me.admin} />
<LayoutMenuItem icon={SettingsIcon} label="System" to="/system" />
</List>
</>
);
};
export default LayoutMenu;

View File

@@ -0,0 +1,32 @@
import { FC } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { ListItem, ListItemButton, ListItemIcon, ListItemText, SvgIconProps } from '@mui/material';
import { grey } from '@mui/material/colors';
import { routeMatches } from '../../utils';
interface LayoutMenuItemProps {
icon: React.ComponentType<SvgIconProps>;
label: string;
to: string;
disabled?: boolean;
}
const LayoutMenuItem: FC<LayoutMenuItemProps> = ({ icon: Icon, label, to, disabled }) => {
const { pathname } = useLocation();
return (
<ListItem disablePadding selected={routeMatches(to, pathname)}>
<ListItemButton component={Link} to={to} disabled={disabled}>
<ListItemIcon sx={{ color: grey[500] }}>
<Icon />
</ListItemIcon>
<ListItemText>{label}</ListItemText>
</ListItemButton>
</ListItem>
);
};
export default LayoutMenuItem;

View File

@@ -0,0 +1,25 @@
import { useRef, useEffect, createContext, useContext } from 'react';
export interface LayoutContextValue {
title: string;
setTitle: (title: string) => void;
}
const LayoutContextDefaultValue = {} as LayoutContextValue;
export const LayoutContext = createContext(LayoutContextDefaultValue);
export const useLayoutTitle = (myTitle: string) => {
const { title, setTitle } = useContext(LayoutContext);
const previousTitle = useRef(title);
useEffect(() => {
setTitle(myTitle);
}, [setTitle, myTitle]);
useEffect(
() => () => {
setTitle(previousTitle.current);
},
[setTitle]
);
};

View File

@@ -0,0 +1,2 @@
export * from './context';
export { default as Layout } from './Layout';

View File

@@ -0,0 +1,43 @@
import { FC } from 'react';
import { Box, Paper, Typography } from '@mui/material';
import WarningIcon from '@mui/icons-material/Warning';
interface ApplicationErrorProps {
message?: string;
}
const ApplicationError: FC<ApplicationErrorProps> = ({ message }) => (
<Box display="flex" height="100vh" justifyContent="center" flexDirection="column">
<Paper
elevation={10}
sx={{
textAlign: 'center',
padding: '280px 0 40px 0',
backgroundImage: 'url("/app/icon.png")',
backgroundRepeat: 'no-repeat',
backgroundPosition: '50% 40px',
backgroundSize: '200px auto',
width: '100%',
borderRadius: 0
}}
>
<Box display="flex" flexDirection="row" justifyContent="center" alignItems="center" mb={2}>
<WarningIcon fontSize="large" color="error" />
<Box ml={2}>
<Typography variant="h4">Application Error</Typography>
</Box>
</Box>
<Typography variant="subtitle1" gutterBottom>
Failed to configure the application, please refresh to try again.
</Typography>
{message && (
<Typography variant="subtitle2" gutterBottom>
{message}
</Typography>
)}
</Paper>
</Box>
);
export default ApplicationError;

View File

@@ -0,0 +1,38 @@
import { FC } from 'react';
import { Box, Button, CircularProgress, Typography } from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import { MessageBox } from '..';
interface FormLoaderProps {
message?: string;
errorMessage?: string;
onRetry?: () => void;
}
const FormLoader: FC<FormLoaderProps> = ({ errorMessage, onRetry, message = 'Loading…' }) => {
if (errorMessage) {
return (
<MessageBox my={2} level="error" message={errorMessage}>
{onRetry && (
<Button startIcon={<RefreshIcon />} variant="contained" color="error" onClick={onRetry}>
Retry
</Button>
)}
</MessageBox>
);
}
return (
<Box m={2} py={2} display="flex" alignItems="center" flexDirection="column">
<Box py={2}>
<CircularProgress size={100} />
</Box>
<Typography variant="h6" fontWeight={400} textAlign="center">
{message}
</Typography>
</Box>
);
};
export default FormLoader;

View File

@@ -0,0 +1,24 @@
import { FC } from 'react';
import { CircularProgress, Box, Typography, Theme } from '@mui/material';
interface LoadingSpinnerProps {
height?: number | string;
}
const LoadingSpinner: FC<LoadingSpinnerProps> = ({ height = '100%' }) => (
<Box display="flex" alignItems="center" justifyContent="center" flexDirection="column" padding={2} height={height}>
<CircularProgress
sx={(theme: Theme) => ({
margin: theme.spacing(4),
color: theme.palette.text.secondary
})}
size={100}
/>
<Typography variant="h4" color="textSecondary">
Loading&hellip;
</Typography>
</Box>
);
export default LoadingSpinner;

View File

@@ -0,0 +1,3 @@
export { default as ApplicationError } from './ApplicationError';
export { default as LoadingSpinner } from './LoadingSpinner';
export { default as FormLoader } from './FormLoader';

View File

@@ -0,0 +1,11 @@
import { FC, useContext } from 'react';
import { Navigate } from 'react-router-dom';
import { AuthenticatedContext } from '../../contexts/authentication';
const RequireAdmin: FC = ({ children }) => {
const authenticatedContext = useContext(AuthenticatedContext);
return authenticatedContext.me.admin ? <>{children}</> : <Navigate replace to="/" />;
};
export default RequireAdmin;

View File

@@ -0,0 +1,30 @@
import { FC, useContext, useEffect } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import {
AuthenticatedContext,
AuthenticatedContextValue,
AuthenticationContext
} from '../../contexts/authentication/context';
import { storeLoginRedirect } from '../../api/authentication';
const RequireAuthenticated: FC = ({ children }) => {
const authenticationContext = useContext(AuthenticationContext);
const location = useLocation();
useEffect(() => {
if (!authenticationContext.me) {
storeLoginRedirect(location);
}
});
return authenticationContext.me ? (
<AuthenticatedContext.Provider value={authenticationContext as AuthenticatedContextValue}>
{children}
</AuthenticatedContext.Provider>
) : (
<Navigate to="/unauthorized" />
);
};
export default RequireAuthenticated;

View File

@@ -0,0 +1,15 @@
import { FC, useContext } from 'react';
import { Navigate } from 'react-router-dom';
import * as AuthenticationApi from '../../api/authentication';
import { AuthenticationContext } from '../../contexts/authentication';
import { FeaturesContext } from '../../contexts/features';
const RequireUnauthenticated: FC = ({ children }) => {
const { features } = useContext(FeaturesContext);
const authenticationContext = useContext(AuthenticationContext);
return authenticationContext.me ? <Navigate to={AuthenticationApi.fetchLoginRedirect(features)} /> : <>{children}</>;
};
export default RequireUnauthenticated;

View File

@@ -0,0 +1,27 @@
import React, { FC } from 'react';
import { useNavigate } from 'react-router-dom';
import { Tabs, useMediaQuery, useTheme } from '@mui/material';
interface RouterTabsProps {
value: string | false;
}
const RouterTabs: FC<RouterTabsProps> = ({ value, children }) => {
const navigate = useNavigate();
const theme = useTheme();
const smallDown = useMediaQuery(theme.breakpoints.down('sm'));
const handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
navigate(path);
};
return (
<Tabs value={value} onChange={handleTabChange} variant={smallDown ? 'scrollable' : 'fullWidth'}>
{children}
</Tabs>
);
};
export default RouterTabs;

View File

@@ -0,0 +1,6 @@
export { default as RouterTabs } from './RouterTabs';
export { default as RequireAdmin } from './RequireAdmin';
export { default as RequireAuthenticated } from './RequireAuthenticated';
export { default as RequireUnauthenticated } from './RequireUnauthenticated';
export * from './useRouterTab';

View File

@@ -0,0 +1,9 @@
import { useMatch, useResolvedPath } from 'react-router-dom';
export const useRouterTab = () => {
const routerTabPath = useResolvedPath(':tab');
const routerTabPathMatch = useMatch(routerTabPath.pathname);
const routerTab = routerTabPathMatch?.params?.tab || false;
return { routerTab } as const;
};

View File

@@ -0,0 +1,86 @@
import { FC, Fragment } from 'react';
import { useDropzone, DropzoneState } from 'react-dropzone';
import { Box, Button, LinearProgress, Theme, Typography, useTheme } from '@mui/material';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CancelIcon from '@mui/icons-material/Cancel';
const progressPercentage = (progress: ProgressEvent) => Math.round((progress.loaded * 100) / progress.total);
const getBorderColor = (theme: Theme, props: DropzoneState) => {
if (props.isDragAccept) {
return theme.palette.success.main;
}
if (props.isDragReject) {
return theme.palette.error.main;
}
if (props.isDragActive) {
return theme.palette.info.main;
}
return theme.palette.grey[700];
};
export interface SingleUploadProps {
onDrop: (acceptedFiles: File[]) => void;
onCancel: () => void;
accept?: string | string[];
uploading: boolean;
progress?: ProgressEvent;
}
const SingleUpload: FC<SingleUploadProps> = ({ onDrop, onCancel, accept, uploading, progress }) => {
const dropzoneState = useDropzone({ onDrop, accept, disabled: uploading, multiple: false });
const { getRootProps, getInputProps } = dropzoneState;
const theme = useTheme();
const progressText = () => {
if (uploading) {
if (progress?.lengthComputable) {
return `Uploading: ${progressPercentage(progress)}%`;
}
return 'Uploading\u2026';
}
return 'Drop file or click here';
};
return (
<Box
{...getRootProps({
sx: {
py: 8,
px: 2,
borderWidth: 2,
borderRadius: 2,
borderStyle: 'dashed',
color: theme.palette.grey[700],
transition: 'border .24s ease-in-out',
width: '100%',
cursor: uploading ? 'default' : 'pointer',
borderColor: getBorderColor(theme, dropzoneState)
}
})}
>
<input {...getInputProps()} />
<Box flexDirection="column" display="flex" alignItems="center">
<CloudUploadIcon fontSize="large" />
<Typography variant="h6">{progressText()}</Typography>
{uploading && (
<Fragment>
<Box width="100%" p={2}>
<LinearProgress
variant={!progress || progress.lengthComputable ? 'determinate' : 'indeterminate'}
value={!progress ? 0 : progress.lengthComputable ? progressPercentage(progress) : 0}
/>
</Box>
<Button startIcon={<CancelIcon />} variant="outlined" color="secondary" onClick={onCancel}>
Cancel
</Button>
</Fragment>
)}
</Box>
</Box>
);
};
export default SingleUpload;

View File

@@ -0,0 +1,2 @@
export { default as SingleUpload } from './SingleUpload';
export { default as useFileUpload } from './useFileUpload';

View File

@@ -0,0 +1,59 @@
import { useCallback, useEffect, useState } from 'react';
import axios, { AxiosPromise, CancelTokenSource } from 'axios';
import { useSnackbar } from 'notistack';
import { extractErrorMessage } from '../../utils';
import { FileUploadConfig } from '../../api/endpoints';
interface MediaUploadOptions {
upload: (file: File, config?: FileUploadConfig) => AxiosPromise<void>;
}
const useFileUpload = ({ upload }: MediaUploadOptions) => {
const { enqueueSnackbar } = useSnackbar();
const [uploading, setUploading] = useState<boolean>(false);
const [uploadProgress, setUploadProgress] = useState<ProgressEvent>();
const [uploadCancelToken, setUploadCancelToken] = useState<CancelTokenSource>();
const resetUploadingStates = () => {
setUploading(false);
setUploadProgress(undefined);
setUploadCancelToken(undefined);
};
const cancelUpload = useCallback(() => {
uploadCancelToken?.cancel();
resetUploadingStates();
}, [uploadCancelToken]);
useEffect(() => {
return () => {
uploadCancelToken?.cancel();
};
}, [uploadCancelToken]);
const uploadFile = async (images: File[]) => {
try {
const cancelToken = axios.CancelToken.source();
setUploadCancelToken(cancelToken);
setUploading(true);
await upload(images[0], {
onUploadProgress: setUploadProgress,
cancelToken: cancelToken.token
});
resetUploadingStates();
enqueueSnackbar('Upload successful', { variant: 'success' });
} catch (error: any) {
if (axios.isCancel(error)) {
enqueueSnackbar('Upload aborted', { variant: 'warning' });
} else {
resetUploadingStates();
enqueueSnackbar(extractErrorMessage(error, 'Upload failed'), { variant: 'error' });
}
}
};
return [uploadFile, cancelUpload, uploading, uploadProgress] as const;
};
export default useFileUpload;