Merge branch 'dev'

This commit is contained in:
proddy
2025-12-31 21:26:15 +01:00
parent eaa277fef0
commit 28135c225b
385 changed files with 40221 additions and 38187 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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';

View File

@@ -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);

View File

@@ -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} />
&nbsp;{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' }} />
&nbsp;CZ
</MenuItem>
<MenuItem key="de" value="de">
<img src={DEflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;DE
</MenuItem>
<MenuItem key="en" value="en">
<img src={GBflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;EN
</MenuItem>
<MenuItem key="fr" value="fr">
<img src={FRflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;FR
</MenuItem>
<MenuItem key="it" value="it">
<img src={ITflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;IT
</MenuItem>
<MenuItem key="nl" value="nl">
<img src={NLflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;NL
</MenuItem>
<MenuItem key="no" value="no">
<img src={NOflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;NO
</MenuItem>
<MenuItem key="pl" value="pl">
<img src={PLflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;PL
</MenuItem>
<MenuItem key="sk" value="sk">
<img src={SKflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;SK
</MenuItem>
<MenuItem key="sv" value="sv">
<img src={SVflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;SV
</MenuItem>
<MenuItem key="tr" value="tr">
<img src={TRflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;TR
</MenuItem>
{menuItems}
</TextField>
);
};
export default LanguageSelector;
export default memo(LanguageSelector);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View 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;

View File

@@ -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);

View File

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

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 })
};
}

View File

@@ -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>
);
};

View File

@@ -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 (

View File

@@ -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;
}
}
}