1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-03-21 14:50:08 +03:00

F #3951: Update app layouts & sidebar (#169)

* F #3951: Update app layouts and router

* F #3951: Fix footer component

* F #3951: Add loading screen & update sidebar

* F #3951: Add responsiveness to sidebar
This commit is contained in:
Sergio Betanzos 2020-08-26 18:42:25 +02:00 committed by GitHub
parent f979efd2b5
commit 78d699561c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 705 additions and 340 deletions

View File

@ -36,7 +36,7 @@
"axios": "^0.19.2",
"body-parser": "^1.19.0",
"btoa": "^1.2.1",
"classnames": "^2.2.6",
"clsx": "^1.1.1",
"colors": "^1.4.0",
"compression": "^1.7.4",
"concurrently": "^5.2.0",

View File

@ -1,11 +1,13 @@
const CHANGE_ZONE = 'CHANGE_ZONE';
const DISPLAY_LOADING = 'DISPLAY_LOADING';
const TOGGLE_MENU = 'TOGGLE_MENU';
const FIX_MENU = 'FIX_MENU';
const Actions = {
CHANGE_ZONE,
DISPLAY_LOADING,
TOGGLE_MENU
TOGGLE_MENU,
FIX_MENU
};
module.exports = {
@ -21,5 +23,9 @@ module.exports = {
openMenu: isOpen => ({
type: TOGGLE_MENU,
isOpen
}),
fixMenu: isFixed => ({
type: FIX_MENU,
isFixed
})
};

View File

@ -18,9 +18,9 @@ import { StaticRouter, BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import PropTypes from 'prop-types';
import { CssBaseline, ThemeProvider } from '@material-ui/core';
import { CssBaseline, ThemeProvider, StylesProvider } from '@material-ui/core';
import theme from 'client/assets/theme';
import theme, { generateClassName } from 'client/assets/theme';
import { TranslateProvider } from 'client/components/HOC';
import Router from 'client/router';
@ -35,21 +35,23 @@ const App = ({ location, context, store }) => {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Provider store={store}>
<TranslateProvider>
{location && context ? (
// server build
<StaticRouter location={location} context={context}>
<Router />
</StaticRouter>
) : (
// browser build
<BrowserRouter>
<Router />
</BrowserRouter>
)}
</TranslateProvider>
</Provider>
<StylesProvider generateClassName={generateClassName}>
<Provider store={store}>
<TranslateProvider>
{location && context ? (
// server build
<StaticRouter location={location} context={context}>
<Router />
</StaticRouter>
) : (
// browser build
<BrowserRouter>
<Router />
</BrowserRouter>
)}
</TranslateProvider>
</Provider>
</StylesProvider>
</ThemeProvider>
);
};

View File

@ -1,6 +1,14 @@
import { createMuiTheme, responsiveFontSizes } from '@material-ui/core';
import {
createMuiTheme,
responsiveFontSizes,
createGenerateClassName
} from '@material-ui/core';
const defaultBreakpoints = {
export const generateClassName = createGenerateClassName({
productionPrefix: 'one-'
});
export const defaultBreakpoints = {
xs: 0,
sm: 600,
md: 960,
@ -12,99 +20,95 @@ const defaultBreakpoints = {
desktop: 1280
};
const theme = createMuiTheme({
typography: {
fontFamily: ['Ubuntu', 'Lato'].join(',')
},
overrides: {
MuiDrawer: {
paper: {
width: 360,
overflow: 'hidden',
[`@media (max-width: ${defaultBreakpoints.tablet}px)`]: {
width: '100%'
}
}
},
MuiFormControl: {
root: {
margin: '.5rem 0'
}
},
MuiExpansionPanel: {
root: {
minHeight: 56,
boxShadow: 'none',
'&:not(:last-child)': {
borderBottom: 0
},
'&$expanded': {
minHeight: 56,
margin: '0'
}
},
content: {
'&$expanded': {
margin: '0'
}
},
expanded: {}
},
MuiExpansionPanelSummary: {
root: {
'&$disabled': {
opacity: 1
}
},
content: {
margin: 8,
'&$expanded': {
margin: 8
}
},
disabled: {},
expanded: {}
},
MuiCssBaseline: {
'@global': {
body: {
// height: '100vh'
}
// '@font-face': [UbuntuFont]
}
}
},
palette: {
common: { black: '#000', white: '#fff' },
background: {
paper: '#fff',
default: '#fafafa'
},
primary: {
light: 'rgba(191, 230, 242, 1)',
main: 'rgba(64, 179, 217, 1)',
dark: 'rgba(0, 152, 195, 1)',
contrastText: '#fff'
},
secondary: {
light: 'rgba(199, 201, 200, 1)',
main: 'rgba(87, 92, 91, 1)',
dark: 'rgba(53, 55, 53, 1)',
contrastText: '#fff'
},
error: {
light: '#e57373',
main: '#f44336',
dark: '#d32f2f',
contrastText: '#fff'
},
text: {
primary: 'rgba(0, 0, 0, 0.87)',
secondary: 'rgba(0, 0, 0, 0.54)',
disabled: 'rgba(0, 0, 0, 0.38)',
hint: 'rgba(0, 0, 0, 0.38)'
}
}
});
export const sidebarWidth = {
minified: 60,
fixed: 240
};
export default responsiveFontSizes(theme);
export default responsiveFontSizes(
createMuiTheme({
typography: {
fontFamily: ['Ubuntu', 'Lato'].join(',')
},
overrides: {
MuiFormControl: {
root: {
margin: '.5rem 0'
}
},
MuiExpansionPanel: {
root: {
minHeight: 56,
boxShadow: 'none',
'&:not(:last-child)': {
borderBottom: 0
},
'&$expanded': {
minHeight: 56,
margin: '0'
}
},
content: {
'&$expanded': {
margin: '0'
}
},
expanded: {}
},
MuiExpansionPanelSummary: {
root: {
'&$disabled': {
opacity: 1
}
},
content: {
margin: 8,
'&$expanded': {
margin: 8
}
},
disabled: {},
expanded: {}
},
MuiCssBaseline: {
'@global': {
body: {
// height: '100vh'
}
// '@font-face': [UbuntuFont]
}
}
},
palette: {
common: { black: '#000', white: '#fff' },
background: {
paper: '#fff',
default: '#fafafa'
},
primary: {
light: 'rgba(191, 230, 242, 1)',
main: 'rgba(64, 179, 217, 1)',
dark: 'rgba(0, 152, 195, 1)',
contrastText: '#fff'
},
secondary: {
light: 'rgba(199, 201, 200, 1)',
main: 'rgba(87, 92, 91, 1)',
dark: 'rgba(53, 55, 53, 1)',
contrastText: '#fff'
},
error: {
light: '#e57373',
main: '#f44336',
dark: '#d32f2f',
contrastText: '#fff'
},
text: {
primary: 'rgba(0, 0, 0, 0.87)',
secondary: 'rgba(0, 0, 0, 0.54)',
disabled: 'rgba(0, 0, 0, 0.38)',
hint: 'rgba(0, 0, 0, 0.38)'
}
}
})
);

View File

@ -26,7 +26,11 @@ const Footer = React.memo(() => {
return (
<Box className={classes.footer} component="footer">
{`❤️ by `}
{'Made with'}
<span className={classes.heartIcon} role="img" aria-label="heart-emoji">
{'❤️'}
</span>
{'by'}
<Link href={url} className={classes.link}>
{text}
</Link>

View File

@ -3,7 +3,7 @@ import { makeStyles } from '@material-ui/core';
export default makeStyles(theme => ({
footer: {
color: theme.palette.primary.light,
position: 'fixed',
position: 'absolute',
bottom: 0,
left: 'auto',
right: 0,
@ -13,6 +13,10 @@ export default makeStyles(theme => ({
textAlign: 'center',
padding: 5
},
heartIcon: {
margin: theme.spacing(0, 1),
color: theme.palette.error.dark
},
link: {
color: theme.palette.primary.light
}

View File

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { makeStyles, CircularProgress, Button } from '@material-ui/core';
import { Translate } from 'client/components/HOC';
import { Tr } from 'client/components/HOC';
import * as CONSTANT from 'client/constants';
const useStyles = makeStyles(theme => ({
@ -26,7 +26,7 @@ const ButtonSubmit = ({ isSubmitting, label, ...rest }) => {
{...rest}
>
{isSubmitting && <CircularProgress size={24} />}
{!isSubmitting && <Translate word={label ?? CONSTANT.default.Submit} />}
{!isSubmitting && Tr(label)}
</Button>
);
};

View File

@ -1,49 +0,0 @@
/* Copyright 2002-2019, OpenNebula Project, OpenNebula Systems */
/* */
/* Licensed under the Apache License, Version 2.0 (the "License"); you may */
/* not use this file except in compliance with the License. You may obtain */
/* a copy of the License at */
/* */
/* http://www.apache.org/licenses/LICENSE-2.0 */
/* */
/* Unless required by applicable law or agreed to in writing, software */
/* distributed under the License is distributed on an "AS IS" BASIS, */
/* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. */
/* See the License for the specific language governing permissions and */
/* limitations under the License. */
/* -------------------------------------------------------------------------- */
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { Redirect } from 'react-router-dom';
import { LinearProgress } from '@material-ui/core';
import useAuth from 'client/hooks/useAuth';
import { PATH } from 'client/router/endpoints';
const GuessLayout = ({ children }) => {
const { isLoginInProcess, isLogged, firstRender } = useAuth();
if (firstRender) {
return <LinearProgress style={{ width: '100%' }} />;
} else if (isLogged && !isLoginInProcess) {
return <Redirect to={PATH.DASHBOARD} />;
}
return <Fragment>{children}</Fragment>;
};
GuessLayout.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
PropTypes.string
])
};
GuessLayout.defaultProps = {
children: ''
};
export default GuessLayout;

View File

@ -15,68 +15,32 @@
import React from 'react';
import PropTypes from 'prop-types';
import clsx from 'clsx';
import { makeStyles, Box, Container } from '@material-ui/core';
import { Skeleton } from '@material-ui/lab';
import { Box, Container } from '@material-ui/core';
import useGeneral from 'client/hooks/useGeneral';
import useAuth from 'client/hooks/useAuth';
import useOpenNebula from 'client/hooks/useOpennebula';
import Header from 'client/components/Header';
import Footer from 'client/components/Footer';
import Header from 'client/components/Header';
const internalStyles = makeStyles(theme => ({
root: {
display: 'flex',
width: '100%'
},
main: {
paddingTop: 64,
paddingBottom: 30,
height: '100vh',
width: '100vw'
},
scrollable: {
paddingTop: theme.spacing(2),
paddingBottom: theme.spacing(2),
height: '100%',
overflow: 'auto',
'&::-webkit-scrollbar': {
width: 14
},
'&::-webkit-scrollbar-thumb': {
backgroundClip: 'content-box',
border: '4px solid transparent',
borderRadius: 7,
boxShadow: 'inset 0 0 0 10px',
color: theme.palette.primary.light
}
}
}));
import internalStyles from 'client/components/HOC/InternalLayout/styles';
const InternalLayout = ({ children, title }) => {
const InternalLayout = ({ authRoute, label, children }) => {
const classes = internalStyles();
const { groups } = useOpenNebula();
const { authUser } = useAuth();
const { isFixMenu } = useGeneral();
const isAuthenticating = Boolean(!authUser && !groups?.length);
return isAuthenticating ? (
<Box width="100%" display="flex" flexDirection="column">
<Skeleton variant="rect" width="100%" height={64} />
<Box padding={2}>
<Skeleton variant="rect" width="50%" height={32} />
</Box>
</Box>
) : (
<>
<Header title={title} />
return authRoute ? (
<Box className={clsx(classes.root, { [classes.isDrawerFixed]: isFixMenu })}>
<Header title={label} />
<Box component="main" className={classes.main}>
<Container component="div" className={classes.scrollable}>
{children}
</Container>
</Box>
<Footer />
</>
</Box>
) : (
children
);
};
@ -86,12 +50,14 @@ InternalLayout.propTypes = {
PropTypes.node,
PropTypes.string
]),
title: PropTypes.string
authRoute: PropTypes.bool.isRequired,
label: PropTypes.string
};
InternalLayout.defaultProps = {
children: [],
title: ''
authRoute: false,
label: null
};
export default InternalLayout;

View File

@ -0,0 +1,47 @@
import { makeStyles } from '@material-ui/core';
import { sidebarWidth } from 'client/assets/theme';
export default makeStyles(theme => ({
root: {
flex: '1 1 auto',
display: 'flex',
zIndex: '3',
overflow: 'hidden',
position: 'relative',
flexDirection: 'column',
transition: theme.transitions.create('margin', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen
}),
[theme.breakpoints.up('lg')]: {
marginLeft: sidebarWidth.minified
}
},
isDrawerFixed: {
[theme.breakpoints.up('lg')]: {
marginLeft: sidebarWidth.fixed
}
},
main: {
paddingTop: 64,
paddingBottom: 30,
height: '100vh',
width: '100%'
},
scrollable: {
paddingTop: theme.spacing(2),
paddingBottom: theme.spacing(2),
height: '100%',
overflow: 'auto',
'&::-webkit-scrollbar': {
width: 14
},
'&::-webkit-scrollbar-thumb': {
backgroundClip: 'content-box',
border: '4px solid transparent',
borderRadius: 7,
boxShadow: 'inset 0 0 0 10px',
color: theme.palette.primary.light
}
}
}));

View File

@ -1,4 +1,4 @@
/* Copyright 2002-2019, OpenNebula Project, OpenNebula Systems */
/* Copyright 2002-2020, OpenNebula Project, OpenNebula Systems */
/* */
/* Licensed under the Apache License, Version 2.0 (the "License"); you may */
/* not use this file except in compliance with the License. You may obtain */
@ -13,17 +13,27 @@
/* limitations under the License. */
/* -------------------------------------------------------------------------- */
import React, { Fragment, useEffect } from 'react';
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { Redirect } from 'react-router-dom';
import { LinearProgress } from '@material-ui/core';
import { useLocation, Redirect } from 'react-router-dom';
import useAuth from 'client/hooks/useAuth';
import { PATH } from 'client/router/endpoints';
import useOpennebula from 'client/hooks/useOpennebula';
const AuthLayout = ({ children }) => {
const { isLoginInProcess, isLogged, firstRender, getAuthInfo } = useAuth();
import LoadingScreen from 'client/components/LoadingScreen';
import Sidebar from 'client/components/Sidebar';
import { PATH, findRouteByPathname } from 'client/router/endpoints';
const MainLayout = ({ children }) => {
const { pathname } = useLocation();
const { groups } = useOpennebula();
const {
isLogged,
isLoginInProcess,
getAuthInfo,
authUser,
firstRender
} = useAuth();
useEffect(() => {
if (isLogged && !isLoginInProcess) {
@ -31,16 +41,34 @@ const AuthLayout = ({ children }) => {
}
}, [isLogged, isLoginInProcess]);
if (firstRender) {
return <LinearProgress style={{ width: '100%' }} />;
} else if (!isLogged && !isLoginInProcess) {
const { authenticated: authRoute } = findRouteByPathname(pathname);
// PENDING TO AUTHENTICATING OR FIRST RENDERING
if (firstRender || (isLogged && authRoute && !authUser && !groups?.length)) {
return <LoadingScreen />;
}
// PROTECTED ROUTE
if (authRoute && !isLogged && !isLoginInProcess) {
console.log('protected route needs redirect to LOGIN');
return <Redirect to={PATH.LOGIN} />;
}
return <Fragment>{children}</Fragment>;
// PUBLIC ROUTE
if (!authRoute && isLogged && !isLoginInProcess) {
console.log('public route needs redirect to DASHBOARD');
return <Redirect to={PATH.DASHBOARD} />;
}
return (
<>
{authRoute && isLogged && <Sidebar />}
{children}
</>
);
};
AuthLayout.propTypes = {
MainLayout.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
@ -48,8 +76,8 @@ AuthLayout.propTypes = {
])
};
AuthLayout.defaultProps = {
MainLayout.defaultProps = {
children: ''
};
export default AuthLayout;
export default MainLayout;

View File

@ -13,20 +13,18 @@
/* limitations under the License. */
/* -------------------------------------------------------------------------- */
import GuessLayout from './GuessLayout';
import AuthLayout from './AuthLayout';
import InternalLayout from './InternalLayout';
import InternalLayout from 'client/components/HOC/InternalLayout';
import MainLayout from 'client/components/HOC/MainLayout';
import {
TranslateContext,
TranslateProvider,
Translate,
Tr
} from './Translate';
} from 'client/components/HOC/Translate';
export {
GuessLayout,
AuthLayout,
InternalLayout,
MainLayout,
TranslateContext,
TranslateProvider,
Translate,

View File

@ -23,7 +23,8 @@ import {
MenuItem,
MenuList,
ClickAwayListener,
Divider
Divider,
useMediaQuery
} from '@material-ui/core';
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
@ -36,6 +37,8 @@ import FilterPoolSelect from 'client/components/Header/FilterPoolSelect';
const User = React.memo(() => {
const history = useHistory();
const { logout, authUser, isOneAdmin } = useAuth();
const isUpSm = useMediaQuery(theme => theme.breakpoints.up('sm'));
const [open, setOpen] = useState(false);
const anchorRef = useRef(null);
const { current } = anchorRef;
@ -63,7 +66,7 @@ const User = React.memo(() => {
data-cy="header-user-button"
>
<AccountCircleIcon />
<span style={{ paddingLeft: 5 }}>{authUser?.NAME}</span>
{isUpSm && <span style={{ paddingLeft: 5 }}>{authUser?.NAME}</span>}
</Button>
<Popper
open={open}

View File

@ -16,7 +16,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import { AppBar, Toolbar, IconButton, Typography } from '@material-ui/core';
import {
AppBar,
Toolbar,
Typography,
IconButton,
useMediaQuery
} from '@material-ui/core';
import MenuIcon from '@material-ui/icons/Menu';
import useGeneral from 'client/hooks/useGeneral';
@ -25,20 +31,23 @@ import Zone from 'client/components/Header/Zone';
import headerStyles from 'client/components/Header/styles';
const Header = ({ title }) => {
const { fixMenu } = useGeneral();
const classes = headerStyles();
const { isOpenMenu, openMenu } = useGeneral();
const isUpLg = useMediaQuery(theme => theme.breakpoints.up('lg'));
return React.useMemo(
() => (
<AppBar position="fixed" data-cy="header">
<AppBar position="absolute" data-cy="header">
<Toolbar>
<IconButton
onClick={() => openMenu(!isOpenMenu)}
edge="start"
color="inherit"
>
<MenuIcon />
</IconButton>
{!isUpLg && (
<IconButton
onClick={() => fixMenu(true)}
edge="start"
color="inherit"
>
<MenuIcon />
</IconButton>
)}
<Typography
variant="h6"
className={classes.title}
@ -51,7 +60,7 @@ const Header = ({ title }) => {
</Toolbar>
</AppBar>
),
[isOpenMenu, openMenu]
[fixMenu, isUpLg]
);
};

View File

@ -0,0 +1,34 @@
import React from 'react';
import { styled } from '@material-ui/core';
import Logo from 'client/icons/logo';
const ScreenBox = styled('div')({
width: '100%',
height: '100vh',
backgroundColor: '#ffffff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'fixed',
zIndex: 10000
});
const LoadingScreen = () => (
<ScreenBox
style={{
width: '100%',
height: '100vh',
backgroundColor: '#ffffff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'fixed',
zIndex: 10000
}}
>
<Logo width={360} height={360} spinner withText />
</ScreenBox>
);
export default LoadingScreen;

View File

@ -1,22 +1,48 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import clsx from 'clsx';
import { List, Collapse, ListItem, ListItemText } from '@material-ui/core';
import {
List,
Collapse,
ListItem,
ListItemText,
ListItemIcon,
useMediaQuery
} from '@material-ui/core';
import ExpandLessIcon from '@material-ui/icons/ExpandLess';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import useGeneral from 'client/hooks/useGeneral';
import SidebarLink from 'client/components/Sidebar/SidebarLink';
import sidebarStyles from 'client/components/Sidebar/styles';
const SidebarCollapseItem = ({ label, routes }) => {
const SidebarCollapseItem = ({ label, routes, icon: Icon }) => {
const classes = sidebarStyles();
const { isFixMenu } = useGeneral();
const [expanded, setExpanded] = useState(false);
const isUpLg = useMediaQuery(theme => theme.breakpoints.up('lg'));
const handleExpand = () => setExpanded(!expanded);
return (
<>
<ListItem button onClick={handleExpand}>
{Icon && (
<ListItemIcon>
<Icon />
</ListItemIcon>
)}
<ListItemText primary={label} />
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
{expanded ? (
<ExpandLessIcon
className={clsx({ [classes.expandIcon]: isUpLg && !isFixMenu })}
/>
) : (
<ExpandMoreIcon
className={clsx({ [classes.expandIcon]: isUpLg && !isFixMenu })}
/>
)}
</ListItem>
{routes?.map((subItem, index) => (
<Collapse
@ -24,9 +50,10 @@ const SidebarCollapseItem = ({ label, routes }) => {
in={expanded}
timeout="auto"
unmountOnExit
className={clsx({ [classes.subItemWrapper]: isUpLg && !isFixMenu })}
>
<List component="div" disablePadding>
<SidebarLink {...subItem} />
<SidebarLink {...subItem} isSubItem />
</List>
</Collapse>
))}
@ -36,6 +63,7 @@ const SidebarCollapseItem = ({ label, routes }) => {
SidebarCollapseItem.propTypes = {
label: PropTypes.string.isRequired,
icon: PropTypes.node,
routes: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string,
@ -46,6 +74,7 @@ SidebarCollapseItem.propTypes = {
SidebarCollapseItem.defaultProps = {
label: '',
icon: null,
routes: []
};

View File

@ -1,17 +1,20 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import { useHistory, useLocation } from 'react-router-dom';
import clsx from 'clsx';
import {
withStyles,
Badge,
Typography,
ListItem,
ListItemIcon,
ListItemText,
useMediaQuery
} from '@material-ui/core';
import useGeneral from 'client/hooks/useGeneral';
import sidebarStyles from 'client/components/Sidebar/styles';
const StyledBadge = withStyles(() => ({
badge: {
@ -21,18 +24,33 @@ const StyledBadge = withStyles(() => ({
}
}))(Badge);
const SidebarLink = ({ label, path, devMode }) => {
const SidebarLink = ({ label, path, icon: Icon, devMode, isSubItem }) => {
const classes = sidebarStyles();
const history = useHistory();
const isDesktop = useMediaQuery(theme => theme.breakpoints.up('sm'));
const { openMenu } = useGeneral();
const { pathname } = useLocation();
const { fixMenu } = useGeneral();
const isUpLg = useMediaQuery(theme => theme.breakpoints.up('lg'));
const handleClick = () => {
history.push(path);
!isDesktop && openMenu(false);
!isUpLg && fixMenu(false);
};
const isCurrentPathname = pathname === path;
return (
<ListItem button onClick={handleClick}>
<ListItem
button
onClick={handleClick}
selected={isCurrentPathname}
className={clsx({ [classes.subItem]: isSubItem })}
classes={{ selected: classes.itemSelected }}
>
{Icon && (
<ListItemIcon>
<Icon />
</ListItemIcon>
)}
<ListItemText
primary={
devMode ? (
@ -51,13 +69,17 @@ const SidebarLink = ({ label, path, devMode }) => {
SidebarLink.propTypes = {
label: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
devMode: PropTypes.bool
icon: PropTypes.node,
devMode: PropTypes.bool,
isSubItem: PropTypes.bool
};
SidebarLink.defaultProps = {
label: '',
path: '/',
devMode: false
icon: null,
devMode: false,
isSubItem: false
};
export default SidebarLink;

View File

@ -14,8 +14,16 @@
/* -------------------------------------------------------------------------- */
import React from 'react';
import { List, Drawer, Divider, Box } from '@material-ui/core';
import clsx from 'clsx';
import {
List,
Drawer,
Divider,
Box,
IconButton,
useMediaQuery
} from '@material-ui/core';
import { Menu as MenuIcon, Close as CloseIcon } from '@material-ui/icons';
import useGeneral from 'client/hooks/useGeneral';
import endpoints from 'client/router/endpoints';
@ -23,12 +31,11 @@ import endpoints from 'client/router/endpoints';
import sidebarStyles from 'client/components/Sidebar/styles';
import SidebarLink from 'client/components/Sidebar/SidebarLink';
import SidebarCollapseItem from 'client/components/Sidebar/SidebarCollapseItem';
import Logo from 'client/icons/logo';
const Endpoints = React.memo(() =>
endpoints
?.filter(
({ authenticated = true, header = false }) => authenticated && !header
)
?.filter(({ authenticated, header = false }) => authenticated && !header)
?.map((endpoint, index) =>
endpoint.routes ? (
<SidebarCollapseItem key={`item-${index}`} {...endpoint} />
@ -40,27 +47,46 @@ const Endpoints = React.memo(() =>
const Sidebar = () => {
const classes = sidebarStyles();
const { isOpenMenu, openMenu } = useGeneral();
const { isFixMenu, fixMenu } = useGeneral();
const isUpLg = useMediaQuery(theme => theme.breakpoints.up('lg'));
return React.useMemo(
() => (
<Drawer anchor="left" open={isOpenMenu} onClose={() => openMenu(false)}>
<Box item className={classes.logo}>
<img
className={classes.img}
src="/static/logo.png"
alt="Opennebula"
<Drawer
variant={'permanent'}
className={clsx({ [classes.drawerFixed]: isFixMenu })}
classes={{
paper: clsx(classes.drawerPaper, {
[classes.drawerFixed]: isFixMenu
})
}}
anchor="left"
open={isFixMenu}
>
<Box item className={classes.header}>
<Logo
width="100%"
height={100}
withText
viewBox="0 0 640 640"
className={classes.svg}
/>
<IconButton
color={isFixMenu ? 'primary' : 'default'}
onClick={() => fixMenu(!isFixMenu)}
>
{isUpLg ? <MenuIcon /> : <CloseIcon />}
</IconButton>
</Box>
<Divider />
<Box className={classes.menu}>
<List className={classes.list}>
<List className={classes.list} disablePadding>
<Endpoints />
</List>
</Box>
</Drawer>
),
[isOpenMenu, openMenu]
[isFixMenu, fixMenu, isUpLg]
);
};

View File

@ -1,8 +1,85 @@
import { makeStyles } from '@material-ui/core';
import { sidebarWidth } from 'client/assets/theme';
export default makeStyles(theme => ({
// -------------------------------
// CONTAINER MENU
// -------------------------------
drawerPaper: {
width: 0,
whiteSpace: 'nowrap',
overflowX: 'hidden',
flexShrink: 0,
transition: theme.transitions.create(['width', 'visibility', 'display'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen
}),
[theme.breakpoints.up('lg')]: {
width: sidebarWidth.minified,
// CONTAINER ONLY WHEN EXPANDED
'&:hover': {
width: sidebarWidth.fixed,
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen
}),
'& #logo__text__top, & #logo__text__bottom': {
visibility: 'visible'
}
},
// CONTAINER ONLY WHEN MINIFIED
'&:not(:hover)': {
'& #logo__text__top, & #logo__text__bottom': {
visibility: 'hidden'
},
'& $menu': {
overflowY: 'hidden'
},
'& $expandIcon, & $subItemWrapper': {
display: 'none'
}
}
}
},
drawerFixed: {
width: '100%',
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen
}),
[theme.breakpoints.only('md')]: {
width: sidebarWidth.fixed
},
[theme.breakpoints.up('lg')]: {
width: sidebarWidth.fixed,
'& #logo__text__top, & #logo__text__bottom': {
visibility: 'visible !important'
},
'& $expandIcon, & $subItemWrapper': {
display: 'block !important'
}
}
},
// -------------------------------
// HEADER MENU
// -------------------------------
header: {
display: 'flex',
alignItems: 'center',
padding: '1rem',
overflow: 'hidden',
height: 64, // appbar height
minHeight: 64 // appbar height
},
svg: {
minWidth: 100
},
// -------------------------------
// LIST MENU
// -------------------------------
menu: {
overflow: 'auto',
overflowY: 'auto',
overflowX: 'hidden',
textTransform: 'capitalize',
color: 'transparent',
transition: 'color 0.3s',
@ -30,10 +107,15 @@ export default makeStyles(theme => ({
list: {
color: theme.palette.common.black
},
logo: {
padding: '1rem 2rem'
expandIcon: {},
subItemWrapper: {},
subItem: {
paddingLeft: theme.spacing(4)
},
img: {
width: '100%'
itemSelected: {
backgroundColor: theme.palette.primary.light,
'&:hover': {
backgroundColor: theme.palette.primary.light
}
}
}));

View File

@ -18,6 +18,7 @@ module.exports = {
classInputInvalid: 'is-invalid',
NotFound: 'Not found',
SignIn: 'Sign In',
Next: 'Next',
Language: 'Language',
Username: 'Username',
Password: 'Password',
@ -26,10 +27,10 @@ module.exports = {
SignOut: 'Sign Out',
jwtName: 'SunstoneToken',
Submit: 'Submit',
Respose: 'Response',
Response: 'Response',
by: {
text: 'Opennebula Systems',
url: 'https://opennebula.org/'
text: 'Opennebula',
url: 'https://opennebula.io/'
},
endpointsRoutes: {
login: '/api/auth/',

View File

@ -1,16 +1,20 @@
import React from 'react';
import { func, string } from 'prop-types';
import { Box, Button, TextField } from '@material-ui/core';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers';
import * as yup from 'yup';
import { Token2FA } from 'client/constants';
import { Token2FA, Next } from 'client/constants';
import loginStyles from 'client/containers/Login/styles';
import { Tr } from 'client/components/HOC';
import ButtonSubmit from 'client/components/FormControl/SubmitButton';
import ErrorHelper from 'client/components/FormControl/ErrorHelper';
const Form2fa = ({ classes, onBack, onSubmit, error }) => {
const Form2fa = ({ onBack, onSubmit, error }) => {
const classes = loginStyles();
const { register, handleSubmit, errors } = useForm({
reValidateMode: 'onSubmit',
resolver: yupResolver(
@ -25,7 +29,7 @@ const Form2fa = ({ classes, onBack, onSubmit, error }) => {
return (
<Box
component="form"
className={classes?.form}
className={classes.form}
onSubmit={handleSubmit(onSubmit)}
>
<TextField
@ -50,11 +54,23 @@ const Form2fa = ({ classes, onBack, onSubmit, error }) => {
<ButtonSubmit
data-cy="login-2fa-button"
isSubmitting={false}
label="Next"
label={Tr(Next)}
/>
</Box>
</Box>
);
};
Form2fa.propTypes = {
onBack: func.isRequired,
onSubmit: func.isRequired,
error: string
};
Form2fa.defaultProps = {
onBack: () => undefined,
onSubmit: () => undefined,
error: null
};
export default Form2fa;

View File

@ -14,17 +14,21 @@
/* -------------------------------------------------------------------------- */
import React from 'react';
import { func, string } from 'prop-types';
import { Box, Checkbox, TextField, FormControlLabel } from '@material-ui/core';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers';
import * as yup from 'yup';
import { SignIn, Username, Password, keepLoggedIn } from 'client/constants';
import { Translate, Tr } from 'client/components/HOC';
import { Tr } from 'client/components/HOC';
import ButtonSubmit from 'client/components/FormControl/SubmitButton';
import ErrorHelper from 'client/components/FormControl/ErrorHelper';
import loginStyles from 'client/containers/Login/styles';
function FormUser({ onSubmit, error }) {
const classes = loginStyles();
function FormUser({ classes, onSubmit, error }) {
const { register, handleSubmit, errors } = useForm({
reValidateMode: 'onSubmit',
resolver: yupResolver(
@ -42,7 +46,7 @@ function FormUser({ classes, onSubmit, error }) {
return (
<Box
component="form"
className={classes?.form}
className={classes.form}
onSubmit={handleSubmit(onSubmit)}
>
<TextField
@ -50,6 +54,7 @@ function FormUser({ classes, onSubmit, error }) {
fullWidth
required
name="user"
autoComplete="username"
label={Tr(Username)}
variant="outlined"
inputRef={register}
@ -90,14 +95,20 @@ function FormUser({ classes, onSubmit, error }) {
<ButtonSubmit
data-cy="login-button"
isSubmitting={false}
label={<Translate word={SignIn} />}
label={Tr(SignIn)}
/>
</Box>
);
}
FormUser.propTypes = {};
FormUser.propTypes = {
onSubmit: func.isRequired,
error: string
};
FormUser.defaultProps = {};
FormUser.defaultProps = {
onSubmit: () => undefined,
error: null
};
export default FormUser;

View File

@ -84,11 +84,7 @@ function Login() {
unmountOnExit
>
<Box style={{ opacity: isLoading ? 0.7 : 1 }}>
<FormUser
classes={classes}
onSubmit={handleSubmitUser}
error={error}
/>
<FormUser onSubmit={handleSubmitUser} error={error} />
</Box>
</Slide>
</Box>
@ -102,7 +98,6 @@ function Login() {
>
<Box style={{ opacity: isLoading ? 0.7 : 1 }}>
<Form2fa
classes={classes}
onBack={handleBack}
onSubmit={handleSubmitUser}
error={error}
@ -121,7 +116,6 @@ function Login() {
<Box style={{ opacity: isLoading ? 0.7 : 1 }}>
<FormGroup
groups={groups}
classes={classes}
onBack={handleBack}
onSubmit={handleSubmitGroup}
/>

View File

@ -3,6 +3,7 @@ import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import { jwtName, FILTER_POOL, ONEADMIN_ID } from 'client/constants';
import { storage, findStorageData, removeStoreData } from 'client/utils';
import { fakeDelay } from 'client/utils/helpers';
import * as serviceAuth from 'client/services/auth';
import * as serviceUsers from 'client/services/users';
@ -16,10 +17,6 @@ import {
} from 'client/actions/user';
import { setGroups } from 'client/actions/pool';
// function delay(ms) {
// return new Promise(resolve => setTimeout(resolve, ms));
// }
export default function useAuth() {
const {
jwt,
@ -36,8 +33,9 @@ export default function useAuth() {
useEffect(() => {
const tokenStorage = findStorageData(jwtName);
if ((tokenStorage && jwt && tokenStorage !== jwt) || firstRender)
dispatch(successAuth({ jwt: tokenStorage }));
if ((tokenStorage && jwt && tokenStorage !== jwt) || firstRender) {
fakeDelay(1500).then(() => dispatch(successAuth({ jwt: tokenStorage })));
}
}, [jwt, firstRender]);
const login = useCallback(
@ -89,7 +87,7 @@ export default function useAuth() {
})
)
.catch(err => dispatch(failureAuth({ error: err })));
}, [dispatch, baseURL, jwtName]);
}, [dispatch, baseURL, jwtName, authUser]);
const setPrimaryGroup = useCallback(
values => {

View File

@ -4,12 +4,16 @@ import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import * as actions from 'client/actions/general';
export default function useGeneral() {
const { isLoading, isOpenMenu } = useSelector(
const { isLoading, isOpenMenu, isFixMenu } = useSelector(
state => state?.General,
shallowEqual
);
const dispatch = useDispatch();
const fixMenu = useCallback(isFixed => dispatch(actions.fixMenu(isFixed)), [
dispatch
]);
const openMenu = useCallback(isOpen => dispatch(actions.openMenu(isOpen)), [
dispatch
]);
@ -26,8 +30,10 @@ export default function useGeneral() {
return {
isLoading,
isOpenMenu,
isFixMenu,
changeZone,
openMenu,
fixMenu,
changeLoading
};
}

View File

@ -0,0 +1,98 @@
import React from 'react';
import { number, string, bool } from 'prop-types';
const Logo = ({ width, height, spinner, withText, viewBox, ...props }) => {
const cloudColor = {
child1: { from: '#0098c3', to: '#ffffff' },
child2: { from: '#0098c3', to: '#ffffff' },
child3: { from: '#40b3d9', to: '#ffffff' },
child4: { from: '#80cde6', to: '#ffffff' },
child5: { from: '#bfe6f2', to: '#ffffff' }
};
const textColor = {
top: '#000000',
bottom: '#0098c3'
};
return (
<svg viewBox={viewBox} width={width} height={height} {...props}>
<defs>
{spinner &&
Object.entries(cloudColor)?.map(([key, color]) => (
<linearGradient id={`gradient__${key}`} x1="0%" x2="200%">
<stop offset="0%" stopColor={color.from} />
<stop offset="200%" stopColor={color.to} />
<animate
attributeName="x2"
dur="2000ms"
repeatCount="indefinite"
values="10%; 100%; 200%"
/>
</linearGradient>
))}
</defs>
<path
fill={spinner ? 'url(#gradient__child1)' : cloudColor.child1.from}
d="M207.7 233.57L196.2 245.35L184.29 256.66L171.97 267.48L159.27 277.8L146.19 287.6L132.74 296.88L118.94 305.62L104.81 313.79L90.36 321.4L75.59 328.41L60.54 334.83L45.19 340.63L29.58 345.81L13.72 350.34L12.05 350.74L12.23 350.33L13.27 348.21L14.39 346.15L15.58 344.13L16.85 342.16L18.19 340.25L19.61 338.39L21.09 336.58L22.63 334.84L24.24 333.16L25.92 331.54L27.65 329.99L29.44 328.5L31.29 327.08L33.2 325.73L35.16 324.46L37.16 323.26L39.22 322.13L41.33 321.09L42.05 320.76L41.6 319.67L40.7 317.18L39.89 314.65L39.18 312.08L38.58 309.47L38.08 306.82L37.69 304.14L37.41 301.42L37.23 298.68L37.18 295.9L37.23 293.13L37.41 290.38L37.69 287.67L38.08 284.99L38.58 282.34L39.18 279.73L39.89 277.16L40.7 274.63L41.6 272.14L42.6 269.7L43.7 267.31L44.88 264.96L46.16 262.67L47.52 260.44L48.97 258.26L50.5 256.14L52.11 254.08L53.8 252.08L55.56 250.15L57.4 248.29L59.31 246.5L61.29 244.77L63.33 243.13L65.44 241.55L67.62 240.06L69.85 238.65L72.14 237.32L74.49 236.08L76.89 234.92L79.34 233.85L81.84 232.87L84.39 231.99L86.98 231.2L89.62 230.51L92.29 229.92L95.01 229.44L97.75 229.05L100.54 228.78L103.35 228.61L106.19 228.55L109.04 228.61L111.85 228.78L114.63 229.05L117.38 229.44L120.1 229.92L122.77 230.51L125.41 231.2L128 231.99L130.55 232.87L133.05 233.85L135.5 234.92L137.9 236.08L140.25 237.32L141.66 238.14L143.92 234L146.4 229.83L149.04 225.77L151.84 221.82L154.78 217.99L157.86 214.27L161.08 210.67L164.43 207.2L167.92 203.86L171.53 200.65L175.26 197.58L179.11 194.66L183.08 191.88L187.15 189.24L191.33 186.77L195.62 184.45L200 182.29L204.47 180.3L208.36 178.75L208.36 232.84L207.7 233.57Z"
/>
<path
fill={spinner ? 'url(#gradient__child2)' : cloudColor.child2.from}
d="M198.85 304.87L182.93 314.47L166.61 323.51L149.89 331.98L132.78 339.85L115.31 347.11L97.49 353.75L79.33 359.76L60.86 365.12L42.08 369.8L23.02 373.81L4.8 376.94L4.78 376.15L4.83 373.65L4.98 371.17L5.23 368.71L5.57 366.29L6.01 363.9L6.16 363.22L20.47 359.78L36.34 355.25L51.95 350.08L67.29 344.28L82.35 337.86L97.12 330.84L111.57 323.24L125.7 315.06L139.5 306.33L152.94 297.05L166.03 287.25L178.73 276.92L191.05 266.1L202.96 254.79L206.62 251.05L206.62 299.77L198.85 304.87Z"
/>
<path
fill={spinner ? 'url(#gradient__child3)' : cloudColor.child3.from}
d="M184.12 353.2L156.39 362.67L128.1 371.33L99.29 379.16L69.96 386.13L40.16 392.25L9.9 397.47L8.67 397.64L8.66 397.61L7.87 395.36L7.16 393.08L6.54 390.76L6.01 388.4L5.57 386.01L5.49 385.45L10.39 384.67L30.75 380.67L50.81 376L70.54 370.66L89.94 364.68L108.98 358.05L127.64 350.81L145.91 342.97L163.78 334.53L181.22 325.52L198.22 315.95L206.62 310.81L206.62 344.7L184.12 353.2Z"
/>
<path
fill={spinner ? 'url(#gradient__child4)' : cloudColor.child4.from}
d="M187.35 393.66L157.55 399.77L127.29 405L96.6 409.32L65.51 412.71L34.04 415.15L19.8 415.82L19.71 415.72L18.23 413.91L16.82 412.05L15.47 410.14L14.21 408.17L13.01 406.15L12.48 405.17L22.87 404.25L43.8 401.66L64.45 398.36L84.82 394.36L104.87 389.69L124.61 384.35L144.01 378.36L163.04 371.74L181.71 364.5L199.98 356.66L206.98 353.35L206.98 388.99L187.35 393.66Z"
/>
<path
fill={spinner ? 'url(#gradient__child5)' : cloudColor.child5.from}
d="M65.28 436.96L65.28 436.96L62.79 436.91L60.32 436.76L57.88 436.51L55.47 436.16L53.09 435.72L50.75 435.19L48.44 434.57L46.17 433.86L43.93 433.06L41.74 432.18L39.59 431.21L37.49 430.17L35.43 429.04L33.42 427.84L31.46 426.57L29.56 425.22L27.71 423.8L25.92 422.31L25.7 422.12L43.1 421.31L74.57 418.87L105.66 415.48L136.35 411.16L166.61 405.93L196.41 399.82L206.62 397.39L206.62 436.96L65.28 436.96Z"
/>
{withText && (
<>
<text
id="logo__text__top"
x={230}
y={280}
fill={textColor.top}
fontSize={125}
fontFamily="Ubuntu,Lato"
fontWeight={500}
>
{'Open'}
</text>
<text
id="logo__text__bottom"
x={230}
y={430}
fill={textColor.bottom}
fontSize={125}
fontFamily="Ubuntu,Lato"
fontWeight={500}
>
{'Nebula'}
</text>
</>
)}
</svg>
);
};
Logo.propTypes = {
width: number.isRequired,
height: number.isRequired,
viewBox: string,
spinner: bool,
withText: bool
};
Logo.defaultProps = {
width: 360,
height: 360,
viewBox: '0 0 640 640',
spinner: false,
withText: false
};
export default Logo;

View File

@ -20,7 +20,8 @@ const { Actions: GeneralActions } = require('../actions/general');
const initial = {
zone: 0,
isLoading: false,
isOpenMenu: false
isOpenMenu: false,
isFixMenu: false
};
const General = (state = initial, action) => {
@ -35,6 +36,8 @@ const General = (state = initial, action) => {
return { ...state, ...action.payload };
case GeneralActions.TOGGLE_MENU:
return { ...state, isOpenMenu: action.isOpen };
case GeneralActions.FIX_MENU:
return { ...state, isFixMenu: action.isFixed };
case UserActions.LOGOUT:
return { ...initial };
default:

View File

@ -13,6 +13,12 @@
/* limitations under the License. */
/* -------------------------------------------------------------------------- */
import {
Dashboard as DashboardIcon,
Settings as SettingsIcon,
Ballot as BallotIcon
} from '@material-ui/icons';
import Login from 'client/containers/Login';
import { Clusters, Hosts, Zones } from 'client/containers/Infrastructure';
import { Users, Groups } from 'client/containers/System';
@ -42,7 +48,7 @@ export const PATH = {
}
};
export default [
const ENDPOINTS = [
{
label: 'login',
path: PATH.LOGIN,
@ -52,74 +58,102 @@ export default [
{
label: 'dashboard',
path: PATH.DASHBOARD,
authenticated: true,
icon: DashboardIcon,
component: Dashboard
},
{
label: 'settings',
path: PATH.SETTINGS,
authenticated: true,
header: true,
icon: SettingsIcon,
component: Settings
},
{
label: 'test api',
path: PATH.TEST_API,
authenticated: true,
devMode: true,
icon: BallotIcon,
component: TestApi
},
{
label: 'infrastructure',
authenticated: true,
icon: BallotIcon,
routes: [
{
label: 'clusters',
path: PATH.INFRASTRUCTURE.CLUSTERS,
authenticated: true,
component: Clusters
},
{
label: 'hosts',
path: PATH.INFRASTRUCTURE.HOSTS,
authenticated: true,
component: Hosts
},
{
label: 'zones',
path: PATH.INFRASTRUCTURE.ZONES,
authenticated: true,
component: Zones
}
]
},
{
label: 'system',
authenticated: true,
icon: BallotIcon,
routes: [
{
label: 'users',
path: PATH.SYSTEM.USERS,
authenticated: true,
component: Users
},
{
label: 'groups',
path: PATH.SYSTEM.GROUPS,
authenticated: true,
component: Groups
}
]
},
{
label: 'networks',
authenticated: true,
icon: BallotIcon,
routes: [
{
label: 'vnets',
path: PATH.NETWORKS.VNETS
path: PATH.NETWORKS.VNETS,
authenticated: true
},
{
label: 'vnets templates',
path: PATH.NETWORKS.VNETS_TEMPLATES
path: PATH.NETWORKS.VNETS_TEMPLATES,
authenticated: true
},
{
label: 'vnets topology',
path: PATH.NETWORKS.VNETS_TOPOLOGY
path: PATH.NETWORKS.VNETS_TOPOLOGY,
authenticated: true
},
{
label: 'vnets secgroup',
path: PATH.NETWORKS.SEC_GROUPS
path: PATH.NETWORKS.SEC_GROUPS,
authenticated: true
}
]
}
];
export const findRouteByPathname = pathname =>
ENDPOINTS.flatMap(({ routes, ...item }) => routes ?? item)?.find(
({ path }) => path === pathname
) ?? {};
export default ENDPOINTS;

View File

@ -16,10 +16,8 @@
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import { AuthLayout, GuessLayout, InternalLayout } from 'client/components/HOC';
import { InternalLayout, MainLayout } from 'client/components/HOC';
import Error404 from 'client/containers/Error404';
import Sidebar from 'client/components/Sidebar';
import endpoints from 'client/router/endpoints';
const renderRoute = ({
@ -32,33 +30,23 @@ const renderRoute = ({
key={`key-${label.replace(' ', '-')}`}
exact
path={path}
component={() =>
authenticated ? (
<AuthLayout>
<InternalLayout title={label}>
<Component />
</InternalLayout>
</AuthLayout>
) : (
<GuessLayout>
<Component />
</GuessLayout>
)
}
component={() => (
<InternalLayout label={label} authRoute={authenticated}>
<Component />
</InternalLayout>
)}
/>
);
const Router = () => (
<>
<Sidebar />
<MainLayout>
<Switch>
{endpoints?.map(({ routes, ...endpoint }) =>
endpoint.path ? renderRoute(endpoint) : routes?.map(renderRoute)
)}
<Route component={Error404} />
</Switch>
</>
</MainLayout>
);
export default Router;
export { endpoints };

View File

@ -0,0 +1 @@
export const fakeDelay = ms => new Promise(resolve => setTimeout(resolve, ms));