mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2025-12-07 00:09:51 +03:00
Merge remote-tracking branch 'origin/v3.4' into dev
This commit is contained in:
@@ -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;
|
||||
26
interface/src/components/ButtonRow.tsx
Normal file
26
interface/src/components/ButtonRow.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -1,7 +0,0 @@
|
||||
import { styled, Box } from '@material-ui/core';
|
||||
|
||||
const FormActions = styled(Box)(({ theme }) => ({
|
||||
marginTop: theme.spacing(1)
|
||||
}));
|
||||
|
||||
export default FormActions;
|
||||
@@ -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;
|
||||
@@ -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…
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormLoader;
|
||||
@@ -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…</Typography>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FullScreenLoading;
|
||||
@@ -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;
|
||||
@@ -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)))
|
||||
)
|
||||
);
|
||||
48
interface/src/components/MessageBox.tsx
Normal file
48
interface/src/components/MessageBox.tsx
Normal 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;
|
||||
@@ -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);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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…
|
||||
</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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
36
interface/src/components/inputs/ValidatedPasswordField.tsx
Normal file
36
interface/src/components/inputs/ValidatedPasswordField.tsx
Normal 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;
|
||||
24
interface/src/components/inputs/ValidatedTextField.tsx
Normal file
24
interface/src/components/inputs/ValidatedTextField.tsx
Normal 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;
|
||||
3
interface/src/components/inputs/index.ts
Normal file
3
interface/src/components/inputs/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as BlockFormControlLabel } from './BlockFormControlLabel';
|
||||
export { default as ValidatedPasswordField } from './ValidatedPasswordField';
|
||||
export { default as ValidatedTextField } from './ValidatedTextField';
|
||||
36
interface/src/components/layout/Layout.tsx
Normal file
36
interface/src/components/layout/Layout.tsx
Normal 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;
|
||||
51
interface/src/components/layout/LayoutAppBar.tsx
Normal file
51
interface/src/components/layout/LayoutAppBar.tsx
Normal 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;
|
||||
73
interface/src/components/layout/LayoutAuthMenu.tsx
Normal file
73
interface/src/components/layout/LayoutAuthMenu.tsx
Normal 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;
|
||||
73
interface/src/components/layout/LayoutDrawer.tsx
Normal file
73
interface/src/components/layout/LayoutDrawer.tsx
Normal 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;
|
||||
42
interface/src/components/layout/LayoutMenu.tsx
Normal file
42
interface/src/components/layout/LayoutMenu.tsx
Normal 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;
|
||||
32
interface/src/components/layout/LayoutMenuItem.tsx
Normal file
32
interface/src/components/layout/LayoutMenuItem.tsx
Normal 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;
|
||||
25
interface/src/components/layout/context.ts
Normal file
25
interface/src/components/layout/context.ts
Normal 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]
|
||||
);
|
||||
};
|
||||
2
interface/src/components/layout/index.ts
Normal file
2
interface/src/components/layout/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './context';
|
||||
export { default as Layout } from './Layout';
|
||||
43
interface/src/components/loading/ApplicationError.tsx
Normal file
43
interface/src/components/loading/ApplicationError.tsx
Normal 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;
|
||||
38
interface/src/components/loading/FormLoader.tsx
Normal file
38
interface/src/components/loading/FormLoader.tsx
Normal 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;
|
||||
24
interface/src/components/loading/LoadingSpinner.tsx
Normal file
24
interface/src/components/loading/LoadingSpinner.tsx
Normal 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…
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default LoadingSpinner;
|
||||
3
interface/src/components/loading/index.ts
Normal file
3
interface/src/components/loading/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as ApplicationError } from './ApplicationError';
|
||||
export { default as LoadingSpinner } from './LoadingSpinner';
|
||||
export { default as FormLoader } from './FormLoader';
|
||||
11
interface/src/components/routing/RequireAdmin.tsx
Normal file
11
interface/src/components/routing/RequireAdmin.tsx
Normal 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;
|
||||
30
interface/src/components/routing/RequireAuthenticated.tsx
Normal file
30
interface/src/components/routing/RequireAuthenticated.tsx
Normal 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;
|
||||
15
interface/src/components/routing/RequireUnauthenticated.tsx
Normal file
15
interface/src/components/routing/RequireUnauthenticated.tsx
Normal 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;
|
||||
27
interface/src/components/routing/RouterTabs.tsx
Normal file
27
interface/src/components/routing/RouterTabs.tsx
Normal 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;
|
||||
6
interface/src/components/routing/index.ts
Normal file
6
interface/src/components/routing/index.ts
Normal 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';
|
||||
9
interface/src/components/routing/useRouterTab.ts
Normal file
9
interface/src/components/routing/useRouterTab.ts
Normal 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;
|
||||
};
|
||||
86
interface/src/components/upload/SingleUpload.tsx
Normal file
86
interface/src/components/upload/SingleUpload.tsx
Normal 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;
|
||||
2
interface/src/components/upload/index.ts
Normal file
2
interface/src/components/upload/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as SingleUpload } from './SingleUpload';
|
||||
export { default as useFileUpload } from './useFileUpload';
|
||||
59
interface/src/components/upload/useFileUpload.ts
Normal file
59
interface/src/components/upload/useFileUpload.ts
Normal 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;
|
||||
Reference in New Issue
Block a user