mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2026-01-28 09:39:11 +03:00
Merge branch 'dev'
This commit is contained in:
@@ -1,26 +1,22 @@
|
||||
import type { FC } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { Box } from '@mui/material';
|
||||
import type { BoxProps } from '@mui/material';
|
||||
|
||||
const ButtonRow: FC<BoxProps> = ({ children, ...rest }) => (
|
||||
const ButtonRow = memo<BoxProps>(({ children, ...rest }) => (
|
||||
<Box
|
||||
sx={{
|
||||
'& button, & a, & .MuiCard-root': {
|
||||
mt: 2,
|
||||
mx: 0.6,
|
||||
'&:last-child': {
|
||||
mr: 0
|
||||
},
|
||||
'&:first-of-type': {
|
||||
ml: 0
|
||||
}
|
||||
'&:last-child': { mr: 0 },
|
||||
'&:first-of-type': { ml: 0 }
|
||||
}
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
));
|
||||
|
||||
export default ButtonRow;
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import { Tooltip, type TooltipProps, styled, tooltipClasses } from '@mui/material';
|
||||
import { Tooltip, type TooltipProps } from '@mui/material';
|
||||
|
||||
export const ButtonTooltip = styled(({ className, ...props }: TooltipProps) => (
|
||||
<Tooltip {...props} placement="top" arrow classes={{ popper: className }} />
|
||||
))(({ theme }) => ({
|
||||
[`& .${tooltipClasses.arrow}`]: {
|
||||
color: theme.palette.success.main
|
||||
},
|
||||
[`& .${tooltipClasses.tooltip}`]: {
|
||||
backgroundColor: theme.palette.success.main,
|
||||
color: 'rgba(0, 0, 0, 0.87)',
|
||||
boxShadow: theme.shadows[1],
|
||||
fontSize: 10
|
||||
}
|
||||
}));
|
||||
export const ButtonTooltip = ({ children, ...props }: TooltipProps) => (
|
||||
<Tooltip {...props}>{children}</Tooltip>
|
||||
);
|
||||
|
||||
export default ButtonTooltip;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { FC } from 'react';
|
||||
import { type FC, memo, useMemo } from 'react';
|
||||
|
||||
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined';
|
||||
import { Box, Typography, useTheme } from '@mui/material';
|
||||
import type { BoxProps, SvgIconProps, Theme } from '@mui/material';
|
||||
import type { BoxProps, SvgIconProps } from '@mui/material';
|
||||
|
||||
type MessageBoxLevel = 'warning' | 'success' | 'info' | 'error';
|
||||
|
||||
@@ -14,22 +14,18 @@ export interface MessageBoxProps extends BoxProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const LEVEL_ICONS: {
|
||||
[type in MessageBoxLevel]: React.ComponentType<SvgIconProps>;
|
||||
} = {
|
||||
const LEVEL_ICONS: Record<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 LEVEL_PALETTE_PATHS: Record<MessageBoxLevel, string> = {
|
||||
success: 'success.dark',
|
||||
info: 'info.main',
|
||||
warning: 'warning.dark',
|
||||
error: 'error.dark'
|
||||
};
|
||||
|
||||
const MessageBox: FC<MessageBoxProps> = ({
|
||||
@@ -40,25 +36,38 @@ const MessageBox: FC<MessageBoxProps> = ({
|
||||
...rest
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const Icon = LEVEL_ICONS[level];
|
||||
const backgroundColor = LEVEL_BACKGROUNDS[level](theme);
|
||||
const color = 'white';
|
||||
|
||||
const { Icon, backgroundColor } = useMemo(() => {
|
||||
const Icon = LEVEL_ICONS[level];
|
||||
const palettePath = LEVEL_PALETTE_PATHS[level];
|
||||
const [key, shade] = palettePath.split('.') as [
|
||||
keyof typeof theme.palette,
|
||||
string
|
||||
];
|
||||
const paletteKey = theme.palette[key] as unknown as Record<string, string>;
|
||||
const backgroundColor = paletteKey[shade];
|
||||
|
||||
return { Icon, backgroundColor };
|
||||
}, [level, theme]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={2}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
borderRadius={1}
|
||||
sx={{ backgroundColor, color, ...sx }}
|
||||
sx={{ backgroundColor, color: 'white', ...sx }}
|
||||
{...rest}
|
||||
>
|
||||
<Icon />
|
||||
<Typography sx={{ ml: 2 }} variant="body1">
|
||||
{message ?? ''}
|
||||
</Typography>
|
||||
{children}
|
||||
{(message || children) && (
|
||||
<Typography sx={{ ml: 2 }} variant="body1">
|
||||
{message}
|
||||
{children}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageBox;
|
||||
export default memo(MessageBox);
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
import { memo } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { Divider, Paper } from '@mui/material';
|
||||
import { Paper } from '@mui/material';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
|
||||
import type { RequiredChildrenProps } from 'utils';
|
||||
|
||||
interface SectionContentProps extends RequiredChildrenProps {
|
||||
title?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const SectionContent: FC<SectionContentProps> = (props) => {
|
||||
const { children, title, id } = props;
|
||||
return (
|
||||
<Paper id={id} sx={{ p: 2, m: 2 }}>
|
||||
{title && (
|
||||
<Divider
|
||||
sx={{
|
||||
pb: 2,
|
||||
borderColor: 'primary.main',
|
||||
fontSize: 20,
|
||||
color: 'primary.main'
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Divider>
|
||||
)}
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
// Extract styles to avoid recreation on every render
|
||||
const paperStyles: SxProps<Theme> = {
|
||||
p: 1.5,
|
||||
m: 1.5,
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgb(65, 65, 65)'
|
||||
};
|
||||
|
||||
export default SectionContent;
|
||||
const SectionContent: FC<SectionContentProps> = ({ children, id }) => (
|
||||
<Paper id={id} sx={paperStyles}>
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
|
||||
// Memoize to prevent unnecessary re-renders
|
||||
export default memo(SectionContent);
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
// use direct exports to reduce bundle size
|
||||
export { default as SectionContent } from './SectionContent';
|
||||
export { default as ButtonRow } from './ButtonRow';
|
||||
export { default as MessageBox } from './MessageBox';
|
||||
export { default as ButtonTooltip } from './ButtonTooltip';
|
||||
|
||||
// Re-export sub-modules
|
||||
export * from './inputs';
|
||||
export * from './layout';
|
||||
export * from './loading';
|
||||
export * from './routing';
|
||||
export * from './upload';
|
||||
export { default as SectionContent } from './SectionContent';
|
||||
export { default as ButtonRow } from './ButtonRow';
|
||||
export { default as MessageBox } from './MessageBox';
|
||||
|
||||
// Specific routing exports
|
||||
export { default as BlockNavigation } from './routing/BlockNavigation';
|
||||
export { default as ButtonTooltip } from './ButtonTooltip';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormControlLabel } from '@mui/material';
|
||||
@@ -9,4 +10,4 @@ const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
export default BlockFormControlLabel;
|
||||
export default memo(BlockFormControlLabel);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { type ChangeEventHandler, useContext } from 'react';
|
||||
import { memo, useCallback, useContext, useMemo } from 'react';
|
||||
import type { ChangeEventHandler } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { MenuItem, TextField } from '@mui/material';
|
||||
|
||||
@@ -17,73 +19,66 @@ import { I18nContext } from 'i18n/i18n-react';
|
||||
import type { Locales } from 'i18n/i18n-types';
|
||||
import { loadLocaleAsync } from 'i18n/i18n-util.async';
|
||||
|
||||
const LanguageSelector = () => {
|
||||
const { setLocale, locale } = useContext(I18nContext);
|
||||
const flagStyle: CSSProperties = { width: 16, verticalAlign: 'middle' };
|
||||
|
||||
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({
|
||||
target
|
||||
}) => {
|
||||
const loc = target.value as Locales;
|
||||
localStorage.setItem('lang', loc);
|
||||
await loadLocaleAsync(loc);
|
||||
setLocale(loc);
|
||||
};
|
||||
interface LanguageOption {
|
||||
key: Locales;
|
||||
flag: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const LANGUAGE_OPTIONS: LanguageOption[] = [
|
||||
{ key: 'cz', flag: CZflag, label: 'CZ' },
|
||||
{ key: 'de', flag: DEflag, label: 'DE' },
|
||||
{ key: 'en', flag: GBflag, label: 'EN' },
|
||||
{ key: 'fr', flag: FRflag, label: 'FR' },
|
||||
{ key: 'it', flag: ITflag, label: 'IT' },
|
||||
{ key: 'nl', flag: NLflag, label: 'NL' },
|
||||
{ key: 'no', flag: NOflag, label: 'NO' },
|
||||
{ key: 'pl', flag: PLflag, label: 'PL' },
|
||||
{ key: 'sk', flag: SKflag, label: 'SK' },
|
||||
{ key: 'sv', flag: SVflag, label: 'SV' },
|
||||
{ key: 'tr', flag: TRflag, label: 'TR' }
|
||||
];
|
||||
|
||||
const LanguageSelector = () => {
|
||||
const { setLocale, locale, LL } = useContext(I18nContext);
|
||||
|
||||
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
async ({ target }) => {
|
||||
const loc = target.value as Locales;
|
||||
localStorage.setItem('lang', loc);
|
||||
await loadLocaleAsync(loc);
|
||||
setLocale(loc);
|
||||
},
|
||||
[setLocale]
|
||||
);
|
||||
|
||||
// Memoize menu items to prevent recreation on every render
|
||||
const menuItems = useMemo(
|
||||
() =>
|
||||
LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
|
||||
<MenuItem key={key} value={key}>
|
||||
<img src={flag} style={flagStyle} alt={label} />
|
||||
{label}
|
||||
</MenuItem>
|
||||
)),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
name="locale"
|
||||
variant="outlined"
|
||||
aria-label={LL.LANGUAGE()}
|
||||
value={locale}
|
||||
onChange={onLocaleSelected}
|
||||
size="small"
|
||||
select
|
||||
>
|
||||
<MenuItem key="cz" value="cz">
|
||||
<img src={CZflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
CZ
|
||||
</MenuItem>
|
||||
<MenuItem key="de" value="de">
|
||||
<img src={DEflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
DE
|
||||
</MenuItem>
|
||||
<MenuItem key="en" value="en">
|
||||
<img src={GBflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
EN
|
||||
</MenuItem>
|
||||
<MenuItem key="fr" value="fr">
|
||||
<img src={FRflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
FR
|
||||
</MenuItem>
|
||||
<MenuItem key="it" value="it">
|
||||
<img src={ITflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
IT
|
||||
</MenuItem>
|
||||
<MenuItem key="nl" value="nl">
|
||||
<img src={NLflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
NL
|
||||
</MenuItem>
|
||||
<MenuItem key="no" value="no">
|
||||
<img src={NOflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
NO
|
||||
</MenuItem>
|
||||
<MenuItem key="pl" value="pl">
|
||||
<img src={PLflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
PL
|
||||
</MenuItem>
|
||||
<MenuItem key="sk" value="sk">
|
||||
<img src={SKflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
SK
|
||||
</MenuItem>
|
||||
<MenuItem key="sv" value="sv">
|
||||
<img src={SVflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
SV
|
||||
</MenuItem>
|
||||
<MenuItem key="tr" value="tr">
|
||||
<img src={TRflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
TR
|
||||
</MenuItem>
|
||||
{menuItems}
|
||||
</TextField>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSelector;
|
||||
export default memo(LanguageSelector);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
@@ -13,6 +13,10 @@ type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
|
||||
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => {
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||
|
||||
const togglePasswordVisibility = useCallback(() => {
|
||||
setShowPassword((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ValidatedTextField
|
||||
{...props}
|
||||
@@ -21,7 +25,11 @@ const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) =
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end">
|
||||
<IconButton
|
||||
onClick={togglePasswordVisibility}
|
||||
edge="end"
|
||||
aria-label="Password visibility"
|
||||
>
|
||||
{showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
@@ -32,4 +40,4 @@ const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) =
|
||||
);
|
||||
};
|
||||
|
||||
export default ValidatedPasswordField;
|
||||
export default memo(ValidatedPasswordField);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormHelperText, TextField } from '@mui/material';
|
||||
@@ -14,18 +15,42 @@ export type ValidatedTextFieldProps = ValidatedFieldProps & TextFieldProps;
|
||||
|
||||
const ValidatedTextField: FC<ValidatedTextFieldProps> = ({
|
||||
fieldErrors,
|
||||
sx,
|
||||
...rest
|
||||
}) => {
|
||||
const errors = fieldErrors && fieldErrors[rest.name];
|
||||
const renderErrors = () =>
|
||||
errors &&
|
||||
errors.map((e) => <FormHelperText key={e.message}>{e.message}</FormHelperText>);
|
||||
const errors = fieldErrors?.[rest.name];
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextField error={!!errors} {...rest} />
|
||||
{renderErrors()}
|
||||
<TextField
|
||||
error={!!errors}
|
||||
{...rest}
|
||||
aria-label="Error"
|
||||
sx={{
|
||||
'& .MuiInputBase-input.Mui-disabled': {
|
||||
WebkitTextFillColor: 'grey'
|
||||
},
|
||||
...(sx || {})
|
||||
}}
|
||||
{...(rest.disabled && {
|
||||
slotProps: {
|
||||
select: {
|
||||
IconComponent: () => null
|
||||
},
|
||||
inputLabel: {
|
||||
style: { color: 'grey' }
|
||||
}
|
||||
}
|
||||
})}
|
||||
color={rest.disabled ? 'secondary' : 'primary'}
|
||||
/>
|
||||
{errors?.map((e) => (
|
||||
<FormHelperText key={e.message} sx={{ color: 'rgb(250, 95, 84)' }}>
|
||||
{e.message}
|
||||
</FormHelperText>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValidatedTextField;
|
||||
export default memo(ValidatedTextField);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
@@ -13,22 +13,26 @@ import { LayoutContext } from './context';
|
||||
|
||||
export const DRAWER_WIDTH = 210;
|
||||
|
||||
const Layout: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [title, setTitle] = useState(PROJECT_NAME);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
setMobileOpen(!mobileOpen);
|
||||
};
|
||||
// Memoize drawer toggle handler to prevent unnecessary re-renders
|
||||
const handleDrawerToggle = useCallback(() => {
|
||||
setMobileOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
useEffect(() => setMobileOpen(false), [pathname]);
|
||||
// Close drawer when route changes
|
||||
useEffect(() => {
|
||||
setMobileOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// cache the object to prevent unnecessary re-renders
|
||||
const obj = useMemo(() => ({ title, setTitle }), [title]);
|
||||
// Memoize context value to prevent unnecessary re-renders
|
||||
const contextValue = useMemo(() => ({ title, setTitle }), [title]);
|
||||
|
||||
return (
|
||||
<LayoutContext.Provider value={obj}>
|
||||
<LayoutContext.Provider value={contextValue}>
|
||||
<LayoutAppBar title={title} onToggleDrawer={handleDrawerToggle} />
|
||||
<LayoutDrawer mobileOpen={mobileOpen} onClose={handleDrawerToggle} />
|
||||
<Box component="main" sx={{ marginLeft: { md: `${DRAWER_WIDTH}px` } }}>
|
||||
@@ -39,4 +43,6 @@ const Layout: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Layout = memo(LayoutComponent);
|
||||
|
||||
export default Layout;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router';
|
||||
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import { AppBar, IconButton, Toolbar, Typography } from '@mui/material';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
@@ -13,30 +15,47 @@ interface LayoutAppBarProps {
|
||||
onToggleDrawer: () => void;
|
||||
}
|
||||
|
||||
const LayoutAppBar = ({ title, onToggleDrawer }: LayoutAppBarProps) => {
|
||||
// Extract static styles
|
||||
const appBarStyles: SxProps<Theme> = {
|
||||
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
|
||||
ml: { md: `${DRAWER_WIDTH}px` },
|
||||
boxShadow: 'none',
|
||||
backgroundColor: '#2e586a'
|
||||
};
|
||||
|
||||
const menuButtonStyles: SxProps<Theme> = {
|
||||
mr: 2,
|
||||
display: { md: 'none' }
|
||||
};
|
||||
|
||||
const backButtonStyles: SxProps<Theme> = {
|
||||
mr: 1,
|
||||
fontSize: 20,
|
||||
verticalAlign: 'middle'
|
||||
};
|
||||
|
||||
const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) => {
|
||||
const { LL } = useI18nContext();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const pathnames = useLocation()
|
||||
.pathname.split('/')
|
||||
.filter((x) => x);
|
||||
const pathnames = useMemo(
|
||||
() => location.pathname.split('/').filter((x) => x),
|
||||
[location.pathname]
|
||||
);
|
||||
|
||||
const handleBackClick = useCallback(() => {
|
||||
void navigate('/' + pathnames[0]);
|
||||
}, [navigate, pathnames]);
|
||||
|
||||
return (
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{
|
||||
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
|
||||
ml: { md: `${DRAWER_WIDTH}px` },
|
||||
boxShadow: 'none',
|
||||
backgroundColor: '#2e586a'
|
||||
}}
|
||||
>
|
||||
<AppBar position="fixed" sx={appBarStyles}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
edge="start"
|
||||
onClick={onToggleDrawer}
|
||||
sx={{ mr: 2, display: { md: 'none' } }}
|
||||
sx={menuButtonStyles}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
@@ -44,10 +63,10 @@ const LayoutAppBar = ({ title, onToggleDrawer }: LayoutAppBarProps) => {
|
||||
{pathnames.length > 1 && (
|
||||
<>
|
||||
<IconButton
|
||||
sx={{ mr: 1, fontSize: 20, verticalAlign: 'middle' }}
|
||||
sx={backButtonStyles}
|
||||
color="primary"
|
||||
edge="start"
|
||||
onClick={() => navigate('/' + pathnames[0])}
|
||||
onClick={handleBackClick}
|
||||
>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
@@ -70,4 +89,6 @@ const LayoutAppBar = ({ title, onToggleDrawer }: LayoutAppBarProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const LayoutAppBar = memo(LayoutAppBarComponent);
|
||||
|
||||
export default LayoutAppBar;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
|
||||
|
||||
import { PROJECT_NAME } from 'env';
|
||||
@@ -21,19 +23,23 @@ interface LayoutDrawerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const LayoutDrawerProps = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
|
||||
const drawer = (
|
||||
<>
|
||||
<Toolbar disableGutters>
|
||||
<Box display="flex" alignItems="center" px={2}>
|
||||
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
|
||||
<Typography variant="h6">{PROJECT_NAME}</Typography>
|
||||
</Box>
|
||||
<Divider absolute />
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<LayoutMenu />
|
||||
</>
|
||||
const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
|
||||
// Memoize drawer content to prevent unnecessary re-renders
|
||||
const drawer = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<Toolbar disableGutters>
|
||||
<Box display="flex" alignItems="center" px={2}>
|
||||
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
|
||||
<Typography variant="h6">{PROJECT_NAME}</Typography>
|
||||
</Box>
|
||||
<Divider absolute />
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<LayoutMenu />
|
||||
</>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -66,4 +72,6 @@ const LayoutDrawerProps = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutDrawerProps;
|
||||
const LayoutDrawer = memo(LayoutDrawerComponent);
|
||||
|
||||
export default LayoutDrawer;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext, useState } from 'react';
|
||||
import { memo, useCallback, useContext, useState } from 'react';
|
||||
|
||||
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
|
||||
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||
@@ -7,47 +7,24 @@ import ConstructionIcon from '@mui/icons-material/Construction';
|
||||
import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown';
|
||||
import LiveHelpIcon from '@mui/icons-material/LiveHelp';
|
||||
import MoreTimeIcon from '@mui/icons-material/MoreTime';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
|
||||
import SensorsIcon from '@mui/icons-material/Sensors';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Popover
|
||||
} from '@mui/material';
|
||||
import { Box, Divider, List, ListItemButton, ListItemText } from '@mui/material';
|
||||
|
||||
import { LanguageSelector } from 'components/inputs';
|
||||
import LayoutMenuItem from 'components/layout/LayoutMenuItem';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
const LayoutMenu = () => {
|
||||
const { me, signOut } = useContext(AuthenticatedContext);
|
||||
const LayoutMenuComponent = () => {
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
const id = anchorEl ? 'app-menu-popover' : undefined;
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(true);
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
const handleMenuToggle = useCallback(() => {
|
||||
setMenuOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -64,28 +41,13 @@ const LayoutMenu = () => {
|
||||
>
|
||||
<ListItemButton
|
||||
alignItems="flex-start"
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
onClick={handleMenuToggle}
|
||||
sx={{
|
||||
pt: 2.5,
|
||||
pb: menuOpen ? 0 : 2.5,
|
||||
'&:hover, &:focus': { '& svg': { opacity: 1 } }
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={LL.MODULES()}
|
||||
// secondary={
|
||||
// LL.CUSTOMIZATIONS() +
|
||||
// ', ' +
|
||||
// LL.SCHEDULER() +
|
||||
// ', ' +
|
||||
// LL.CUSTOM_ENTITIES(0) +
|
||||
// '...'
|
||||
// }
|
||||
// secondaryTypographyProps={{
|
||||
// noWrap: true,
|
||||
// fontSize: 12,
|
||||
// color: menuOpen ? 'rgba(0,0,0,0)' : 'rgba(255,255,255,0.5)'
|
||||
// }}
|
||||
sx={{ my: 0 }}
|
||||
slotProps={{
|
||||
primary: {
|
||||
@@ -147,66 +109,13 @@ const LayoutMenu = () => {
|
||||
to="/settings"
|
||||
/>
|
||||
<LayoutMenuItem icon={LiveHelpIcon} label={LL.HELP()} to={`/help`} />
|
||||
<Divider />
|
||||
<LayoutMenuItem icon={AccountCircleIcon} label={me.username} to={`/user`} />
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton component="button" onClick={handleClick}>
|
||||
<ListItemIcon sx={{ color: '#9e9e9e' }}>
|
||||
<AccountCircleIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText sx={{ color: '#2196f3' }}>{me.username}</ListItemText>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</List>
|
||||
<Popover
|
||||
id={id}
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center'
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
padding={2}
|
||||
justifyContent="center"
|
||||
flexDirection="column"
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
border: '3px solid grey'
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
color="primary"
|
||||
onClick={() => signOut(true)}
|
||||
>
|
||||
{LL.SIGN_OUT()}
|
||||
</Button>
|
||||
<List>
|
||||
<ListItem disablePadding>
|
||||
<Avatar sx={{ bgcolor: '#9e9e9e', color: 'white' }}>
|
||||
<PersonIcon />
|
||||
</Avatar>
|
||||
<ListItemText
|
||||
sx={{ pl: 2, color: '#2196f3' }}
|
||||
primary={me.username}
|
||||
secondary={'(' + (me.admin ? LL.ADMINISTRATOR() : LL.GUEST()) + ')'}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
<LanguageSelector />
|
||||
</Box>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LayoutMenu = memo(LayoutMenuComponent);
|
||||
|
||||
export default LayoutMenu;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
|
||||
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
|
||||
import type { SvgIconProps } from '@mui/material';
|
||||
import type { SvgIconProps, SxProps, Theme } from '@mui/material';
|
||||
|
||||
import { routeMatches } from 'utils';
|
||||
|
||||
@@ -12,7 +13,7 @@ interface LayoutMenuItemProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const LayoutMenuItem = ({
|
||||
const LayoutMenuItemComponent = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
to,
|
||||
@@ -20,18 +21,68 @@ const LayoutMenuItem = ({
|
||||
}: LayoutMenuItemProps) => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const selected = routeMatches(to, pathname);
|
||||
const selected = useMemo(() => routeMatches(to, pathname), [to, pathname]);
|
||||
|
||||
// Memoize dynamic styles based on selected state
|
||||
const buttonStyles: SxProps<Theme> = useMemo(
|
||||
() => ({
|
||||
transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||
backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent',
|
||||
borderRadius: '8px',
|
||||
margin: '2px 8px',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(68, 82, 211, 0.39)'
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: selected ? '4px' : '0px',
|
||||
backgroundColor: '#90caf9',
|
||||
borderRadius: '0 2px 2px 0',
|
||||
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
|
||||
}
|
||||
}),
|
||||
[selected]
|
||||
);
|
||||
|
||||
const iconStyles: SxProps<Theme> = useMemo(
|
||||
() => ({
|
||||
color: selected ? '#90caf9' : '#9e9e9e',
|
||||
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||
transform: selected ? 'scale(1.1)' : 'scale(1)',
|
||||
transitionProperty: 'color, transform'
|
||||
}),
|
||||
[selected]
|
||||
);
|
||||
|
||||
const textStyles: SxProps<Theme> = useMemo(
|
||||
() => ({
|
||||
color: selected ? '#90caf9' : '#f5f5f5',
|
||||
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||
transitionProperty: 'color, font-weight'
|
||||
}),
|
||||
[selected]
|
||||
);
|
||||
|
||||
return (
|
||||
<ListItemButton component={Link} to={to} disabled={disabled} selected={selected}>
|
||||
<ListItemIcon sx={{ color: selected ? '#90caf9' : '#9e9e9e' }}>
|
||||
<ListItemButton
|
||||
component={Link}
|
||||
to={to}
|
||||
disabled={disabled || false}
|
||||
selected={selected}
|
||||
sx={buttonStyles}
|
||||
>
|
||||
<ListItemIcon sx={iconStyles}>
|
||||
<Icon />
|
||||
</ListItemIcon>
|
||||
<ListItemText sx={{ color: selected ? '#90caf9' : '#f5f5f5' }}>
|
||||
{label}
|
||||
</ListItemText>
|
||||
<ListItemText sx={textStyles}>{label}</ListItemText>
|
||||
</ListItemButton>
|
||||
);
|
||||
};
|
||||
|
||||
const LayoutMenuItem = memo(LayoutMenuItemComponent);
|
||||
|
||||
export default LayoutMenuItem;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||
@@ -20,8 +22,14 @@ interface ListMenuItemProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function RenderIcon({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) {
|
||||
return (
|
||||
const iconStyles: CSSProperties = {
|
||||
justifyContent: 'right',
|
||||
color: 'lightblue',
|
||||
verticalAlign: 'middle'
|
||||
};
|
||||
|
||||
const RenderIcon = memo(
|
||||
({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) => (
|
||||
<>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor, color: 'white' }}>
|
||||
@@ -30,8 +38,8 @@ function RenderIcon({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) {
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={label} secondary={text} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const LayoutMenuItem = ({
|
||||
icon,
|
||||
@@ -46,27 +54,31 @@ const LayoutMenuItem = ({
|
||||
<ListItem
|
||||
disablePadding
|
||||
secondaryAction={
|
||||
<ListItemIcon
|
||||
style={{
|
||||
justifyContent: 'right',
|
||||
color: 'lightblue',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
>
|
||||
<ListItemIcon style={iconStyles}>
|
||||
<NavigateNextIcon />
|
||||
</ListItemIcon>
|
||||
}
|
||||
>
|
||||
<ListItemButton component={Link} to={to}>
|
||||
<RenderIcon icon={icon} bgcolor={bgcolor} label={label} text={text} />
|
||||
<RenderIcon
|
||||
icon={icon}
|
||||
{...(bgcolor && { bgcolor })}
|
||||
label={label}
|
||||
text={text}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
) : (
|
||||
<ListItem>
|
||||
<RenderIcon icon={icon} bgcolor={bgcolor} label={label} text={text} />
|
||||
<RenderIcon
|
||||
icon={icon}
|
||||
{...(bgcolor && { bgcolor })}
|
||||
label={label}
|
||||
text={text}
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
export default LayoutMenuItem;
|
||||
export default memo(LayoutMenuItem);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { Box, Button, CircularProgress } from '@mui/material';
|
||||
|
||||
@@ -9,12 +11,12 @@ interface FormLoaderProps {
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
const FormLoader = ({ errorMessage, onRetry }: FormLoaderProps) => {
|
||||
const FormLoaderComponent = ({ errorMessage, onRetry }: FormLoaderProps) => {
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<MessageBox my={2} level="error" message={errorMessage}>
|
||||
<MessageBox level="error" message={errorMessage}>
|
||||
{onRetry && (
|
||||
<Button
|
||||
sx={{ ml: 2 }}
|
||||
@@ -38,4 +40,6 @@ const FormLoader = ({ errorMessage, onRetry }: FormLoaderProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const FormLoader = memo(FormLoaderComponent);
|
||||
|
||||
export default FormLoader;
|
||||
|
||||
23
interface/src/components/loading/LazyLoader.tsx
Normal file
23
interface/src/components/loading/LazyLoader.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import { Box, CircularProgress } from '@mui/material';
|
||||
import type { SxProps, Theme } from '@mui/material';
|
||||
|
||||
const containerStyles: SxProps<Theme> = {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '200px',
|
||||
backgroundColor: 'background.default',
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider'
|
||||
};
|
||||
|
||||
const LazyLoader = memo(() => (
|
||||
<Box sx={containerStyles}>
|
||||
<CircularProgress size={40} />
|
||||
</Box>
|
||||
));
|
||||
|
||||
export default LazyLoader;
|
||||
@@ -1,10 +1,17 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import { Box, CircularProgress } from '@mui/material';
|
||||
import type { Theme } from '@mui/material';
|
||||
import type { SxProps, Theme } from '@mui/material';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
height?: number | string;
|
||||
}
|
||||
|
||||
const circularProgressStyles: SxProps<Theme> = (theme: Theme) => ({
|
||||
margin: theme.spacing(4),
|
||||
color: theme.palette.text.secondary
|
||||
});
|
||||
|
||||
const LoadingSpinner = ({ height = '100%' }: LoadingSpinnerProps) => {
|
||||
return (
|
||||
<Box
|
||||
@@ -15,15 +22,9 @@ const LoadingSpinner = ({ height = '100%' }: LoadingSpinnerProps) => {
|
||||
padding={2}
|
||||
height={height}
|
||||
>
|
||||
<CircularProgress
|
||||
sx={(theme: Theme) => ({
|
||||
margin: theme.spacing(4),
|
||||
color: theme.palette.text.secondary
|
||||
})}
|
||||
size={100}
|
||||
/>
|
||||
<CircularProgress sx={circularProgressStyles} size={100} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingSpinner;
|
||||
export default memo(LoadingSpinner);
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as LoadingSpinner } from './LoadingSpinner';
|
||||
export { default as FormLoader } from './FormLoader';
|
||||
export { default as LazyLoader } from './LazyLoader';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
import type { Blocker } from 'react-router';
|
||||
|
||||
import {
|
||||
@@ -14,23 +15,23 @@ import { useI18nContext } from 'i18n/i18n-react';
|
||||
const BlockNavigation = ({ blocker }: { blocker: Blocker }) => {
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
blocker.reset?.();
|
||||
}, [blocker]);
|
||||
|
||||
const handleProceed = useCallback(() => {
|
||||
blocker.proceed?.();
|
||||
}, [blocker]);
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={blocker.state === 'blocked'}>
|
||||
<DialogTitle>{LL.BLOCK_NAVIGATE_1()}</DialogTitle>
|
||||
<DialogContent dividers>{LL.BLOCK_NAVIGATE_2()}</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => blocker.reset?.()}
|
||||
color="secondary"
|
||||
>
|
||||
<Button variant="outlined" onClick={handleReset} color="secondary">
|
||||
{LL.STAY()}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => blocker.proceed?.()}
|
||||
color="primary"
|
||||
>
|
||||
<Button variant="contained" onClick={handleProceed} color="primary">
|
||||
{LL.LEAVE()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
@@ -38,4 +39,4 @@ const BlockNavigation = ({ blocker }: { blocker: Blocker }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default BlockNavigation;
|
||||
export default memo(BlockNavigation);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext } from 'react';
|
||||
import { memo, useContext } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { Navigate } from 'react-router';
|
||||
|
||||
@@ -14,4 +14,4 @@ const RequireAdmin: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default RequireAdmin;
|
||||
export default memo(RequireAdmin);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { memo, useContext, useEffect } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router';
|
||||
|
||||
@@ -18,7 +18,7 @@ const RequireAuthenticated: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
if (!authenticationContext.me) {
|
||||
storeLoginRedirect(location);
|
||||
}
|
||||
});
|
||||
}, [authenticationContext.me, location]);
|
||||
|
||||
return authenticationContext.me ? (
|
||||
<AuthenticatedContext.Provider
|
||||
@@ -31,4 +31,4 @@ const RequireAuthenticated: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default RequireAuthenticated;
|
||||
export default memo(RequireAuthenticated);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext } from 'react';
|
||||
import { memo, useContext } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { Navigate } from 'react-router';
|
||||
|
||||
@@ -16,4 +16,4 @@ const RequireUnauthenticated: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default RequireUnauthenticated;
|
||||
export default memo(RequireUnauthenticated);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
@@ -15,9 +16,12 @@ const RouterTabs: FC<RouterTabsProps> = ({ value, children }) => {
|
||||
const theme = useTheme();
|
||||
const smallDown = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
|
||||
const handleTabChange = (_event: unknown, path: string) => {
|
||||
void navigate(path);
|
||||
};
|
||||
const handleTabChange = useCallback(
|
||||
(_event: unknown, path: string) => {
|
||||
void navigate(path);
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
@@ -30,4 +34,4 @@ const RouterTabs: FC<RouterTabsProps> = ({ value, children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default RouterTabs;
|
||||
export default memo(RouterTabs);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Path } from 'react-router';
|
||||
|
||||
import type * as H from 'history';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import type { Me, SignInRequest, SignInResponse } from 'types';
|
||||
|
||||
@@ -14,11 +13,17 @@ export const verifyAuthorization = () =>
|
||||
export const signIn = (request: SignInRequest) =>
|
||||
alovaInstance.Post<SignInResponse>('/rest/signIn', request);
|
||||
|
||||
// Cache storage reference to avoid repeated checks
|
||||
let cachedStorage: Storage | undefined;
|
||||
|
||||
export function getStorage() {
|
||||
return localStorage || sessionStorage;
|
||||
if (!cachedStorage) {
|
||||
cachedStorage = localStorage || sessionStorage;
|
||||
}
|
||||
return cachedStorage;
|
||||
}
|
||||
|
||||
export function storeLoginRedirect(location?: H.Location) {
|
||||
export function storeLoginRedirect(location?: { pathname: string; search: string }) {
|
||||
if (location) {
|
||||
getStorage().setItem(SIGN_IN_PATHNAME, location.pathname);
|
||||
getStorage().setItem(SIGN_IN_SEARCH, location.search);
|
||||
@@ -36,7 +41,7 @@ export function fetchLoginRedirect(): Partial<Path> {
|
||||
clearLoginRedirect();
|
||||
return {
|
||||
pathname: signInPathname || `/dashboard`,
|
||||
search: (signInPathname && signInSearch) || undefined
|
||||
...(signInPathname && signInSearch && { search: signInSearch })
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,59 @@
|
||||
// Code inspired by Prince Azubuike from https://medium.com/@dprincecoder/creating-a-drag-and-drop-file-upload-component-in-react-a-step-by-step-guide-4d93b6cc21e0
|
||||
import { type ChangeEvent, useRef, useState } from 'react';
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type DragEvent,
|
||||
type MouseEvent,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import UploadIcon from '@mui/icons-material/Upload';
|
||||
import { Box, Button } from '@mui/material';
|
||||
import { Box, Button, Typography, styled } from '@mui/material';
|
||||
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
import './dragNdrop.css';
|
||||
const DocumentUploader = styled(Box)<{ active?: boolean }>(({ theme, active }) => ({
|
||||
border: `2px dashed ${active ? '#6dc24b' : '#4282fe'}`,
|
||||
backgroundColor: '#2e3339',
|
||||
padding: theme.spacing(1.25),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
borderRadius: theme.spacing(1),
|
||||
cursor: 'pointer',
|
||||
minHeight: '120px',
|
||||
transition: 'border-color 0.2s ease-in-out'
|
||||
}));
|
||||
|
||||
const DragNdrop = ({ text, onFileSelected }) => {
|
||||
const UploadInfo = styled(Box)({
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
});
|
||||
|
||||
const FileInfo = styled(Box)({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
});
|
||||
|
||||
const FileName = styled(Typography)(({ theme }) => ({
|
||||
fontSize: '14px',
|
||||
color: '#6dc24b',
|
||||
margin: theme.spacing(1, 0)
|
||||
}));
|
||||
|
||||
interface DragNdropProps {
|
||||
text: string;
|
||||
onFileSelected: (file: File) => void;
|
||||
}
|
||||
|
||||
const DragNdrop = ({ text, onFileSelected }: DragNdropProps) => {
|
||||
const [file, setFile] = useState<File>();
|
||||
const [dragged, setDragged] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -28,14 +71,17 @@ const DragNdrop = ({ text, onFileSelected }) => {
|
||||
};
|
||||
|
||||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files) {
|
||||
if (!e.target.files || e.target.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
checkFileExtension(e.target.files[0]);
|
||||
const selectedFile = e.target.files[0];
|
||||
if (selectedFile) {
|
||||
checkFileExtension(selectedFile);
|
||||
}
|
||||
e.target.value = ''; // this is to allow the same file to be selected again
|
||||
};
|
||||
|
||||
const handleDrop = (event) => {
|
||||
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const droppedFiles = event.dataTransfer.files;
|
||||
if (droppedFiles.length > 0) {
|
||||
@@ -43,38 +89,40 @@ const DragNdrop = ({ text, onFileSelected }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFile = (event) => {
|
||||
const handleRemoveFile = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
setFile(undefined);
|
||||
setDragged(false);
|
||||
};
|
||||
|
||||
const handleUploadClick = (event) => {
|
||||
const handleUploadClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onFileSelected(file);
|
||||
if (file) {
|
||||
onFileSelected(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseClick = () => {
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleDragOver = (event) => {
|
||||
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault(); // prevent file from being opened
|
||||
setDragged(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`document-uploader ${file || dragged ? 'active' : ''}`}
|
||||
<DocumentUploader
|
||||
active={!!(file || dragged)}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={() => setDragged(false)}
|
||||
onClick={handleBrowseClick}
|
||||
>
|
||||
<div className="upload-info">
|
||||
<UploadInfo>
|
||||
<CloudUploadIcon sx={{ marginRight: 4 }} color="primary" fontSize="large" />
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
<Typography>{text}</Typography>
|
||||
</UploadInfo>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
@@ -88,9 +136,9 @@ const DragNdrop = ({ text, onFileSelected }) => {
|
||||
|
||||
{file && (
|
||||
<>
|
||||
<div className="file-info">
|
||||
<p>{file.name}</p>
|
||||
</div>
|
||||
<FileInfo>
|
||||
<FileName>{file.name}</FileName>
|
||||
</FileInfo>
|
||||
<Box>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
@@ -112,7 +160,7 @@ const DragNdrop = ({ text, onFileSelected }) => {
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DocumentUploader>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,12 @@ import { useI18nContext } from 'i18n/i18n-react';
|
||||
import DragNdrop from './DragNdrop';
|
||||
import { LinearProgressWithLabel } from './LinearProgressWithLabel';
|
||||
|
||||
const SingleUpload = ({ text, doRestart }) => {
|
||||
interface SingleUploadProps {
|
||||
text: string;
|
||||
doRestart: () => void;
|
||||
}
|
||||
|
||||
const SingleUpload = ({ text, doRestart }: SingleUploadProps) => {
|
||||
const [md5, setMd5] = useState<string>();
|
||||
const [file, setFile] = useState<File>();
|
||||
const { LL } = useI18nContext();
|
||||
@@ -25,8 +30,8 @@ const SingleUpload = ({ text, doRestart }) => {
|
||||
} = useRequest(SystemApi.uploadFile, {
|
||||
immediate: false
|
||||
}).onSuccess(({ data }) => {
|
||||
if (data) {
|
||||
setMd5(data.md5 as string);
|
||||
if (data && typeof data === 'object' && 'md5' in data) {
|
||||
setMd5((data as { md5: string }).md5);
|
||||
toast.success(LL.UPLOAD() + ' MD5 ' + LL.SUCCESSFUL());
|
||||
setFile(undefined);
|
||||
} else {
|
||||
@@ -34,16 +39,19 @@ const SingleUpload = ({ text, doRestart }) => {
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(async () => {
|
||||
if (file) {
|
||||
await sendUpload(file).catch((error: Error) => {
|
||||
if (error.message === 'The user aborted a request') {
|
||||
toast.warning(LL.UPLOAD() + ' ' + LL.ABORTED());
|
||||
} else {
|
||||
toast.warning('Invalid file extension or incompatible bin file');
|
||||
}
|
||||
});
|
||||
}
|
||||
useEffect(() => {
|
||||
const uploadFile = async () => {
|
||||
if (file) {
|
||||
await sendUpload(file).catch((error: Error) => {
|
||||
if (error.message.includes('The user aborted a request')) {
|
||||
toast.warning(LL.UPLOAD() + ' ' + LL.ABORTED());
|
||||
} else {
|
||||
toast.warning('Invalid file extension or incompatible bin file');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
void uploadFile();
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
.document-uploader {
|
||||
border: 2px dashed #4282fe;
|
||||
background-color: #2e3339;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
border-color: #6dc24b;
|
||||
}
|
||||
|
||||
.upload-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #6dc24b;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user