Separate Client Portal & Dashboard
This commit is contained in:
100
frontend/dashboard/src/components/BadgeStatus.tsx
Normal file
100
frontend/dashboard/src/components/BadgeStatus.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
// @mui
|
||||
import { Theme, useTheme, styled } from '@mui/material/styles';
|
||||
import { BoxProps } from '@mui/material';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export type BadgeStatusEnum =
|
||||
| 'away'
|
||||
| 'busy'
|
||||
| 'unread'
|
||||
| 'online'
|
||||
| 'offline'
|
||||
| 'invisible'
|
||||
| string;
|
||||
|
||||
type BadgeSize = 'small' | 'medium' | 'large';
|
||||
|
||||
const RootStyle = styled('span')(
|
||||
({
|
||||
theme,
|
||||
ownerState,
|
||||
}: {
|
||||
theme: Theme;
|
||||
ownerState: {
|
||||
size: BadgeSize;
|
||||
status: BadgeStatusEnum;
|
||||
};
|
||||
}) => {
|
||||
const { status, size } = ownerState;
|
||||
|
||||
return {
|
||||
width: 10,
|
||||
height: 10,
|
||||
display: 'flex',
|
||||
borderRadius: '50%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
'&:before, &:after': {
|
||||
content: "''",
|
||||
borderRadius: 1,
|
||||
backgroundColor: theme.palette.common.white,
|
||||
},
|
||||
|
||||
...(size === 'small' && { width: 8, height: 8 }),
|
||||
|
||||
...(size === 'large' && { width: 12, height: 12 }),
|
||||
|
||||
...(status === 'offline' && { backgroundColor: 'transparent' }),
|
||||
|
||||
...(status === 'away' && {
|
||||
backgroundColor: theme.palette.warning.main,
|
||||
'&:before': {
|
||||
width: 2,
|
||||
height: 4,
|
||||
transform: 'translateX(1px) translateY(-1px)',
|
||||
},
|
||||
'&:after': {
|
||||
width: 2,
|
||||
height: 4,
|
||||
transform: 'translateY(1px) rotate(125deg)',
|
||||
},
|
||||
}),
|
||||
|
||||
...(status === 'busy' && {
|
||||
backgroundColor: theme.palette.error.main,
|
||||
'&:before': { width: 6, height: 2 },
|
||||
}),
|
||||
|
||||
...(status === 'online' && {
|
||||
backgroundColor: theme.palette.success.main,
|
||||
}),
|
||||
|
||||
...(status === 'invisible' && {
|
||||
backgroundColor: theme.palette.text.disabled,
|
||||
'&:before': {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
},
|
||||
}),
|
||||
|
||||
...(status === 'unread' && {
|
||||
backgroundColor: theme.palette.info.main,
|
||||
}),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
interface Props extends BoxProps {
|
||||
size?: BadgeSize;
|
||||
status?: BadgeStatusEnum;
|
||||
}
|
||||
|
||||
export default function BadgeStatus({ size = 'medium', status = 'offline', sx }: Props) {
|
||||
const theme = useTheme();
|
||||
|
||||
return <RootStyle ownerState={{ status, size }} sx={sx} theme={theme} />;
|
||||
}
|
||||
15
frontend/dashboard/src/components/Iconify.tsx
Normal file
15
frontend/dashboard/src/components/Iconify.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
// icons
|
||||
import { Icon, IconifyIcon } from '@iconify/react';
|
||||
// @mui
|
||||
import { Box, BoxProps, SxProps } from '@mui/material';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
interface Props extends BoxProps {
|
||||
sx?: SxProps;
|
||||
icon: IconifyIcon | string;
|
||||
}
|
||||
|
||||
export default function Iconify({ icon, sx, ...other }: Props) {
|
||||
return <Box component={Icon} icon={icon} sx={{ ...sx }} {...other} />;
|
||||
}
|
||||
97
frontend/dashboard/src/components/Image.tsx
Normal file
97
frontend/dashboard/src/components/Image.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { LazyLoadImage, LazyLoadImageProps } from 'react-lazy-load-image-component';
|
||||
// @mui
|
||||
import { Theme } from '@mui/material/styles';
|
||||
import { Box, BoxProps, SxProps } from '@mui/material';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export type ImageRato = '4/3' | '3/4' | '6/4' | '4/6' | '16/9' | '9/16' | '21/9' | '9/21' | '1/1';
|
||||
|
||||
type IProps = BoxProps & LazyLoadImageProps;
|
||||
|
||||
interface Props extends IProps {
|
||||
sx?: SxProps<Theme>;
|
||||
ratio?: ImageRato;
|
||||
disabledEffect?: boolean;
|
||||
}
|
||||
|
||||
export default function Image({
|
||||
ratio,
|
||||
disabledEffect = false,
|
||||
effect = 'blur',
|
||||
sx,
|
||||
...other
|
||||
}: Props) {
|
||||
if (ratio) {
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
width: 1,
|
||||
lineHeight: 0,
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
pt: getRatio(ratio),
|
||||
'& .wrapper': {
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
lineHeight: 0,
|
||||
position: 'absolute',
|
||||
backgroundSize: 'cover !important',
|
||||
},
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component={LazyLoadImage}
|
||||
wrapperClassName="wrapper"
|
||||
effect={disabledEffect ? undefined : effect}
|
||||
placeholderSrc="https://zone-assets-api.vercel.app/assets/img_placeholder.svg"
|
||||
sx={{ width: 1, height: 1, objectFit: 'cover' }}
|
||||
{...other}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
lineHeight: 0,
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
'& .wrapper': { width: 1, height: 1, backgroundSize: 'cover !important' },
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component={LazyLoadImage}
|
||||
wrapperClassName="wrapper"
|
||||
effect={disabledEffect ? undefined : effect}
|
||||
placeholderSrc="https://zone-assets-api.vercel.app/assets/img_placeholder.svg"
|
||||
sx={{ width: 1, height: 1, objectFit: 'cover' }}
|
||||
{...other}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function getRatio(ratio = '1/1') {
|
||||
return {
|
||||
'4/3': 'calc(100% / 4 * 3)',
|
||||
'3/4': 'calc(100% / 3 * 4)',
|
||||
'6/4': 'calc(100% / 6 * 4)',
|
||||
'4/6': 'calc(100% / 4 * 6)',
|
||||
'16/9': 'calc(100% / 16 * 9)',
|
||||
'9/16': 'calc(100% / 9 * 16)',
|
||||
'21/9': 'calc(100% / 21 * 9)',
|
||||
'9/21': 'calc(100% / 9 * 21)',
|
||||
'1/1': '100%',
|
||||
}[ratio];
|
||||
}
|
||||
94
frontend/dashboard/src/components/LoadingScreen.tsx
Normal file
94
frontend/dashboard/src/components/LoadingScreen.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { m } from 'framer-motion';
|
||||
// @mui
|
||||
import { alpha, styled } from '@mui/material/styles';
|
||||
import { Box, SxProps } from '@mui/material';
|
||||
//
|
||||
import Logo from './Logo';
|
||||
import ProgressBar from './ProgressBar';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const RootStyle = styled('div')(({ theme }) => ({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 99999,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'fixed',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: theme.palette.background.default,
|
||||
}));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
isDashboard?: boolean;
|
||||
sx?: SxProps;
|
||||
};
|
||||
|
||||
export default function LoadingScreen({ isDashboard, ...other }: Props) {
|
||||
return (
|
||||
<>
|
||||
<ProgressBar />
|
||||
|
||||
{!isDashboard && (
|
||||
<RootStyle {...other}>
|
||||
<m.div
|
||||
initial={{ rotateY: 0 }}
|
||||
animate={{ rotateY: 360 }}
|
||||
transition={{
|
||||
duration: 2,
|
||||
ease: 'easeInOut',
|
||||
repeatDelay: 1,
|
||||
repeat: Infinity,
|
||||
}}
|
||||
>
|
||||
<Logo disabledLink sx={{ width: 64, height: 64 }} />
|
||||
</m.div>
|
||||
|
||||
<Box
|
||||
component={m.div}
|
||||
animate={{
|
||||
scale: [1.2, 1, 1, 1.2, 1.2],
|
||||
rotate: [270, 0, 0, 270, 270],
|
||||
opacity: [0.25, 1, 1, 1, 0.25],
|
||||
borderRadius: ['25%', '25%', '50%', '50%', '25%'],
|
||||
}}
|
||||
transition={{ ease: 'linear', duration: 3.2, repeat: Infinity }}
|
||||
sx={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: '25%',
|
||||
position: 'absolute',
|
||||
border: (theme) => `solid 3px ${alpha(theme.palette.primary.dark, 0.24)}`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box
|
||||
component={m.div}
|
||||
animate={{
|
||||
scale: [1, 1.2, 1.2, 1, 1],
|
||||
rotate: [0, 270, 270, 0, 0],
|
||||
opacity: [1, 0.25, 0.25, 0.25, 1],
|
||||
borderRadius: ['25%', '25%', '50%', '50%', '25%'],
|
||||
}}
|
||||
transition={{
|
||||
ease: 'linear',
|
||||
duration: 3.2,
|
||||
repeat: Infinity,
|
||||
}}
|
||||
sx={{
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: '25%',
|
||||
position: 'absolute',
|
||||
border: (theme) => `solid 8px ${alpha(theme.palette.primary.dark, 0.24)}`,
|
||||
}}
|
||||
/>
|
||||
</RootStyle>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
58
frontend/dashboard/src/components/Logo.tsx
Normal file
58
frontend/dashboard/src/components/Logo.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
// @mui
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Box, BoxProps } from '@mui/material';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
interface Props extends BoxProps {
|
||||
disabledLink?: boolean;
|
||||
}
|
||||
|
||||
export default function Logo({ disabledLink = false, sx }: Props) {
|
||||
const theme = useTheme();
|
||||
const PRIMARY_LIGHT = theme.palette.primary.light;
|
||||
const PRIMARY_MAIN = theme.palette.primary.main;
|
||||
const PRIMARY_DARK = theme.palette.primary.dark;
|
||||
|
||||
const logo = (
|
||||
<Box sx={{ width: 40, height: 40, ...sx }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="BG1" x1="100%" x2="50%" y1="9.946%" y2="50%">
|
||||
<stop offset="0%" stopColor={PRIMARY_DARK} />
|
||||
<stop offset="100%" stopColor={PRIMARY_MAIN} />
|
||||
</linearGradient>
|
||||
<linearGradient id="BG2" x1="50%" x2="50%" y1="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={PRIMARY_LIGHT} />
|
||||
<stop offset="100%" stopColor={PRIMARY_MAIN} />
|
||||
</linearGradient>
|
||||
<linearGradient id="BG3" x1="50%" x2="50%" y1="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={PRIMARY_LIGHT} />
|
||||
<stop offset="100%" stopColor={PRIMARY_MAIN} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g fill={PRIMARY_MAIN} fillRule="evenodd" stroke="none" strokeWidth="1">
|
||||
<path
|
||||
fill="url(#BG1)"
|
||||
d="M183.168 285.573l-2.918 5.298-2.973 5.363-2.846 5.095-2.274 4.043-2.186 3.857-2.506 4.383-1.6 2.774-2.294 3.939-1.099 1.869-1.416 2.388-1.025 1.713-1.317 2.18-.95 1.558-1.514 2.447-.866 1.38-.833 1.312-.802 1.246-.77 1.18-.739 1.111-.935 1.38-.664.956-.425.6-.41.572-.59.8-.376.497-.537.69-.171.214c-10.76 13.37-22.496 23.493-36.93 29.334-30.346 14.262-68.07 14.929-97.202-2.704l72.347-124.682 2.8-1.72c49.257-29.326 73.08 1.117 94.02 40.927z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#BG2)"
|
||||
d="M444.31 229.726c-46.27-80.956-94.1-157.228-149.043-45.344-7.516 14.384-12.995 42.337-25.267 42.337v-.142c-12.272 0-17.75-27.953-25.265-42.337C189.79 72.356 141.96 148.628 95.69 229.584c-3.483 6.106-6.828 11.932-9.69 16.996 106.038-67.127 97.11 135.667 184 137.278V384c86.891-1.611 77.962-204.405 184-137.28-2.86-5.062-6.206-10.888-9.69-16.994"
|
||||
/>
|
||||
<path
|
||||
fill="url(#BG3)"
|
||||
d="M450 384c26.509 0 48-21.491 48-48s-21.491-48-48-48-48 21.491-48 48 21.491 48 48 48"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (disabledLink) {
|
||||
return <>{logo}</>;
|
||||
}
|
||||
|
||||
return <RouterLink to="/">{logo}</RouterLink>;
|
||||
}
|
||||
120
frontend/dashboard/src/components/MenuPopover.tsx
Normal file
120
frontend/dashboard/src/components/MenuPopover.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
// @mui
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { Popover, PopoverProps } from '@mui/material';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Arrow =
|
||||
| 'top-left'
|
||||
| 'top-center'
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-center'
|
||||
| 'bottom-right'
|
||||
| 'left-top'
|
||||
| 'left-center'
|
||||
| 'left-bottom'
|
||||
| 'right-top'
|
||||
| 'right-center'
|
||||
| 'right-bottom';
|
||||
|
||||
type ArrowStyleProps = {
|
||||
arrow: Arrow;
|
||||
};
|
||||
|
||||
const ArrowStyle = styled('span')<ArrowStyleProps>(({ arrow, theme }) => {
|
||||
const SIZE = 12;
|
||||
|
||||
const POSITION = -(SIZE / 2);
|
||||
|
||||
const borderStyle = `solid 1px ${theme.palette.grey[500_12]}`;
|
||||
|
||||
const topStyle = {
|
||||
borderRadius: '0 0 3px 0',
|
||||
top: POSITION,
|
||||
borderBottom: borderStyle,
|
||||
borderRight: borderStyle,
|
||||
};
|
||||
const bottomStyle = {
|
||||
borderRadius: '3px 0 0 0',
|
||||
bottom: POSITION,
|
||||
borderTop: borderStyle,
|
||||
borderLeft: borderStyle,
|
||||
};
|
||||
const leftStyle = {
|
||||
borderRadius: '0 3px 0 0',
|
||||
left: POSITION,
|
||||
borderTop: borderStyle,
|
||||
borderRight: borderStyle,
|
||||
};
|
||||
const rightStyle = {
|
||||
borderRadius: '0 0 0 3px',
|
||||
right: POSITION,
|
||||
borderBottom: borderStyle,
|
||||
borderLeft: borderStyle,
|
||||
};
|
||||
|
||||
return {
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
zIndex: 1,
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
content: "''",
|
||||
position: 'absolute',
|
||||
|
||||
transform: 'rotate(-135deg)',
|
||||
background: theme.palette.background.paper,
|
||||
},
|
||||
// Top
|
||||
...(arrow === 'top-left' && { ...topStyle, left: 20 }),
|
||||
...(arrow === 'top-center' && { ...topStyle, left: 0, right: 0, margin: 'auto' }),
|
||||
...(arrow === 'top-right' && { ...topStyle, right: 20 }),
|
||||
// Bottom
|
||||
...(arrow === 'bottom-left' && { ...bottomStyle, left: 20 }),
|
||||
...(arrow === 'bottom-center' && { ...bottomStyle, left: 0, right: 0, margin: 'auto' }),
|
||||
...(arrow === 'bottom-right' && { ...bottomStyle, right: 20 }),
|
||||
// Left
|
||||
...(arrow === 'left-top' && { ...leftStyle, top: 20 }),
|
||||
...(arrow === 'left-center' && { ...leftStyle, top: 0, bottom: 0, margin: 'auto' }),
|
||||
...(arrow === 'left-bottom' && { ...leftStyle, bottom: 20 }),
|
||||
// Right
|
||||
...(arrow === 'right-top' && { ...rightStyle, top: 20 }),
|
||||
...(arrow === 'right-center' && { ...rightStyle, top: 0, bottom: 0, margin: 'auto' }),
|
||||
...(arrow === 'right-bottom' && { ...rightStyle, bottom: 20 }),
|
||||
};
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
interface Props extends PopoverProps {
|
||||
arrow?: Arrow;
|
||||
disabledArrow?: boolean;
|
||||
}
|
||||
|
||||
export default function MenuPopover({
|
||||
children,
|
||||
arrow = 'top-right',
|
||||
disabledArrow,
|
||||
sx,
|
||||
...other
|
||||
}: Props) {
|
||||
return (
|
||||
<Popover
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
p: 1,
|
||||
width: 200,
|
||||
overflow: 'inherit',
|
||||
...sx,
|
||||
},
|
||||
}}
|
||||
{...other}
|
||||
>
|
||||
{!disabledArrow && <ArrowStyle arrow={arrow} />}
|
||||
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
27
frontend/dashboard/src/components/Page.tsx
Normal file
27
frontend/dashboard/src/components/Page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { forwardRef, ReactNode } from 'react';
|
||||
// @mui
|
||||
import { Box, BoxProps } from '@mui/material';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
interface Props extends BoxProps {
|
||||
children: ReactNode;
|
||||
meta?: ReactNode;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const Page = forwardRef<HTMLDivElement, Props>(({ children, title = '', meta, ...other }, ref) => (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{`${title} | Minimal-UI`}</title>
|
||||
{meta}
|
||||
</Helmet>
|
||||
|
||||
<Box ref={ref} {...other}>
|
||||
{children}
|
||||
</Box>
|
||||
</>
|
||||
));
|
||||
|
||||
export default Page;
|
||||
57
frontend/dashboard/src/components/ProgressBar.tsx
Normal file
57
frontend/dashboard/src/components/ProgressBar.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import NProgress from 'nprogress';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
// @mui
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { GlobalStyles } from '@mui/material';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export function ProgressBarStyle() {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<GlobalStyles
|
||||
styles={{
|
||||
'#nprogress': {
|
||||
pointerEvents: 'none',
|
||||
'& .bar': {
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: 2,
|
||||
width: '100%',
|
||||
position: 'fixed',
|
||||
zIndex: theme.zIndex.snackbar,
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
boxShadow: `0 0 2px ${theme.palette.primary.main}`
|
||||
},
|
||||
'& .peg': {
|
||||
right: 0,
|
||||
opacity: 1,
|
||||
width: 100,
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
transform: 'rotate(3deg) translate(0px, -4px)',
|
||||
boxShadow: `0 0 10px ${theme.palette.primary.main}, 0 0 5px ${theme.palette.primary.main}`
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProgressBar() {
|
||||
NProgress.configure({
|
||||
showSpinner: false
|
||||
});
|
||||
|
||||
useMemo(() => {
|
||||
NProgress.start();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
NProgress.done();
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
29
frontend/dashboard/src/components/RtlLayout.tsx
Normal file
29
frontend/dashboard/src/components/RtlLayout.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useEffect, ReactNode } from 'react';
|
||||
// rtl
|
||||
import rtlPlugin from 'stylis-plugin-rtl';
|
||||
// emotion
|
||||
import createCache from '@emotion/cache';
|
||||
import { CacheProvider } from '@emotion/react';
|
||||
// @mui
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function RtlLayout({ children }: Props) {
|
||||
const theme = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
document.dir = theme.direction;
|
||||
}, [theme.direction]);
|
||||
|
||||
const cacheRtl = createCache({
|
||||
key: theme.direction === 'rtl' ? 'rtl' : 'css',
|
||||
stylisPlugins: theme.direction === 'rtl' ? [rtlPlugin] : [],
|
||||
});
|
||||
|
||||
return <CacheProvider value={cacheRtl}>{children}</CacheProvider>;
|
||||
}
|
||||
14
frontend/dashboard/src/components/ScrollToTop.ts
Normal file
14
frontend/dashboard/src/components/ScrollToTop.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export default function ScrollToTop() {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
61
frontend/dashboard/src/components/Scrollbar.tsx
Normal file
61
frontend/dashboard/src/components/Scrollbar.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import SimpleBarReact, { Props as ScrollbarProps } from 'simplebar-react';
|
||||
// @mui
|
||||
import { alpha, styled } from '@mui/material/styles';
|
||||
import { Box, SxProps } from '@mui/material';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const RootStyle = styled('div')(() => ({
|
||||
flexGrow: 1,
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
}));
|
||||
|
||||
const SimpleBarStyle = styled(SimpleBarReact)(({ theme }) => ({
|
||||
maxHeight: '100%',
|
||||
'& .simplebar-scrollbar': {
|
||||
'&:before': {
|
||||
backgroundColor: alpha(theme.palette.grey[600], 0.48),
|
||||
},
|
||||
'&.simplebar-visible:before': {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
'& .simplebar-track.simplebar-vertical': {
|
||||
width: 10,
|
||||
},
|
||||
'& .simplebar-track.simplebar-horizontal .simplebar-scrollbar': {
|
||||
height: 6,
|
||||
},
|
||||
'& .simplebar-mask': {
|
||||
zIndex: 'inherit',
|
||||
},
|
||||
}));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
interface Props extends ScrollbarProps {
|
||||
sx?: SxProps;
|
||||
}
|
||||
|
||||
export default function Scrollbar({ children, sx, ...other }: Props) {
|
||||
const userAgent = typeof navigator === 'undefined' ? 'SSR' : navigator.userAgent;
|
||||
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Box sx={{ overflowX: 'auto', ...sx }} {...other}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RootStyle>
|
||||
<SimpleBarStyle timeout={500} clickOnTrack={false} sx={sx} {...other}>
|
||||
{children}
|
||||
</SimpleBarStyle>
|
||||
</RootStyle>
|
||||
);
|
||||
}
|
||||
24
frontend/dashboard/src/components/SvgIconStyle.tsx
Normal file
24
frontend/dashboard/src/components/SvgIconStyle.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Box, BoxProps } from '@mui/material';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
interface Props extends BoxProps {
|
||||
src: string;
|
||||
}
|
||||
|
||||
export default function SvgIconStyle({ src, sx }: Props) {
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
display: 'inline-block',
|
||||
bgcolor: 'currentColor',
|
||||
mask: `url(${src}) no-repeat center / contain`,
|
||||
WebkitMask: `url(${src}) no-repeat center / contain`,
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
38
frontend/dashboard/src/components/ThemeColorPresets.tsx
Normal file
38
frontend/dashboard/src/components/ThemeColorPresets.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
// @mui
|
||||
import { alpha, ThemeProvider, createTheme, useTheme } from '@mui/material/styles';
|
||||
// hooks
|
||||
import useSettings from '../hooks/useSettings';
|
||||
//
|
||||
import componentsOverride from '../theme/overrides';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function ThemeColorPresets({ children }: Props) {
|
||||
const defaultTheme = useTheme();
|
||||
const { setColor } = useSettings();
|
||||
|
||||
const themeOptions = useMemo(
|
||||
() => ({
|
||||
...defaultTheme,
|
||||
palette: {
|
||||
...defaultTheme.palette,
|
||||
primary: setColor,
|
||||
},
|
||||
customShadows: {
|
||||
...defaultTheme.customShadows,
|
||||
primary: `0 8px 16px 0 ${alpha(setColor.main, 0.24)}`,
|
||||
},
|
||||
}),
|
||||
[setColor, defaultTheme]
|
||||
);
|
||||
|
||||
const theme = createTheme(themeOptions);
|
||||
theme.components = componentsOverride(theme);
|
||||
|
||||
return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
|
||||
}
|
||||
61
frontend/dashboard/src/components/animate/DialogAnimate.tsx
Normal file
61
frontend/dashboard/src/components/animate/DialogAnimate.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { m, AnimatePresence } from 'framer-motion';
|
||||
// @mui
|
||||
import { Dialog, Box, Paper, DialogProps } from '@mui/material';
|
||||
//
|
||||
import { varFade } from './variants';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export interface Props extends DialogProps {
|
||||
variants?: Record<string, unknown>;
|
||||
onClose?: VoidFunction;
|
||||
}
|
||||
|
||||
export default function DialogAnimate({
|
||||
open = false,
|
||||
variants,
|
||||
onClose,
|
||||
children,
|
||||
sx,
|
||||
...other
|
||||
}: Props) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<Dialog
|
||||
fullWidth
|
||||
maxWidth="xs"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
PaperComponent={(props) => (
|
||||
<Box
|
||||
component={m.div}
|
||||
{...(variants ||
|
||||
varFade({
|
||||
distance: 120,
|
||||
durationIn: 0.32,
|
||||
durationOut: 0.24,
|
||||
easeIn: 'easeInOut',
|
||||
}).inUp)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Box onClick={onClose} sx={{ width: '100%', height: '100%', position: 'fixed' }} />
|
||||
<Paper sx={sx} {...props}>
|
||||
{props.children}
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
{...other}
|
||||
>
|
||||
{children}
|
||||
</Dialog>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
107
frontend/dashboard/src/components/animate/FabButtonAnimate.tsx
Normal file
107
frontend/dashboard/src/components/animate/FabButtonAnimate.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { m } from 'framer-motion';
|
||||
import { forwardRef, ReactNode } from 'react';
|
||||
// @mui
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Box, Fab, FabProps, SxProps } from '@mui/material';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
interface Props extends Omit<FabProps, 'color'> {
|
||||
sxWrap?: SxProps;
|
||||
color?:
|
||||
| 'inherit'
|
||||
| 'default'
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'info'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'error';
|
||||
}
|
||||
|
||||
const FabButtonAnimate = forwardRef<HTMLButtonElement, Props>(
|
||||
({ color = 'primary', size = 'large', children, sx, sxWrap, ...other }, ref) => {
|
||||
const theme = useTheme();
|
||||
|
||||
if (
|
||||
color === 'default' ||
|
||||
color === 'inherit' ||
|
||||
color === 'primary' ||
|
||||
color === 'secondary'
|
||||
) {
|
||||
return (
|
||||
<AnimateWrap size={size} sxWrap={sxWrap}>
|
||||
<Fab ref={ref} size={size} color={color} sx={sx} {...other}>
|
||||
{children}
|
||||
</Fab>
|
||||
</AnimateWrap>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimateWrap size={size} sxWrap={sxWrap}>
|
||||
<Fab
|
||||
ref={ref}
|
||||
size={size}
|
||||
sx={{
|
||||
boxShadow: theme.customShadows[color],
|
||||
color: theme.palette[color].contrastText,
|
||||
bgcolor: theme.palette[color].main,
|
||||
'&:hover': {
|
||||
bgcolor: theme.palette[color].dark,
|
||||
},
|
||||
...sx,
|
||||
}}
|
||||
{...other}
|
||||
>
|
||||
{children}
|
||||
</Fab>
|
||||
</AnimateWrap>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default FabButtonAnimate;
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type AnimateWrapProp = {
|
||||
children: ReactNode;
|
||||
size: 'small' | 'medium' | 'large';
|
||||
sxWrap?: SxProps;
|
||||
};
|
||||
|
||||
const varSmall = {
|
||||
hover: { scale: 1.07 },
|
||||
tap: { scale: 0.97 },
|
||||
};
|
||||
|
||||
const varMedium = {
|
||||
hover: { scale: 1.06 },
|
||||
tap: { scale: 0.98 },
|
||||
};
|
||||
|
||||
const varLarge = {
|
||||
hover: { scale: 1.05 },
|
||||
tap: { scale: 0.99 },
|
||||
};
|
||||
|
||||
function AnimateWrap({ size, children, sxWrap }: AnimateWrapProp) {
|
||||
const isSmall = size === 'small';
|
||||
const isLarge = size === 'large';
|
||||
|
||||
return (
|
||||
<Box
|
||||
component={m.div}
|
||||
whileTap="tap"
|
||||
whileHover="hover"
|
||||
variants={(isSmall && varSmall) || (isLarge && varLarge) || varMedium}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
...sxWrap,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { m } from 'framer-motion';
|
||||
import { forwardRef, ReactNode } from 'react';
|
||||
// @mui
|
||||
import { Box, IconButton, IconButtonProps } from '@mui/material';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const IconButtonAnimate = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
({ children, size = 'medium', ...other }, ref) => (
|
||||
<AnimateWrap size={size}>
|
||||
<IconButton size={size} ref={ref} {...other}>
|
||||
{children}
|
||||
</IconButton>
|
||||
</AnimateWrap>
|
||||
)
|
||||
);
|
||||
|
||||
export default IconButtonAnimate;
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type AnimateWrapProp = {
|
||||
children: ReactNode;
|
||||
size: 'small' | 'medium' | 'large';
|
||||
};
|
||||
|
||||
const varSmall = {
|
||||
hover: { scale: 1.1 },
|
||||
tap: { scale: 0.95 },
|
||||
};
|
||||
|
||||
const varMedium = {
|
||||
hover: { scale: 1.09 },
|
||||
tap: { scale: 0.97 },
|
||||
};
|
||||
|
||||
const varLarge = {
|
||||
hover: { scale: 1.08 },
|
||||
tap: { scale: 0.99 },
|
||||
};
|
||||
|
||||
function AnimateWrap({ size, children }: AnimateWrapProp) {
|
||||
const isSmall = size === 'small';
|
||||
const isLarge = size === 'large';
|
||||
|
||||
return (
|
||||
<Box
|
||||
component={m.div}
|
||||
whileTap="tap"
|
||||
whileHover="hover"
|
||||
variants={(isSmall && varSmall) || (isLarge && varLarge) || varMedium}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { m, MotionProps } from 'framer-motion';
|
||||
// @mui
|
||||
import { Box, BoxProps } from '@mui/material';
|
||||
//
|
||||
import { varContainer } from './variants';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type IProps = BoxProps & MotionProps;
|
||||
|
||||
export interface Props extends IProps {
|
||||
animate?: boolean;
|
||||
action?: boolean;
|
||||
}
|
||||
|
||||
export default function MotionContainer({ animate, action = false, children, ...other }: Props) {
|
||||
if (action) {
|
||||
return (
|
||||
<Box
|
||||
component={m.div}
|
||||
initial={false}
|
||||
animate={animate ? 'animate' : 'exit'}
|
||||
variants={varContainer()}
|
||||
{...other}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
component={m.div}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={varContainer()}
|
||||
{...other}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
50
frontend/dashboard/src/components/animate/MotionInView.tsx
Normal file
50
frontend/dashboard/src/components/animate/MotionInView.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useEffect } from 'react';
|
||||
import { m, useAnimation, MotionProps } from 'framer-motion';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
// @mui
|
||||
import { Box, BoxProps } from '@mui/material';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = BoxProps & MotionProps;
|
||||
|
||||
interface MotionInViewProps extends Props {
|
||||
threshold?: number | number[];
|
||||
}
|
||||
|
||||
export default function MotionInView({
|
||||
children,
|
||||
variants,
|
||||
transition,
|
||||
threshold,
|
||||
...other
|
||||
}: MotionInViewProps) {
|
||||
const controls = useAnimation();
|
||||
const [ref, inView] = useInView({
|
||||
threshold: threshold || 0,
|
||||
triggerOnce: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!variants) return;
|
||||
if (inView) {
|
||||
controls.start(Object.keys(variants)[1]);
|
||||
} else {
|
||||
controls.start(Object.keys(variants)[0]);
|
||||
}
|
||||
}, [controls, inView, variants]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
component={m.div}
|
||||
initial={variants ? Object.keys(variants)[0] : false}
|
||||
animate={controls}
|
||||
variants={variants}
|
||||
transition={transition}
|
||||
{...other}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { LazyMotion } from 'framer-motion';
|
||||
import features from "./features";
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
// eslint-disable-next-line import/extensions
|
||||
// const loadFeatures = () => import('~/src/components/animate/features.js').then((res) => res.default);
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function MotionLazyContainer({ children }: Props) {
|
||||
return (
|
||||
<LazyMotion strict features={features}>
|
||||
{children}
|
||||
</LazyMotion>
|
||||
);
|
||||
}
|
||||
34
frontend/dashboard/src/components/animate/TextAnimate.tsx
Normal file
34
frontend/dashboard/src/components/animate/TextAnimate.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { m, MotionProps } from 'framer-motion';
|
||||
// @mui
|
||||
import { Box, BoxProps } from '@mui/material';
|
||||
//
|
||||
import { varFade } from './variants';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = BoxProps & MotionProps;
|
||||
|
||||
interface TextAnimateProps extends Props {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export default function TextAnimate({ text, variants, sx, ...other }: TextAnimateProps) {
|
||||
return (
|
||||
<Box
|
||||
component={m.h1}
|
||||
sx={{
|
||||
typography: 'h1',
|
||||
overflow: 'hidden',
|
||||
display: 'inline-flex',
|
||||
...sx,
|
||||
}}
|
||||
{...other}
|
||||
>
|
||||
{text.split('').map((letter, index) => (
|
||||
<m.span key={index} variants={variants || varFade().inUp}>
|
||||
{letter}
|
||||
</m.span>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
3
frontend/dashboard/src/components/animate/features.js
Normal file
3
frontend/dashboard/src/components/animate/features.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { domMax } from 'framer-motion';
|
||||
|
||||
export default domMax;
|
||||
13
frontend/dashboard/src/components/animate/index.ts
Normal file
13
frontend/dashboard/src/components/animate/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export * from './variants';
|
||||
|
||||
export { default as DialogAnimate } from './DialogAnimate';
|
||||
export { default as TextAnimate } from './TextAnimate';
|
||||
|
||||
export { default as FabButtonAnimate } from './FabButtonAnimate';
|
||||
export { default as IconButtonAnimate } from './IconButtonAnimate';
|
||||
|
||||
export { default as MotionInView } from './MotionInView';
|
||||
export { default as MotionContainer } from './MotionContainer';
|
||||
export { default as MotionLazyContainer } from './MotionLazyContainer';
|
||||
44
frontend/dashboard/src/components/animate/type.ts
Normal file
44
frontend/dashboard/src/components/animate/type.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type EaseType =
|
||||
| 'linear'
|
||||
| 'easeIn'
|
||||
| 'easeOut'
|
||||
| 'easeInOut'
|
||||
| 'circIn'
|
||||
| 'circOut'
|
||||
| 'circInOut'
|
||||
| 'backIn'
|
||||
| 'backOut'
|
||||
| 'backInOut'
|
||||
| 'anticipate'
|
||||
| number[];
|
||||
|
||||
export type VariantsType = {
|
||||
distance?: number;
|
||||
durationIn?: number;
|
||||
durationOut?: number;
|
||||
easeIn?: EaseType;
|
||||
easeOut?: EaseType;
|
||||
};
|
||||
|
||||
export type TranHoverType = {
|
||||
duration?: number;
|
||||
ease?: EaseType;
|
||||
};
|
||||
|
||||
export type TranEnterType = {
|
||||
durationIn?: number;
|
||||
easeIn?: EaseType;
|
||||
};
|
||||
|
||||
export type TranExitType = {
|
||||
durationOut?: number;
|
||||
easeOut?: EaseType;
|
||||
};
|
||||
|
||||
export type BackgroundType = {
|
||||
colors?: string[];
|
||||
duration?: number;
|
||||
ease?: EaseType;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export const varHover = (scale?: number) => ({
|
||||
hover: {
|
||||
scale: scale || 1.1,
|
||||
},
|
||||
});
|
||||
106
frontend/dashboard/src/components/animate/variants/background.ts
Normal file
106
frontend/dashboard/src/components/animate/variants/background.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// @types
|
||||
import { BackgroundType } from '../type';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export const varBgColor = (props?: BackgroundType) => {
|
||||
const colors = props?.colors || ['#19dcea', '#b22cff'];
|
||||
const duration = props?.duration || 5;
|
||||
const ease = props?.ease || 'linear';
|
||||
|
||||
return {
|
||||
animate: {
|
||||
background: colors,
|
||||
transition: { duration, ease },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export const varBgKenburns = (props?: BackgroundType) => {
|
||||
const duration = props?.duration || 5;
|
||||
const ease = props?.ease || 'easeOut';
|
||||
|
||||
return {
|
||||
top: {
|
||||
animate: {
|
||||
scale: [1, 1.25],
|
||||
y: [0, -15],
|
||||
transformOrigin: ['50% 16%', 'top'],
|
||||
transition: { duration, ease },
|
||||
},
|
||||
},
|
||||
right: {
|
||||
animate: {
|
||||
scale: [1, 1.25],
|
||||
x: [0, 20],
|
||||
y: [0, -15],
|
||||
transformOrigin: ['84% 50%', 'right'],
|
||||
transition: { duration, ease },
|
||||
},
|
||||
},
|
||||
bottom: {
|
||||
animate: {
|
||||
scale: [1, 1.25],
|
||||
y: [0, 15],
|
||||
transformOrigin: ['50% 84%', 'bottom'],
|
||||
transition: { duration, ease },
|
||||
},
|
||||
},
|
||||
left: {
|
||||
animate: {
|
||||
scale: [1, 1.25],
|
||||
x: [0, -20],
|
||||
y: [0, 15],
|
||||
transformOrigin: ['16% 50%', 'left'],
|
||||
transition: { duration, ease },
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export const varBgPan = (props?: BackgroundType) => {
|
||||
const colors = props?.colors || ['#ee7752', '#e73c7e', '#23a6d5', '#23d5ab'];
|
||||
const duration = props?.duration || 5;
|
||||
const ease = props?.ease || 'linear';
|
||||
|
||||
const gradient = (deg: number) => `linear-gradient(${deg}deg, ${colors})`;
|
||||
|
||||
return {
|
||||
top: {
|
||||
animate: {
|
||||
backgroundImage: [gradient(0), gradient(0)],
|
||||
backgroundPosition: ['center 99%', 'center 1%'],
|
||||
backgroundSize: ['100% 600%', '100% 600%'],
|
||||
transition: { duration, ease },
|
||||
},
|
||||
},
|
||||
right: {
|
||||
animate: {
|
||||
backgroundPosition: ['1% center', '99% center'],
|
||||
backgroundImage: [gradient(270), gradient(270)],
|
||||
backgroundSize: ['600% 100%', '600% 100%'],
|
||||
transition: { duration, ease },
|
||||
},
|
||||
},
|
||||
bottom: {
|
||||
animate: {
|
||||
backgroundImage: [gradient(0), gradient(0)],
|
||||
backgroundPosition: ['center 1%', 'center 99%'],
|
||||
backgroundSize: ['100% 600%', '100% 600%'],
|
||||
transition: { duration, ease },
|
||||
},
|
||||
},
|
||||
left: {
|
||||
animate: {
|
||||
backgroundPosition: ['99% center', '1% center'],
|
||||
backgroundImage: [gradient(270), gradient(270)],
|
||||
backgroundSize: ['600% 100%', '600% 100%'],
|
||||
transition: { duration, ease },
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
106
frontend/dashboard/src/components/animate/variants/bounce.ts
Normal file
106
frontend/dashboard/src/components/animate/variants/bounce.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// @types
|
||||
import { VariantsType } from '../type';
|
||||
//
|
||||
import { varTranEnter, varTranExit } from './transition';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export const varBounce = (props?: VariantsType) => {
|
||||
const durationIn = props?.durationIn;
|
||||
const durationOut = props?.durationOut;
|
||||
const easeIn = props?.easeIn;
|
||||
const easeOut = props?.easeOut;
|
||||
|
||||
return {
|
||||
// IN
|
||||
in: {
|
||||
initial: {},
|
||||
animate: {
|
||||
scale: [0.3, 1.1, 0.9, 1.03, 0.97, 1],
|
||||
opacity: [0, 1, 1, 1, 1, 1],
|
||||
transition: varTranEnter({ durationIn, easeIn }),
|
||||
},
|
||||
exit: {
|
||||
scale: [0.9, 1.1, 0.3],
|
||||
opacity: [1, 1, 0],
|
||||
},
|
||||
},
|
||||
inUp: {
|
||||
initial: {},
|
||||
animate: {
|
||||
y: [720, -24, 12, -4, 0],
|
||||
scaleY: [4, 0.9, 0.95, 0.985, 1],
|
||||
opacity: [0, 1, 1, 1, 1],
|
||||
transition: { ...varTranEnter({ durationIn, easeIn }) },
|
||||
},
|
||||
exit: {
|
||||
y: [12, -24, 720],
|
||||
scaleY: [0.985, 0.9, 3],
|
||||
opacity: [1, 1, 0],
|
||||
transition: varTranExit({ durationOut, easeOut }),
|
||||
},
|
||||
},
|
||||
inDown: {
|
||||
initial: {},
|
||||
animate: {
|
||||
y: [-720, 24, -12, 4, 0],
|
||||
scaleY: [4, 0.9, 0.95, 0.985, 1],
|
||||
opacity: [0, 1, 1, 1, 1],
|
||||
transition: varTranEnter({ durationIn, easeIn }),
|
||||
},
|
||||
exit: {
|
||||
y: [-12, 24, -720],
|
||||
scaleY: [0.985, 0.9, 3],
|
||||
opacity: [1, 1, 0],
|
||||
transition: varTranExit({ durationOut, easeOut }),
|
||||
},
|
||||
},
|
||||
inLeft: {
|
||||
initial: {},
|
||||
animate: {
|
||||
x: [-720, 24, -12, 4, 0],
|
||||
scaleX: [3, 1, 0.98, 0.995, 1],
|
||||
opacity: [0, 1, 1, 1, 1],
|
||||
transition: varTranEnter({ durationIn, easeIn }),
|
||||
},
|
||||
exit: {
|
||||
x: [0, 24, -720],
|
||||
scaleX: [1, 0.9, 2],
|
||||
opacity: [1, 1, 0],
|
||||
transition: varTranExit({ durationOut, easeOut }),
|
||||
},
|
||||
},
|
||||
inRight: {
|
||||
initial: {},
|
||||
animate: {
|
||||
x: [720, -24, 12, -4, 0],
|
||||
scaleX: [3, 1, 0.98, 0.995, 1],
|
||||
opacity: [0, 1, 1, 1, 1],
|
||||
transition: varTranEnter({ durationIn, easeIn }),
|
||||
},
|
||||
exit: {
|
||||
x: [0, -24, 720],
|
||||
scaleX: [1, 0.9, 2],
|
||||
opacity: [1, 1, 0],
|
||||
transition: varTranExit({ durationOut, easeOut }),
|
||||
},
|
||||
},
|
||||
|
||||
// OUT
|
||||
out: {
|
||||
animate: { scale: [0.9, 1.1, 0.3], opacity: [1, 1, 0] },
|
||||
},
|
||||
outUp: {
|
||||
animate: { y: [-12, 24, -720], scaleY: [0.985, 0.9, 3], opacity: [1, 1, 0] },
|
||||
},
|
||||
outDown: {
|
||||
animate: { y: [12, -24, 720], scaleY: [0.985, 0.9, 3], opacity: [1, 1, 0] },
|
||||
},
|
||||
outLeft: {
|
||||
animate: { x: [0, 24, -720], scaleX: [1, 0.9, 2], opacity: [1, 1, 0] },
|
||||
},
|
||||
outRight: {
|
||||
animate: { x: [0, -24, 720], scaleX: [1, 0.9, 2], opacity: [1, 1, 0] },
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export type Props = {
|
||||
staggerIn?: number;
|
||||
delayIn?: number;
|
||||
staggerOut?: number;
|
||||
};
|
||||
|
||||
export const varContainer = (props?: Props) => {
|
||||
const staggerIn = props?.staggerIn || 0.05;
|
||||
const delayIn = props?.staggerIn || 0.05;
|
||||
const staggerOut = props?.staggerIn || 0.05;
|
||||
|
||||
return {
|
||||
animate: {
|
||||
transition: {
|
||||
staggerChildren: staggerIn,
|
||||
delayChildren: delayIn,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
transition: {
|
||||
staggerChildren: staggerOut,
|
||||
staggerDirection: -1,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
70
frontend/dashboard/src/components/animate/variants/fade.ts
Normal file
70
frontend/dashboard/src/components/animate/variants/fade.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// @types
|
||||
import { VariantsType } from '../type';
|
||||
//
|
||||
import { varTranEnter, varTranExit } from './transition';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export const varFade = (props?: VariantsType) => {
|
||||
const distance = props?.distance || 120;
|
||||
const durationIn = props?.durationIn;
|
||||
const durationOut = props?.durationOut;
|
||||
const easeIn = props?.easeIn;
|
||||
const easeOut = props?.easeOut;
|
||||
|
||||
return {
|
||||
// IN
|
||||
in: {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1, transition: varTranEnter },
|
||||
exit: { opacity: 0, transition: varTranExit },
|
||||
},
|
||||
inUp: {
|
||||
initial: { y: distance, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { y: distance, opacity: 0, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
inDown: {
|
||||
initial: { y: -distance, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { y: -distance, opacity: 0, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
inLeft: {
|
||||
initial: { x: -distance, opacity: 0 },
|
||||
animate: { x: 0, opacity: 1, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { x: -distance, opacity: 0, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
inRight: {
|
||||
initial: { x: distance, opacity: 0 },
|
||||
animate: { x: 0, opacity: 1, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { x: distance, opacity: 0, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
|
||||
// OUT
|
||||
out: {
|
||||
initial: { opacity: 1 },
|
||||
animate: { opacity: 0, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { opacity: 1, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
outUp: {
|
||||
initial: { y: 0, opacity: 1 },
|
||||
animate: { y: -distance, opacity: 0, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { y: 0, opacity: 1, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
outDown: {
|
||||
initial: { y: 0, opacity: 1 },
|
||||
animate: { y: distance, opacity: 0, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { y: 0, opacity: 1, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
outLeft: {
|
||||
initial: { x: 0, opacity: 1 },
|
||||
animate: { x: -distance, opacity: 0, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { x: 0, opacity: 1, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
outRight: {
|
||||
initial: { x: 0, opacity: 1 },
|
||||
animate: { x: distance, opacity: 0, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { x: 0, opacity: 1, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
};
|
||||
};
|
||||
37
frontend/dashboard/src/components/animate/variants/flip.ts
Normal file
37
frontend/dashboard/src/components/animate/variants/flip.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// @types
|
||||
import { VariantsType } from '../type';
|
||||
//
|
||||
import { varTranEnter, varTranExit } from './transition';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export const varFlip = (props?: VariantsType) => {
|
||||
const durationIn = props?.durationIn;
|
||||
const durationOut = props?.durationOut;
|
||||
const easeIn = props?.easeIn;
|
||||
const easeOut = props?.easeOut;
|
||||
|
||||
return {
|
||||
// IN
|
||||
inX: {
|
||||
initial: { rotateX: -180, opacity: 0 },
|
||||
animate: { rotateX: 0, opacity: 1, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { rotateX: -180, opacity: 0, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
inY: {
|
||||
initial: { rotateY: -180, opacity: 0 },
|
||||
animate: { rotateY: 0, opacity: 1, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { rotateY: -180, opacity: 0, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
|
||||
// OUT
|
||||
outX: {
|
||||
initial: { rotateX: 0, opacity: 1 },
|
||||
animate: { rotateX: 70, opacity: 0, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
outY: {
|
||||
initial: { rotateY: 0, opacity: 1 },
|
||||
animate: { rotateY: 70, opacity: 0, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
};
|
||||
};
|
||||
12
frontend/dashboard/src/components/animate/variants/index.ts
Normal file
12
frontend/dashboard/src/components/animate/variants/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export * from './path';
|
||||
export * from './fade';
|
||||
export * from './zoom';
|
||||
export * from './flip';
|
||||
export * from './slide';
|
||||
export * from './scale';
|
||||
export * from './bounce';
|
||||
export * from './rotate';
|
||||
export * from './actions';
|
||||
export * from './container';
|
||||
export * from './transition';
|
||||
export * from './background';
|
||||
14
frontend/dashboard/src/components/animate/variants/path.ts
Normal file
14
frontend/dashboard/src/components/animate/variants/path.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export const TRANSITION = {
|
||||
duration: 2,
|
||||
ease: [0.43, 0.13, 0.23, 0.96],
|
||||
};
|
||||
|
||||
export const varPath = {
|
||||
animate: {
|
||||
fillOpacity: [0, 0, 1],
|
||||
pathLength: [1, 0.4, 0],
|
||||
transition: TRANSITION,
|
||||
},
|
||||
};
|
||||
28
frontend/dashboard/src/components/animate/variants/rotate.ts
Normal file
28
frontend/dashboard/src/components/animate/variants/rotate.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// @types
|
||||
import { VariantsType } from '../type';
|
||||
//
|
||||
import { varTranEnter, varTranExit } from './transition';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export const varRotate = (props?: VariantsType) => {
|
||||
const durationIn = props?.durationIn;
|
||||
const durationOut = props?.durationOut;
|
||||
const easeIn = props?.easeIn;
|
||||
const easeOut = props?.easeOut;
|
||||
|
||||
return {
|
||||
// IN
|
||||
in: {
|
||||
initial: { opacity: 0, rotate: -360 },
|
||||
animate: { opacity: 1, rotate: 0, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { opacity: 0, rotate: -360, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
|
||||
// OUT
|
||||
out: {
|
||||
initial: { opacity: 1, rotate: 0 },
|
||||
animate: { opacity: 0, rotate: -360, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
};
|
||||
};
|
||||
37
frontend/dashboard/src/components/animate/variants/scale.ts
Normal file
37
frontend/dashboard/src/components/animate/variants/scale.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// @types
|
||||
import { VariantsType } from '../type';
|
||||
//
|
||||
import { varTranEnter, varTranExit } from './transition';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export const varScale = (props?: VariantsType) => {
|
||||
const durationIn = props?.durationIn;
|
||||
const durationOut = props?.durationOut;
|
||||
const easeIn = props?.easeIn;
|
||||
const easeOut = props?.easeOut;
|
||||
|
||||
return {
|
||||
// IN
|
||||
inX: {
|
||||
initial: { scaleX: 0, opacity: 0 },
|
||||
animate: { scaleX: 1, opacity: 1, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { scaleX: 0, opacity: 0, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
inY: {
|
||||
initial: { scaleY: 0, opacity: 0 },
|
||||
animate: { scaleY: 1, opacity: 1, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { scaleY: 0, opacity: 0, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
|
||||
// OUT
|
||||
outX: {
|
||||
initial: { scaleX: 1, opacity: 1 },
|
||||
animate: { scaleX: 0, opacity: 0, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
},
|
||||
outY: {
|
||||
initial: { scaleY: 1, opacity: 1 },
|
||||
animate: { scaleY: 0, opacity: 0, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
},
|
||||
};
|
||||
};
|
||||
60
frontend/dashboard/src/components/animate/variants/slide.ts
Normal file
60
frontend/dashboard/src/components/animate/variants/slide.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// @types
|
||||
import { VariantsType } from '../type';
|
||||
//
|
||||
import { varTranEnter, varTranExit } from './transition';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export const varSlide = (props?: VariantsType) => {
|
||||
const distance = props?.distance || 160;
|
||||
const durationIn = props?.durationIn;
|
||||
const durationOut = props?.durationOut;
|
||||
const easeIn = props?.easeIn;
|
||||
const easeOut = props?.easeOut;
|
||||
|
||||
return {
|
||||
// IN
|
||||
inUp: {
|
||||
initial: { y: distance },
|
||||
animate: { y: 0, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { y: distance, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
inDown: {
|
||||
initial: { y: -distance },
|
||||
animate: { y: 0, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { y: -distance, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
inLeft: {
|
||||
initial: { x: -distance },
|
||||
animate: { x: 0, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { x: -distance, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
inRight: {
|
||||
initial: { x: distance },
|
||||
animate: { x: 0, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { x: distance, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
|
||||
// OUT
|
||||
outUp: {
|
||||
initial: { y: 0 },
|
||||
animate: { y: -distance, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { y: 0, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
outDown: {
|
||||
initial: { y: 0 },
|
||||
animate: { y: distance, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { y: 0, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
outLeft: {
|
||||
initial: { x: 0 },
|
||||
animate: { x: -distance, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { x: 0, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
outRight: {
|
||||
initial: { x: 0 },
|
||||
animate: { x: distance, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { x: 0, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
// @types
|
||||
import { TranHoverType, TranEnterType, TranExitType } from '../type';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export const varTranHover = (props?: TranHoverType) => {
|
||||
const duration = props?.duration || 0.32;
|
||||
const ease = props?.ease || [0.43, 0.13, 0.23, 0.96];
|
||||
|
||||
return { duration, ease };
|
||||
};
|
||||
|
||||
export const varTranEnter = (props?: TranEnterType) => {
|
||||
const duration = props?.durationIn || 0.64;
|
||||
const ease = props?.easeIn || [0.43, 0.13, 0.23, 0.96];
|
||||
|
||||
return { duration, ease };
|
||||
};
|
||||
|
||||
export const varTranExit = (props?: TranExitType) => {
|
||||
const duration = props?.durationOut || 0.48;
|
||||
const ease = props?.easeOut || [0.43, 0.13, 0.23, 0.96];
|
||||
|
||||
return { duration, ease };
|
||||
};
|
||||
125
frontend/dashboard/src/components/animate/variants/zoom.ts
Normal file
125
frontend/dashboard/src/components/animate/variants/zoom.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// @types
|
||||
import { VariantsType } from '../type';
|
||||
//
|
||||
import { varTranEnter, varTranExit } from './transition';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export const varZoom = (props?: VariantsType) => {
|
||||
const distance = props?.distance || 720;
|
||||
const durationIn = props?.durationIn;
|
||||
const durationOut = props?.durationOut;
|
||||
const easeIn = props?.easeIn;
|
||||
const easeOut = props?.easeOut;
|
||||
|
||||
return {
|
||||
// IN
|
||||
in: {
|
||||
initial: { scale: 0, opacity: 0 },
|
||||
animate: { scale: 1, opacity: 1, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
exit: { scale: 0, opacity: 0, transition: varTranExit({ durationOut, easeOut }) },
|
||||
},
|
||||
inUp: {
|
||||
initial: { scale: 0, opacity: 0, translateY: distance },
|
||||
animate: {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
translateY: 0,
|
||||
transition: varTranEnter({ durationIn, easeIn }),
|
||||
},
|
||||
exit: {
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
translateY: distance,
|
||||
transition: varTranExit({ durationOut, easeOut }),
|
||||
},
|
||||
},
|
||||
inDown: {
|
||||
initial: { scale: 0, opacity: 0, translateY: -distance },
|
||||
animate: {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
translateY: 0,
|
||||
transition: varTranEnter({ durationIn, easeIn }),
|
||||
},
|
||||
exit: {
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
translateY: -distance,
|
||||
transition: varTranExit({ durationOut, easeOut }),
|
||||
},
|
||||
},
|
||||
inLeft: {
|
||||
initial: { scale: 0, opacity: 0, translateX: -distance },
|
||||
animate: {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
translateX: 0,
|
||||
transition: varTranEnter({ durationIn, easeIn }),
|
||||
},
|
||||
exit: {
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
translateX: -distance,
|
||||
transition: varTranExit({ durationOut, easeOut }),
|
||||
},
|
||||
},
|
||||
inRight: {
|
||||
initial: { scale: 0, opacity: 0, translateX: distance },
|
||||
animate: {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
translateX: 0,
|
||||
transition: varTranEnter({ durationIn, easeIn }),
|
||||
},
|
||||
exit: {
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
translateX: distance,
|
||||
transition: varTranExit({ durationOut, easeOut }),
|
||||
},
|
||||
},
|
||||
|
||||
// OUT
|
||||
out: {
|
||||
initial: { scale: 1, opacity: 1 },
|
||||
animate: { scale: 0, opacity: 0, transition: varTranEnter({ durationIn, easeIn }) },
|
||||
},
|
||||
outUp: {
|
||||
initial: { scale: 1, opacity: 1 },
|
||||
animate: {
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
translateY: -distance,
|
||||
transition: varTranEnter({ durationIn, easeIn }),
|
||||
},
|
||||
},
|
||||
outDown: {
|
||||
initial: { scale: 1, opacity: 1 },
|
||||
animate: {
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
translateY: distance,
|
||||
transition: varTranEnter({ durationIn, easeIn }),
|
||||
},
|
||||
},
|
||||
outLeft: {
|
||||
initial: { scale: 1, opacity: 1 },
|
||||
animate: {
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
translateX: -distance,
|
||||
transition: varTranEnter({ durationIn, easeIn }),
|
||||
},
|
||||
},
|
||||
outRight: {
|
||||
initial: { scale: 1, opacity: 1 },
|
||||
animate: {
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
translateX: distance,
|
||||
transition: varTranEnter({ durationIn, easeIn }),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
177
frontend/dashboard/src/components/editor/EditorToolbar.tsx
Normal file
177
frontend/dashboard/src/components/editor/EditorToolbar.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { Quill } from 'react-quill';
|
||||
// components
|
||||
import Iconify from '../Iconify';
|
||||
//
|
||||
import EditorToolbarStyle from './EditorToolbarStyle';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const FONT_FAMILY = ['Arial', 'Tahoma', 'Georgia', 'Impact', 'Verdana'];
|
||||
|
||||
const FONT_SIZE = [
|
||||
'8px',
|
||||
'9px',
|
||||
'10px',
|
||||
'12px',
|
||||
'14px',
|
||||
'16px',
|
||||
'20px',
|
||||
'24px',
|
||||
'32px',
|
||||
'42px',
|
||||
'54px',
|
||||
'68px',
|
||||
'84px',
|
||||
'98px',
|
||||
];
|
||||
const HEADINGS = ['Heading 1', 'Heading 2', 'Heading 3', 'Heading 4', 'Heading 5', 'Heading 6'];
|
||||
|
||||
export function undoChange() {
|
||||
// @ts-ignore
|
||||
this.quill.history.undo();
|
||||
}
|
||||
export function redoChange() {
|
||||
// @ts-ignore
|
||||
this.quill.history.redo();
|
||||
}
|
||||
|
||||
const Size = Quill.import('attributors/style/size');
|
||||
Size.whitelist = FONT_SIZE;
|
||||
Quill.register(Size, true);
|
||||
|
||||
const Font = Quill.import('attributors/style/font');
|
||||
Font.whitelist = FONT_FAMILY;
|
||||
Quill.register(Font, true);
|
||||
|
||||
export const formats = [
|
||||
'align',
|
||||
'background',
|
||||
'blockquote',
|
||||
'bold',
|
||||
'bullet',
|
||||
'code',
|
||||
'code-block',
|
||||
'color',
|
||||
'direction',
|
||||
'font',
|
||||
'formula',
|
||||
'header',
|
||||
'image',
|
||||
'indent',
|
||||
'italic',
|
||||
'link',
|
||||
'list',
|
||||
'script',
|
||||
'size',
|
||||
'strike',
|
||||
'table',
|
||||
'underline',
|
||||
'video',
|
||||
];
|
||||
|
||||
type EditorToolbarProps = {
|
||||
id: string;
|
||||
isSimple?: boolean;
|
||||
};
|
||||
|
||||
export default function EditorToolbar({ id, isSimple, ...other }: EditorToolbarProps) {
|
||||
return (
|
||||
<EditorToolbarStyle {...other}>
|
||||
<div id={id}>
|
||||
<div className="ql-formats">
|
||||
{!isSimple && (
|
||||
<select className="ql-font" defaultValue="">
|
||||
<option value="">Font</option>
|
||||
{FONT_FAMILY.map((font) => (
|
||||
<option key={font} value={font}>
|
||||
{font}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{!isSimple && (
|
||||
<select className="ql-size" defaultValue="16px">
|
||||
{FONT_SIZE.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<select className="ql-header" defaultValue="">
|
||||
{HEADINGS.map((heading, index) => (
|
||||
<option key={heading} value={index + 1}>
|
||||
{heading}
|
||||
</option>
|
||||
))}
|
||||
<option value="">Normal</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="ql-formats">
|
||||
<button type="button" className="ql-bold" />
|
||||
<button type="button" className="ql-italic" />
|
||||
<button type="button" className="ql-underline" />
|
||||
<button type="button" className="ql-strike" />
|
||||
</div>
|
||||
|
||||
{!isSimple && (
|
||||
<div className="ql-formats">
|
||||
<select className="ql-color" />
|
||||
<select className="ql-background" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ql-formats">
|
||||
<button type="button" className="ql-list" value="ordered" />
|
||||
<button type="button" className="ql-list" value="bullet" />
|
||||
{!isSimple && <button type="button" className="ql-indent" value="-1" />}
|
||||
{!isSimple && <button type="button" className="ql-indent" value="+1" />}
|
||||
</div>
|
||||
|
||||
{!isSimple && (
|
||||
<div className="ql-formats">
|
||||
<button type="button" className="ql-script" value="super" />
|
||||
<button type="button" className="ql-script" value="sub" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSimple && (
|
||||
<div className="ql-formats">
|
||||
<button type="button" className="ql-code-block" />
|
||||
<button type="button" className="ql-blockquote" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ql-formats">
|
||||
<button type="button" className="ql-direction" value="rtl" />
|
||||
<select className="ql-align" />
|
||||
</div>
|
||||
|
||||
<div className="ql-formats">
|
||||
<button type="button" className="ql-link" />
|
||||
<button type="button" className="ql-image" />
|
||||
<button type="button" className="ql-video" />
|
||||
</div>
|
||||
|
||||
<div className="ql-formats">
|
||||
{!isSimple && <button type="button" className="ql-formula" />}
|
||||
<button type="button" className="ql-clean" />
|
||||
</div>
|
||||
|
||||
{!isSimple && (
|
||||
<div className="ql-formats">
|
||||
<button type="button" className="ql-undo">
|
||||
<Iconify icon={'ic:round-undo'} width={18} height={18} />
|
||||
</button>
|
||||
<button type="button" className="ql-redo">
|
||||
<Iconify icon={'ic:round-redo'} width={18} height={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</EditorToolbarStyle>
|
||||
);
|
||||
}
|
||||
133
frontend/dashboard/src/components/editor/EditorToolbarStyle.tsx
Normal file
133
frontend/dashboard/src/components/editor/EditorToolbarStyle.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const EditorToolbarStyle = styled('div')(({ theme }) => {
|
||||
const isRTL = theme.direction === 'rtl';
|
||||
|
||||
return {
|
||||
'& .ql-snow.ql-toolbar button:hover .ql-fill, .ql-snow .ql-toolbar button:hover .ql-fill, .ql-snow.ql-toolbar button:focus .ql-fill, .ql-snow .ql-toolbar button:focus .ql-fill, .ql-snow.ql-toolbar button.ql-active .ql-fill, .ql-snow .ql-toolbar button.ql-active .ql-fill, .ql-snow.ql-toolbar .ql-picker-label:hover .ql-fill, .ql-snow .ql-toolbar .ql-picker-label:hover .ql-fill, .ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-fill, .ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-fill, .ql-snow.ql-toolbar .ql-picker-item:hover .ql-fill, .ql-snow .ql-toolbar .ql-picker-item:hover .ql-fill, .ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-fill, .ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-fill, .ql-snow.ql-toolbar button:hover .ql-stroke.ql-fill, .ql-snow .ql-toolbar button:hover .ql-stroke.ql-fill, .ql-snow.ql-toolbar button:focus .ql-stroke.ql-fill, .ql-snow .ql-toolbar button:focus .ql-stroke.ql-fill, .ql-snow.ql-toolbar button.ql-active .ql-stroke.ql-fill, .ql-snow .ql-toolbar button.ql-active .ql-stroke.ql-fill, .ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke.ql-fill, .ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke.ql-fill, .ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke.ql-fill, .ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke.ql-fill, .ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke.ql-fill, .ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke.ql-fill, .ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill, .ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill':
|
||||
{
|
||||
fill: theme.palette.primary.main,
|
||||
},
|
||||
'& .ql-snow.ql-toolbar button:hover, .ql-snow .ql-toolbar button:hover, .ql-snow.ql-toolbar button:focus, .ql-snow .ql-toolbar button:focus, .ql-snow.ql-toolbar button.ql-active, .ql-snow .ql-toolbar button.ql-active, .ql-snow.ql-toolbar .ql-picker-label:hover, .ql-snow .ql-toolbar .ql-picker-label:hover, .ql-snow.ql-toolbar .ql-picker-label.ql-active, .ql-snow .ql-toolbar .ql-picker-label.ql-active, .ql-snow.ql-toolbar .ql-picker-item:hover, .ql-snow .ql-toolbar .ql-picker-item:hover, .ql-snow.ql-toolbar .ql-picker-item.ql-selected, .ql-snow .ql-toolbar .ql-picker-item.ql-selected':
|
||||
{
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
'& .ql-snow.ql-toolbar button:hover .ql-stroke, .ql-snow .ql-toolbar button:hover .ql-stroke, .ql-snow.ql-toolbar button:focus .ql-stroke, .ql-snow .ql-toolbar button:focus .ql-stroke, .ql-snow.ql-toolbar button.ql-active .ql-stroke, .ql-snow .ql-toolbar button.ql-active .ql-stroke, .ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke, .ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke, .ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke, .ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke, .ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke, .ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke, .ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke, .ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke, .ql-snow.ql-toolbar button:hover .ql-stroke-miter, .ql-snow .ql-toolbar button:hover .ql-stroke-miter, .ql-snow.ql-toolbar button:focus .ql-stroke-miter, .ql-snow .ql-toolbar button:focus .ql-stroke-miter, .ql-snow.ql-toolbar button.ql-active .ql-stroke-miter, .ql-snow .ql-toolbar button.ql-active .ql-stroke-miter, .ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke-miter, .ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke-miter, .ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke-miter, .ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke-miter, .ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke-miter, .ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke-miter, .ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter, .ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter':
|
||||
{
|
||||
stroke: theme.palette.primary.main,
|
||||
},
|
||||
'& .ql-stroke': {
|
||||
stroke: theme.palette.text.primary,
|
||||
},
|
||||
'& .ql-fill, .ql-stroke.ql-fill': {
|
||||
fill: theme.palette.text.primary,
|
||||
},
|
||||
'& .ql-picker, .ql-picker-options, .ql-picker-item, .ql-picker-label, button': {
|
||||
'&:focus': { outline: 'none' },
|
||||
},
|
||||
'& .ql-toolbar.ql-snow': {
|
||||
border: 'none',
|
||||
borderBottom: `solid 1px ${theme.palette.grey[500_32]}`,
|
||||
'& .ql-formats': {
|
||||
'&:not(:last-of-type)': {
|
||||
marginRight: theme.spacing(2),
|
||||
},
|
||||
},
|
||||
|
||||
// Button
|
||||
'& button': {
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 4,
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
|
||||
// Icon svg
|
||||
'& button svg, span svg': {
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
|
||||
// Select
|
||||
'& .ql-picker-label': {
|
||||
...theme.typography.subtitle2,
|
||||
color: theme.palette.text.primary,
|
||||
'& .ql-stroke': {
|
||||
stroke: theme.palette.text.primary,
|
||||
},
|
||||
},
|
||||
'& .ql-picker-label svg': {
|
||||
...(isRTL && {
|
||||
right: '0 !important',
|
||||
left: 'auto !important',
|
||||
}),
|
||||
},
|
||||
'& .ql-color,& .ql-background,& .ql-align ': {
|
||||
'& .ql-picker-label': {
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
},
|
||||
'& .ql-expanded': {
|
||||
'& .ql-picker-label': {
|
||||
borderRadius: 4,
|
||||
color: theme.palette.text.disabled,
|
||||
borderColor: 'transparent !important',
|
||||
backgroundColor: theme.palette.action.focus,
|
||||
'& .ql-stroke': { stroke: theme.palette.text.disabled },
|
||||
},
|
||||
'& .ql-picker-options': {
|
||||
padding: 0,
|
||||
marginTop: 4,
|
||||
border: 'none',
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
boxShadow: theme.customShadows.z20,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
},
|
||||
'& .ql-picker-item': {
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
|
||||
// Align
|
||||
'&.ql-align': {
|
||||
'& .ql-picker-options': { padding: 0, display: 'flex' },
|
||||
'& .ql-picker-item': {
|
||||
width: 32,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
},
|
||||
// Color & Background
|
||||
'&.ql-color, &.ql-background': {
|
||||
'& .ql-picker-options': { padding: 8 },
|
||||
'& .ql-picker-item': {
|
||||
margin: 3,
|
||||
borderRadius: 4,
|
||||
'&.ql-selected': { border: 'solid 1px black' },
|
||||
},
|
||||
},
|
||||
// Font, Size, Header
|
||||
'&.ql-font, &.ql-size, &.ql-header': {
|
||||
'& .ql-picker-options': {
|
||||
padding: theme.spacing(1, 0),
|
||||
},
|
||||
'& .ql-picker-item': {
|
||||
padding: theme.spacing(0.5, 1.5),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default EditorToolbarStyle;
|
||||
97
frontend/dashboard/src/components/editor/index.tsx
Normal file
97
frontend/dashboard/src/components/editor/index.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { ReactNode } from 'react';
|
||||
import ReactQuill, { ReactQuillProps } from 'react-quill';
|
||||
// @mui
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { Box, BoxProps } from '@mui/material';
|
||||
//
|
||||
import EditorToolbar, { formats, redoChange, undoChange } from './EditorToolbar';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const RootStyle = styled(Box)(({ theme }) => ({
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
border: `solid 1px ${theme.palette.grey[500_32]}`,
|
||||
'& .ql-container.ql-snow': {
|
||||
borderColor: 'transparent',
|
||||
...theme.typography.body1,
|
||||
fontFamily: theme.typography.fontFamily,
|
||||
},
|
||||
'& .ql-editor': {
|
||||
minHeight: 200,
|
||||
'&.ql-blank::before': {
|
||||
fontStyle: 'normal',
|
||||
color: theme.palette.text.disabled,
|
||||
},
|
||||
'& pre.ql-syntax': {
|
||||
...theme.typography.body2,
|
||||
padding: theme.spacing(2),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: theme.palette.grey[900],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export interface Props extends ReactQuillProps {
|
||||
id?: string;
|
||||
error?: boolean;
|
||||
simple?: boolean;
|
||||
helperText?: ReactNode;
|
||||
sx?: BoxProps;
|
||||
}
|
||||
|
||||
export default function Editor({
|
||||
id = 'minimal-quill',
|
||||
error,
|
||||
value,
|
||||
onChange,
|
||||
simple = false,
|
||||
helperText,
|
||||
sx,
|
||||
...other
|
||||
}: Props) {
|
||||
const modules = {
|
||||
toolbar: {
|
||||
container: `#${id}`,
|
||||
handlers: {
|
||||
undo: undoChange,
|
||||
redo: redoChange,
|
||||
},
|
||||
},
|
||||
history: {
|
||||
delay: 500,
|
||||
maxStack: 100,
|
||||
userOnly: true,
|
||||
},
|
||||
syntax: true,
|
||||
clipboard: {
|
||||
matchVisual: false,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<RootStyle
|
||||
sx={{
|
||||
...(error && {
|
||||
border: (theme) => `solid 1px ${theme.palette.error.main}`,
|
||||
}),
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<EditorToolbar id={id} isSimple={simple} />
|
||||
<ReactQuill
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
modules={modules}
|
||||
formats={formats}
|
||||
placeholder="Write something awesome..."
|
||||
{...other}
|
||||
/>
|
||||
</RootStyle>
|
||||
|
||||
{helperText && helperText}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
frontend/dashboard/src/components/hook-form/FormProvider.tsx
Normal file
19
frontend/dashboard/src/components/hook-form/FormProvider.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ReactNode } from 'react';
|
||||
// form
|
||||
import { FormProvider as Form, UseFormReturn } from 'react-hook-form';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
methods: UseFormReturn<any>;
|
||||
onSubmit?: VoidFunction;
|
||||
};
|
||||
|
||||
export default function FormProvider({ children, onSubmit, methods }: Props) {
|
||||
return (
|
||||
<Form {...methods}>
|
||||
<form onSubmit={onSubmit}>{children}</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
69
frontend/dashboard/src/components/hook-form/RHFCheckbox.tsx
Normal file
69
frontend/dashboard/src/components/hook-form/RHFCheckbox.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
// form
|
||||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
// @mui
|
||||
import { Checkbox, FormControlLabel, FormGroup, FormControlLabelProps } from '@mui/material';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
interface RHFCheckboxProps extends Omit<FormControlLabelProps, 'control'> {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function RHFCheckbox({ name, ...other }: RHFCheckboxProps) {
|
||||
const { control } = useFormContext();
|
||||
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field }) => <Checkbox {...field} checked={field.value} />}
|
||||
/>
|
||||
}
|
||||
{...other}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
interface RHFMultiCheckboxProps extends Omit<FormControlLabelProps, 'control' | 'label'> {
|
||||
name: string;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
export function RHFMultiCheckbox({ name, options, ...other }: RHFMultiCheckboxProps) {
|
||||
const { control } = useFormContext();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
const onSelected = (option: string) =>
|
||||
field.value.includes(option)
|
||||
? field.value.filter((value: string) => value !== option)
|
||||
: [...field.value, option];
|
||||
|
||||
return (
|
||||
<FormGroup>
|
||||
{options.map((option) => (
|
||||
<FormControlLabel
|
||||
key={option}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={field.value.includes(option)}
|
||||
onChange={() => field.onChange(onSelected(option))}
|
||||
/>
|
||||
}
|
||||
label={option}
|
||||
{...other}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
37
frontend/dashboard/src/components/hook-form/RHFEditor.tsx
Normal file
37
frontend/dashboard/src/components/hook-form/RHFEditor.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
// form
|
||||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
// @mui
|
||||
import { FormHelperText } from '@mui/material';
|
||||
//
|
||||
import Editor, { Props as EditorProps } from '../editor';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
interface Props extends EditorProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function RHFEditor({ name, ...other }: Props) {
|
||||
const { control } = useFormContext();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<Editor
|
||||
id={name}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
error={!!error}
|
||||
helperText={
|
||||
<FormHelperText error sx={{ px: 2, textTransform: 'capitalize' }}>
|
||||
{error?.message}
|
||||
</FormHelperText>
|
||||
}
|
||||
{...other}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// form
|
||||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
// @mui
|
||||
import {
|
||||
Radio,
|
||||
RadioGroup,
|
||||
FormHelperText,
|
||||
RadioGroupProps,
|
||||
FormControlLabel,
|
||||
} from '@mui/material';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
interface IProps {
|
||||
name: string;
|
||||
options: string[];
|
||||
getOptionLabel?: string[];
|
||||
}
|
||||
|
||||
export default function RHFRadioGroup({
|
||||
name,
|
||||
options,
|
||||
getOptionLabel,
|
||||
...other
|
||||
}: IProps & RadioGroupProps) {
|
||||
const { control } = useFormContext();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<div>
|
||||
<RadioGroup {...field} row {...other}>
|
||||
{options.map((option, index) => (
|
||||
<FormControlLabel
|
||||
key={option}
|
||||
value={option}
|
||||
control={<Radio />}
|
||||
label={getOptionLabel?.length ? getOptionLabel[index] : option}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
|
||||
{!!error && (
|
||||
<FormHelperText error sx={{ px: 2 }}>
|
||||
{error.message}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
35
frontend/dashboard/src/components/hook-form/RHFSelect.tsx
Normal file
35
frontend/dashboard/src/components/hook-form/RHFSelect.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
// form
|
||||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
// @mui
|
||||
import { TextField, TextFieldProps } from '@mui/material';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
interface IProps {
|
||||
name: string;
|
||||
children: any;
|
||||
}
|
||||
|
||||
export default function RHFSelect({ name, children, ...other }: IProps & TextFieldProps) {
|
||||
const { control } = useFormContext();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
select
|
||||
fullWidth
|
||||
SelectProps={{ native: true }}
|
||||
error={!!error}
|
||||
helperText={error?.message}
|
||||
{...other}
|
||||
>
|
||||
{children}
|
||||
</TextField>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
29
frontend/dashboard/src/components/hook-form/RHFSwitch.tsx
Normal file
29
frontend/dashboard/src/components/hook-form/RHFSwitch.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
// form
|
||||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
// @mui
|
||||
import { Switch, FormControlLabel, FormControlLabelProps } from '@mui/material';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type IProps = Omit<FormControlLabelProps, 'control'>;
|
||||
|
||||
interface Props extends IProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function RHFSwitch({ name, ...other }: Props) {
|
||||
const { control } = useFormContext();
|
||||
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field }) => <Switch {...field} checked={field.value} />}
|
||||
/>
|
||||
}
|
||||
{...other}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
frontend/dashboard/src/components/hook-form/RHFTextField.tsx
Normal file
24
frontend/dashboard/src/components/hook-form/RHFTextField.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
// form
|
||||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
// @mui
|
||||
import { TextField, TextFieldProps } from '@mui/material';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
interface IProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function RHFTextField({ name, ...other }: IProps & TextFieldProps) {
|
||||
const { control } = useFormContext();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<TextField {...field} fullWidth error={!!error} helperText={error?.message} {...other} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
111
frontend/dashboard/src/components/hook-form/RHFUpload.tsx
Normal file
111
frontend/dashboard/src/components/hook-form/RHFUpload.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
// form
|
||||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
// @mui
|
||||
import { FormHelperText } from '@mui/material';
|
||||
// type
|
||||
import {
|
||||
UploadAvatar,
|
||||
UploadMultiFile,
|
||||
UploadSingleFile,
|
||||
UploadProps,
|
||||
UploadMultiFileProps,
|
||||
} from '../upload';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
interface Props extends Omit<UploadProps, 'file'> {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function RHFUploadAvatar({ name, ...other }: Props) {
|
||||
const { control } = useFormContext();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => {
|
||||
const checkError = !!error && !field.value;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<UploadAvatar error={checkError} {...other} file={field.value} />
|
||||
{checkError && (
|
||||
<FormHelperText error sx={{ px: 2, textAlign: 'center' }}>
|
||||
{error.message}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export function RHFUploadSingleFile({ name, ...other }: Props) {
|
||||
const { control } = useFormContext();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => {
|
||||
const checkError = !!error && !field.value;
|
||||
|
||||
return (
|
||||
<UploadSingleFile
|
||||
accept="image/*"
|
||||
file={field.value}
|
||||
error={checkError}
|
||||
helperText={
|
||||
checkError && (
|
||||
<FormHelperText error sx={{ px: 2 }}>
|
||||
{error.message}
|
||||
</FormHelperText>
|
||||
)
|
||||
}
|
||||
{...other}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
interface RHFUploadMultiFileProps extends Omit<UploadMultiFileProps, 'files'> {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function RHFUploadMultiFile({ name, ...other }: RHFUploadMultiFileProps) {
|
||||
const { control } = useFormContext();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => {
|
||||
const checkError = !!error && field.value?.length === 0;
|
||||
|
||||
return (
|
||||
<UploadMultiFile
|
||||
accept="image/*"
|
||||
files={field.value}
|
||||
error={checkError}
|
||||
helperText={
|
||||
checkError && (
|
||||
<FormHelperText error sx={{ px: 2 }}>
|
||||
{error?.message}
|
||||
</FormHelperText>
|
||||
)
|
||||
}
|
||||
{...other}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
frontend/dashboard/src/components/hook-form/index.ts
Normal file
10
frontend/dashboard/src/components/hook-form/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export * from './RHFCheckbox';
|
||||
export * from './RHFUpload';
|
||||
|
||||
export { default as FormProvider } from './FormProvider';
|
||||
|
||||
export { default as RHFSwitch } from './RHFSwitch';
|
||||
export { default as RHFSelect } from './RHFSelect';
|
||||
export { default as RHFEditor } from './RHFEditor';
|
||||
export { default as RHFTextField } from './RHFTextField';
|
||||
export { default as RHFRadioGroup } from './RHFRadioGroup';
|
||||
@@ -0,0 +1,125 @@
|
||||
import { ReactElement, forwardRef } from 'react';
|
||||
import { NavLink as RouterLink } from 'react-router-dom';
|
||||
// @mui
|
||||
import { Box, Link } from '@mui/material';
|
||||
// config
|
||||
import { ICON } from '../../../config';
|
||||
// type
|
||||
import { NavItemProps } from '../type';
|
||||
//
|
||||
import Iconify from '../../Iconify';
|
||||
import { ListItemStyle } from './style';
|
||||
import { isExternalLink } from '..';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export const NavItemRoot = forwardRef<HTMLButtonElement & HTMLAnchorElement, NavItemProps>(
|
||||
({ item, active, open, onMouseEnter, onMouseLeave }, ref) => {
|
||||
const { title, path, icon, children } = item;
|
||||
|
||||
if (children) {
|
||||
return (
|
||||
<ListItemStyle
|
||||
ref={ref}
|
||||
open={open}
|
||||
activeRoot={active}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<NavItemContent icon={icon} title={title} children={children} />
|
||||
</ListItemStyle>
|
||||
);
|
||||
}
|
||||
|
||||
return isExternalLink(path) ? (
|
||||
<ListItemStyle component={Link} href={path} target="_blank" rel="noopener">
|
||||
<NavItemContent icon={icon} title={title} children={children} />
|
||||
</ListItemStyle>
|
||||
) : (
|
||||
<ListItemStyle component={RouterLink} to={path} activeRoot={active}>
|
||||
<NavItemContent icon={icon} title={title} children={children} />
|
||||
</ListItemStyle>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export const NavItemSub = forwardRef<HTMLButtonElement & HTMLAnchorElement, NavItemProps>(
|
||||
({ item, active, open, onMouseEnter, onMouseLeave }, ref) => {
|
||||
const { title, path, icon, children } = item;
|
||||
|
||||
if (children) {
|
||||
return (
|
||||
<ListItemStyle
|
||||
ref={ref}
|
||||
subItem
|
||||
disableRipple
|
||||
open={open}
|
||||
activeSub={active}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<NavItemContent icon={icon} title={title} children={children} subItem />
|
||||
</ListItemStyle>
|
||||
);
|
||||
}
|
||||
|
||||
return isExternalLink(path) ? (
|
||||
<ListItemStyle
|
||||
subItem
|
||||
href={path}
|
||||
disableRipple
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
component={Link}
|
||||
>
|
||||
<NavItemContent icon={icon} title={title} children={children} subItem />
|
||||
</ListItemStyle>
|
||||
) : (
|
||||
<ListItemStyle disableRipple component={RouterLink} to={path} activeSub={active} subItem>
|
||||
<NavItemContent icon={icon} title={title} children={children} subItem />
|
||||
</ListItemStyle>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type NavItemContentProps = {
|
||||
title: string;
|
||||
icon?: ReactElement;
|
||||
children?: { title: string; path: string }[];
|
||||
subItem?: boolean;
|
||||
};
|
||||
|
||||
function NavItemContent({ icon, title, children, subItem }: NavItemContentProps) {
|
||||
return (
|
||||
<>
|
||||
{icon && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
mr: 1,
|
||||
width: ICON.NAVBAR_ITEM_HORIZONTAL,
|
||||
height: ICON.NAVBAR_ITEM_HORIZONTAL,
|
||||
'& svg': { width: '100%', height: '100%' },
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
{title}
|
||||
{children && (
|
||||
<Iconify
|
||||
icon={subItem ? 'eva:chevron-right-fill' : 'eva:chevron-down-fill'}
|
||||
sx={{
|
||||
ml: 0.5,
|
||||
width: ICON.NAVBAR_ITEM_HORIZONTAL,
|
||||
height: ICON.NAVBAR_ITEM_HORIZONTAL,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
// type
|
||||
import { NavListProps } from '../type';
|
||||
//
|
||||
import { NavItemRoot, NavItemSub } from './NavItem';
|
||||
import { PaperStyle } from './style';
|
||||
import { getActive } from '..';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type NavListRootProps = {
|
||||
list: NavListProps;
|
||||
};
|
||||
|
||||
export function NavListRoot({ list }: NavListRootProps) {
|
||||
const menuRef = useRef(null);
|
||||
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const active = getActive(list.path, pathname);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const hasChildren = list.children;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
handleClose();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname]);
|
||||
|
||||
const handleOpen = () => {
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<>
|
||||
<NavItemRoot
|
||||
open={open}
|
||||
item={list}
|
||||
active={active}
|
||||
ref={menuRef}
|
||||
onMouseEnter={handleOpen}
|
||||
onMouseLeave={handleClose}
|
||||
/>
|
||||
|
||||
<PaperStyle
|
||||
open={open}
|
||||
anchorEl={menuRef.current}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||
PaperProps={{
|
||||
onMouseEnter: handleOpen,
|
||||
onMouseLeave: handleClose,
|
||||
}}
|
||||
>
|
||||
{(list.children || []).map((item) => (
|
||||
<NavListSub key={item.title} list={item} />
|
||||
))}
|
||||
</PaperStyle>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <NavItemRoot item={list} active={active} />;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type NavListSubProps = {
|
||||
list: NavListProps;
|
||||
};
|
||||
|
||||
function NavListSub({ list }: NavListSubProps) {
|
||||
const menuRef = useRef(null);
|
||||
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const active = getActive(list.path, pathname);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleOpen = () => {
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const hasChildren = list.children;
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<>
|
||||
<NavItemSub
|
||||
ref={menuRef}
|
||||
open={open}
|
||||
item={list}
|
||||
active={active}
|
||||
onMouseEnter={handleOpen}
|
||||
onMouseLeave={handleClose}
|
||||
/>
|
||||
|
||||
<PaperStyle
|
||||
open={open}
|
||||
anchorEl={menuRef.current}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||
PaperProps={{
|
||||
onMouseEnter: handleOpen,
|
||||
onMouseLeave: handleClose,
|
||||
}}
|
||||
>
|
||||
{(list.children || []).map((item) => (
|
||||
<NavListSub key={item.title} list={item} />
|
||||
))}
|
||||
</PaperStyle>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <NavItemSub item={list} active={active} />;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { memo } from 'react';
|
||||
// @mui
|
||||
import { Stack } from '@mui/material';
|
||||
// type
|
||||
import { NavSectionProps } from '../type';
|
||||
//
|
||||
import { NavListRoot } from './NavList';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const hideScrollbar = {
|
||||
msOverflowStyle: 'none',
|
||||
scrollbarWidth: 'none',
|
||||
overflowY: 'scroll',
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
} as const;
|
||||
|
||||
function NavSectionHorizontal({ navConfig }: NavSectionProps) {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="center"
|
||||
sx={{ bgcolor: 'background.neutral', borderRadius: 1, px: 0.5 }}
|
||||
>
|
||||
<Stack direction="row" sx={{ ...hideScrollbar, py: 1 }}>
|
||||
{navConfig.map((group) => (
|
||||
<Stack key={group.subheader} direction="row" flexShrink={0}>
|
||||
{group.items.map((list) => (
|
||||
<NavListRoot key={list.title} list={list} />
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(NavSectionHorizontal);
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ReactNode } from 'react';
|
||||
// @mui
|
||||
import { alpha, styled } from '@mui/material/styles';
|
||||
import { Button, Popover, ButtonProps, LinkProps } from '@mui/material';
|
||||
// config
|
||||
import { NAVBAR } from '../../../config';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type IProps = LinkProps & ButtonProps;
|
||||
|
||||
interface ListItemStyleProps extends IProps {
|
||||
component?: ReactNode;
|
||||
to?: string;
|
||||
activeRoot?: boolean;
|
||||
activeSub?: boolean;
|
||||
subItem?: boolean;
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
export const ListItemStyle = styled(Button, {
|
||||
shouldForwardProp: (prop) =>
|
||||
prop !== 'activeRoot' && prop !== 'activeSub' && prop !== 'subItem' && prop !== 'open',
|
||||
})<ListItemStyleProps>(({ activeRoot, activeSub, subItem, open, theme }) => {
|
||||
const isLight = theme.palette.mode === 'light';
|
||||
|
||||
const activeRootStyle = {
|
||||
color: theme.palette.grey[800],
|
||||
backgroundColor: theme.palette.common.white,
|
||||
boxShadow: `-2px 4px 6px 0 ${alpha(
|
||||
isLight ? theme.palette.grey[500] : theme.palette.common.black,
|
||||
0.16
|
||||
)}`,
|
||||
};
|
||||
|
||||
return {
|
||||
...theme.typography.body2,
|
||||
margin: theme.spacing(0, 0.5),
|
||||
padding: theme.spacing(0, 1),
|
||||
color: theme.palette.text.secondary,
|
||||
height: NAVBAR.DASHBOARD_ITEM_HORIZONTAL_HEIGHT,
|
||||
'&:hover': {
|
||||
color: theme.palette.text.primary,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
},
|
||||
// activeRoot
|
||||
...(activeRoot && {
|
||||
...theme.typography.subtitle2,
|
||||
...activeRootStyle,
|
||||
'&:hover': { ...activeRootStyle },
|
||||
}),
|
||||
// activeSub
|
||||
...(activeSub && {
|
||||
...theme.typography.subtitle2,
|
||||
color: theme.palette.text.primary,
|
||||
}),
|
||||
// subItem
|
||||
...(subItem && {
|
||||
width: '100%',
|
||||
margin: 0,
|
||||
paddingRight: 0,
|
||||
paddingLeft: theme.spacing(1),
|
||||
justifyContent: 'space-between',
|
||||
}),
|
||||
// open
|
||||
...(open &&
|
||||
!activeRoot && {
|
||||
color: theme.palette.text.primary,
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export const PaperStyle = styled(Popover)(({ theme }) => ({
|
||||
pointerEvents: 'none',
|
||||
'& .MuiPopover-paper': {
|
||||
width: 160,
|
||||
pointerEvents: 'auto',
|
||||
padding: theme.spacing(1),
|
||||
borderRadius: Number(theme.shape.borderRadius) * 1.5,
|
||||
boxShadow: theme.customShadows.dropdown,
|
||||
},
|
||||
}));
|
||||
14
frontend/dashboard/src/components/nav-section/index.ts
Normal file
14
frontend/dashboard/src/components/nav-section/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { matchPath } from 'react-router-dom';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export { default as NavSectionVertical } from './vertical';
|
||||
export { default as NavSectionHorizontal } from './horizontal';
|
||||
|
||||
export function isExternalLink(path: string) {
|
||||
return path.includes('http');
|
||||
}
|
||||
|
||||
export function getActive(path: string, pathname: string) {
|
||||
return path ? !!matchPath({ path: path, end: false }, pathname) : false;
|
||||
}
|
||||
34
frontend/dashboard/src/components/nav-section/type.ts
Normal file
34
frontend/dashboard/src/components/nav-section/type.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { BoxProps } from '@mui/material';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export type NavListProps = {
|
||||
title: string;
|
||||
path: string;
|
||||
icon?: ReactElement;
|
||||
info?: ReactElement;
|
||||
children?: {
|
||||
title: string;
|
||||
path: string;
|
||||
children?: { title: string; path: string }[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export type NavItemProps = {
|
||||
item: NavListProps;
|
||||
isCollapse?: boolean;
|
||||
active?: boolean | undefined;
|
||||
open?: boolean;
|
||||
onOpen?: VoidFunction;
|
||||
onMouseEnter?: VoidFunction;
|
||||
onMouseLeave?: VoidFunction;
|
||||
};
|
||||
|
||||
export interface NavSectionProps extends BoxProps {
|
||||
isCollapse?: boolean;
|
||||
navConfig: {
|
||||
subheader: string;
|
||||
items: NavListProps[];
|
||||
}[];
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { NavLink as RouterLink } from 'react-router-dom';
|
||||
// @mui
|
||||
import { Box, Link, ListItemText } from '@mui/material';
|
||||
// type
|
||||
import { NavItemProps } from '../type';
|
||||
//
|
||||
import Iconify from '../../Iconify';
|
||||
import { ListItemStyle, ListItemTextStyle, ListItemIconStyle } from './style';
|
||||
import { isExternalLink } from '..';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export function NavItemRoot({ item, isCollapse, open = false, active, onOpen }: NavItemProps) {
|
||||
const { title, path, icon, info, children } = item;
|
||||
|
||||
const renderContent = (
|
||||
<>
|
||||
{icon && <ListItemIconStyle>{icon}</ListItemIconStyle>}
|
||||
<ListItemTextStyle disableTypography primary={title} isCollapse={isCollapse} />
|
||||
{!isCollapse && (
|
||||
<>
|
||||
{info && info}
|
||||
{children && <ArrowIcon open={open} />}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (children) {
|
||||
return (
|
||||
<ListItemStyle onClick={onOpen} activeRoot={active}>
|
||||
{renderContent}
|
||||
</ListItemStyle>
|
||||
);
|
||||
}
|
||||
|
||||
return isExternalLink(path) ? (
|
||||
<ListItemStyle component={Link} href={path} target="_blank" rel="noopener">
|
||||
{renderContent}
|
||||
</ListItemStyle>
|
||||
) : (
|
||||
<ListItemStyle component={RouterLink} to={path} activeRoot={active}>
|
||||
{renderContent}
|
||||
</ListItemStyle>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type NavItemSubProps = Omit<NavItemProps, 'isCollapse'>;
|
||||
|
||||
export function NavItemSub({ item, open = false, active = false, onOpen }: NavItemSubProps) {
|
||||
const { title, path, info, children } = item;
|
||||
|
||||
const renderContent = (
|
||||
<>
|
||||
<DotIcon active={active} />
|
||||
<ListItemText disableTypography primary={title} />
|
||||
{info && info}
|
||||
{children && <ArrowIcon open={open} />}
|
||||
</>
|
||||
);
|
||||
|
||||
if (children) {
|
||||
return (
|
||||
<ListItemStyle onClick={onOpen} activeSub={active} subItem>
|
||||
{renderContent}
|
||||
</ListItemStyle>
|
||||
);
|
||||
}
|
||||
|
||||
return isExternalLink(path) ? (
|
||||
<ListItemStyle component={Link} href={path} target="_blank" rel="noopener" subItem>
|
||||
{renderContent}
|
||||
</ListItemStyle>
|
||||
) : (
|
||||
<ListItemStyle component={RouterLink} to={path} activeSub={active} subItem>
|
||||
{renderContent}
|
||||
</ListItemStyle>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type DotIconProps = {
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export function DotIcon({ active }: DotIconProps) {
|
||||
return (
|
||||
<ListItemIconStyle>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'text.disabled',
|
||||
transition: (theme) =>
|
||||
theme.transitions.create('transform', {
|
||||
duration: theme.transitions.duration.shorter,
|
||||
}),
|
||||
...(active && {
|
||||
transform: 'scale(2)',
|
||||
bgcolor: 'primary.main',
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</ListItemIconStyle>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type ArrowIconProps = {
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
export function ArrowIcon({ open }: ArrowIconProps) {
|
||||
return (
|
||||
<Iconify
|
||||
icon={open ? 'eva:arrow-ios-downward-fill' : 'eva:arrow-ios-forward-fill'}
|
||||
sx={{ width: 16, height: 16, ml: 1 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
// @mui
|
||||
import { List, Collapse } from '@mui/material';
|
||||
// type
|
||||
import { NavListProps } from '../type';
|
||||
//
|
||||
import { NavItemRoot, NavItemSub } from './NavItem';
|
||||
import { getActive } from '..';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type NavListRootProps = {
|
||||
list: NavListProps;
|
||||
isCollapse: boolean;
|
||||
};
|
||||
|
||||
export function NavListRoot({ list, isCollapse }: NavListRootProps) {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const active = getActive(list.path, pathname);
|
||||
|
||||
const [open, setOpen] = useState(active);
|
||||
|
||||
const hasChildren = list.children;
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<>
|
||||
<NavItemRoot
|
||||
item={list}
|
||||
isCollapse={isCollapse}
|
||||
active={active}
|
||||
open={open}
|
||||
onOpen={() => setOpen(!open)}
|
||||
/>
|
||||
|
||||
{!isCollapse && (
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
{(list.children || []).map((item) => (
|
||||
<NavListSub key={item.title} list={item} />
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <NavItemRoot item={list} active={active} isCollapse={isCollapse} />;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type NavListSubProps = {
|
||||
list: NavListProps;
|
||||
};
|
||||
|
||||
function NavListSub({ list }: NavListSubProps) {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const active = getActive(list.path, pathname);
|
||||
|
||||
const [open, setOpen] = useState(active);
|
||||
|
||||
const hasChildren = list.children;
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<>
|
||||
<NavItemSub item={list} onOpen={() => setOpen(!open)} open={open} active={active} />
|
||||
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding sx={{ pl: 3 }}>
|
||||
{(list.children || []).map((item) => (
|
||||
<NavItemSub key={item.title} item={item} active={getActive(item.path, pathname)} />
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <NavItemSub item={list} active={active} />;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// @mui
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { List, Box, ListSubheader } from '@mui/material';
|
||||
// type
|
||||
import { NavSectionProps } from '../type';
|
||||
//
|
||||
import { NavListRoot } from './NavList';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export const ListSubheaderStyle = styled((props) => (
|
||||
<ListSubheader disableSticky disableGutters {...props} />
|
||||
))(({ theme }) => ({
|
||||
...theme.typography.overline,
|
||||
paddingTop: theme.spacing(3),
|
||||
paddingLeft: theme.spacing(2),
|
||||
paddingBottom: theme.spacing(1),
|
||||
color: theme.palette.text.primary,
|
||||
transition: theme.transitions.create('opacity', {
|
||||
duration: theme.transitions.duration.shorter,
|
||||
}),
|
||||
}));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export default function NavSectionVertical({
|
||||
navConfig,
|
||||
isCollapse = false,
|
||||
...other
|
||||
}: NavSectionProps) {
|
||||
return (
|
||||
<Box {...other}>
|
||||
{navConfig.map((group) => (
|
||||
<List key={group.subheader} disablePadding sx={{ px: 2 }}>
|
||||
<ListSubheaderStyle
|
||||
sx={{
|
||||
...(isCollapse && {
|
||||
opacity: 0,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{group.subheader}
|
||||
</ListSubheaderStyle>
|
||||
|
||||
{group.items.map((list) => (
|
||||
<NavListRoot key={list.title} list={list} isCollapse={isCollapse} />
|
||||
))}
|
||||
</List>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ReactNode } from 'react';
|
||||
// @mui
|
||||
import { alpha, styled } from '@mui/material/styles';
|
||||
import {
|
||||
LinkProps,
|
||||
ListItemText,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemButtonProps,
|
||||
} from '@mui/material';
|
||||
// config
|
||||
import { ICON, NAVBAR } from '../../../config';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type IProps = LinkProps & ListItemButtonProps;
|
||||
|
||||
interface ListItemStyleProps extends IProps {
|
||||
component?: ReactNode;
|
||||
to?: string;
|
||||
activeRoot?: boolean;
|
||||
activeSub?: boolean;
|
||||
subItem?: boolean;
|
||||
}
|
||||
|
||||
export const ListItemStyle = styled(ListItemButton, {
|
||||
shouldForwardProp: (prop) => prop !== 'activeRoot' && prop !== 'activeSub' && prop !== 'subItem',
|
||||
})<ListItemStyleProps>(({ activeRoot, activeSub, subItem, theme }) => ({
|
||||
...theme.typography.body2,
|
||||
position: 'relative',
|
||||
height: NAVBAR.DASHBOARD_ITEM_ROOT_HEIGHT,
|
||||
textTransform: 'capitalize',
|
||||
paddingLeft: theme.spacing(2),
|
||||
paddingRight: theme.spacing(1.5),
|
||||
marginBottom: theme.spacing(0.5),
|
||||
color: theme.palette.text.secondary,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
// activeRoot
|
||||
...(activeRoot && {
|
||||
...theme.typography.subtitle2,
|
||||
color: theme.palette.primary.main,
|
||||
backgroundColor: alpha(theme.palette.primary.main, theme.palette.action.selectedOpacity),
|
||||
}),
|
||||
// activeSub
|
||||
...(activeSub && {
|
||||
...theme.typography.subtitle2,
|
||||
color: theme.palette.text.primary,
|
||||
}),
|
||||
// subItem
|
||||
...(subItem && {
|
||||
height: NAVBAR.DASHBOARD_ITEM_SUB_HEIGHT,
|
||||
}),
|
||||
}));
|
||||
|
||||
interface ListItemTextStyleProps extends ListItemButtonProps {
|
||||
isCollapse?: boolean;
|
||||
}
|
||||
|
||||
export const ListItemTextStyle = styled(ListItemText, {
|
||||
shouldForwardProp: (prop) => prop !== 'isCollapse',
|
||||
})<ListItemTextStyleProps>(({ isCollapse, theme }) => ({
|
||||
whiteSpace: 'nowrap',
|
||||
transition: theme.transitions.create(['width', 'opacity'], {
|
||||
duration: theme.transitions.duration.shorter,
|
||||
}),
|
||||
...(isCollapse && {
|
||||
width: 0,
|
||||
opacity: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
export const ListItemIconStyle = styled(ListItemIcon)({
|
||||
width: ICON.NAVBAR_ITEM,
|
||||
height: ICON.NAVBAR_ITEM,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
'& svg': { width: '100%', height: '100%' },
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
// @mui
|
||||
import { alpha, styled } from '@mui/material/styles';
|
||||
import { Box, Grid, RadioGroup, CardActionArea } from '@mui/material';
|
||||
// hooks
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
//
|
||||
import { BoxMask } from '.';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const BoxStyle = styled(CardActionArea)(({ theme }) => ({
|
||||
height: 48,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: theme.palette.text.disabled,
|
||||
border: `solid 1px ${theme.palette.grey[500_12]}`,
|
||||
borderRadius: Number(theme.shape.borderRadius) * 1.25,
|
||||
}));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export default function SettingColorPresets() {
|
||||
const { themeColorPresets, onChangeColor, colorOption } = useSettings();
|
||||
|
||||
return (
|
||||
<RadioGroup name="themeColorPresets" value={themeColorPresets} onChange={onChangeColor}>
|
||||
<Grid dir="ltr" container spacing={1.5}>
|
||||
{colorOption.map((color) => {
|
||||
const colorName = color.name;
|
||||
const colorValue = color.value;
|
||||
const isSelected = themeColorPresets === colorName;
|
||||
|
||||
return (
|
||||
<Grid key={colorName} item xs={4}>
|
||||
<BoxStyle
|
||||
sx={{
|
||||
...(isSelected && {
|
||||
bgcolor: alpha(colorValue, 0.08),
|
||||
border: `solid 2px ${colorValue}`,
|
||||
boxShadow: `inset 0 4px 8px 0 ${alpha(colorValue, 0.24)}`,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 14,
|
||||
borderRadius: '50%',
|
||||
bgcolor: colorValue,
|
||||
transform: 'rotate(-45deg)',
|
||||
transition: (theme) =>
|
||||
theme.transitions.create('all', {
|
||||
easing: theme.transitions.easing.easeInOut,
|
||||
duration: theme.transitions.duration.shorter,
|
||||
}),
|
||||
...(isSelected && { transform: 'none' }),
|
||||
}}
|
||||
/>
|
||||
|
||||
<BoxMask value={colorName} />
|
||||
</BoxStyle>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// @mui
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { Grid, RadioGroup, CardActionArea } from '@mui/material';
|
||||
// hooks
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
//
|
||||
import Iconify from '../Iconify';
|
||||
import { BoxMask } from '.';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const BoxStyle = styled(CardActionArea)(({ theme }) => ({
|
||||
height: 72,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: theme.palette.text.disabled,
|
||||
border: `solid 1px ${theme.palette.grey[500_12]}`,
|
||||
borderRadius: Number(theme.shape.borderRadius) * 1.25,
|
||||
}));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export default function SettingDirection() {
|
||||
const { themeDirection, onChangeDirection } = useSettings();
|
||||
|
||||
return (
|
||||
<RadioGroup name="themeDirection" value={themeDirection} onChange={onChangeDirection}>
|
||||
<Grid dir="ltr" container spacing={2.5}>
|
||||
{['ltr', 'rtl'].map((direction, index) => {
|
||||
const isSelected = themeDirection === direction;
|
||||
|
||||
return (
|
||||
<Grid key={direction} item xs={6}>
|
||||
<BoxStyle
|
||||
sx={{
|
||||
...(isSelected && {
|
||||
color: 'primary.main',
|
||||
boxShadow: (theme) => theme.customShadows.z20,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Iconify
|
||||
icon={index === 0 ? 'ph:align-left-duotone' : 'ph:align-right-duotone'}
|
||||
width={28}
|
||||
height={28}
|
||||
/>
|
||||
<BoxMask value={direction} />
|
||||
</BoxStyle>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from 'react';
|
||||
// @mui
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import { Button } from '@mui/material';
|
||||
// components
|
||||
import Iconify from '../Iconify';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export default function SettingFullscreen() {
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
const toggleFullScreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
setFullscreen(true);
|
||||
} else if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
setFullscreen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
fullWidth
|
||||
size="large"
|
||||
variant="outlined"
|
||||
color={fullscreen ? 'primary' : 'inherit'}
|
||||
startIcon={<Iconify icon={fullscreen ? 'ic:round-fullscreen-exit' : 'ic:round-fullscreen'} />}
|
||||
onClick={toggleFullScreen}
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
...(fullscreen && {
|
||||
bgcolor: (theme) =>
|
||||
alpha(theme.palette.primary.main, theme.palette.action.selectedOpacity),
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{fullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
138
frontend/dashboard/src/components/settings/SettingLayout.tsx
Normal file
138
frontend/dashboard/src/components/settings/SettingLayout.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
// @mui
|
||||
import { styled, alpha } from '@mui/material/styles';
|
||||
import { Grid, RadioGroup, CardActionArea, Box, Stack } from '@mui/material';
|
||||
// hooks
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
//
|
||||
import { BoxMask } from '.';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const BoxStyle = styled(CardActionArea)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: theme.spacing(1.5),
|
||||
color: theme.palette.text.disabled,
|
||||
border: `solid 1px ${theme.palette.grey[500_12]}`,
|
||||
borderRadius: Number(theme.shape.borderRadius) * 1.25,
|
||||
}));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export default function SettingLayout() {
|
||||
const { themeLayout, onChangeLayout } = useSettings();
|
||||
|
||||
return (
|
||||
<RadioGroup name="themeLayout" value={themeLayout} onChange={onChangeLayout}>
|
||||
<Grid dir="ltr" container spacing={2.5}>
|
||||
{['horizontal', 'vertical'].map((layout) => {
|
||||
const isSelected = themeLayout === layout;
|
||||
const isVertical = layout === 'vertical';
|
||||
|
||||
return (
|
||||
<Grid key={layout} item xs={6}>
|
||||
<BoxStyle
|
||||
sx={{
|
||||
...(isSelected && {
|
||||
color: 'primary.main',
|
||||
boxShadow: (theme) => theme.customShadows.z20,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{isVertical ? (
|
||||
<VerticalBox isSelected={isSelected} />
|
||||
) : (
|
||||
<HorizontalBox isSelected={isSelected} />
|
||||
)}
|
||||
<BoxMask value={layout} />
|
||||
</BoxStyle>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type LayoutBoxProps = {
|
||||
isSelected: boolean;
|
||||
};
|
||||
|
||||
const style = {
|
||||
width: 1,
|
||||
height: 32,
|
||||
borderRadius: 0.5,
|
||||
};
|
||||
|
||||
function VerticalBox({ isSelected }: LayoutBoxProps) {
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
...style,
|
||||
mb: 0.75,
|
||||
height: 12,
|
||||
bgcolor: (theme) => alpha(theme.palette.text.disabled, 0.72),
|
||||
...(isSelected && {
|
||||
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.72),
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
...style,
|
||||
border: (theme) => `dashed 1px ${theme.palette.divider}`,
|
||||
bgcolor: (theme) => alpha(theme.palette.text.disabled, 0.08),
|
||||
...(isSelected && {
|
||||
border: (theme) => `dashed 1px ${theme.palette.primary.main}`,
|
||||
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.16),
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HorizontalBox({ isSelected }: LayoutBoxProps) {
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
...style,
|
||||
mb: 0.75,
|
||||
height: 12,
|
||||
bgcolor: (theme) => alpha(theme.palette.text.disabled, 0.72),
|
||||
...(isSelected && {
|
||||
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.72),
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<Stack width={1} direction="row" justifyContent="space-between">
|
||||
<Box
|
||||
sx={{
|
||||
...style,
|
||||
width: 20,
|
||||
bgcolor: (theme) => alpha(theme.palette.text.disabled, 0.32),
|
||||
...(isSelected && {
|
||||
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.32),
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
...style,
|
||||
width: `calc(100% - 26px)`,
|
||||
border: (theme) => `dashed 1px ${theme.palette.divider}`,
|
||||
bgcolor: (theme) => alpha(theme.palette.text.disabled, 0.08),
|
||||
...(isSelected && {
|
||||
border: (theme) => `dashed 1px ${theme.palette.primary.main}`,
|
||||
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.16),
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
57
frontend/dashboard/src/components/settings/SettingMode.tsx
Normal file
57
frontend/dashboard/src/components/settings/SettingMode.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
// @mui
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { Grid, RadioGroup, CardActionArea } from '@mui/material';
|
||||
// hooks
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
//
|
||||
import Iconify from '../Iconify';
|
||||
import { BoxMask } from '.';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const BoxStyle = styled(CardActionArea)(({ theme }) => ({
|
||||
height: 72,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: theme.palette.text.disabled,
|
||||
border: `solid 1px ${theme.palette.grey[500_12]}`,
|
||||
borderRadius: Number(theme.shape.borderRadius) * 1.25,
|
||||
}));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export default function SettingMode() {
|
||||
const { themeMode, onChangeMode } = useSettings();
|
||||
|
||||
return (
|
||||
<RadioGroup name="themeMode" value={themeMode} onChange={onChangeMode}>
|
||||
<Grid dir="ltr" container spacing={2.5}>
|
||||
{['light', 'dark'].map((mode, index) => {
|
||||
const isSelected = themeMode === mode;
|
||||
|
||||
return (
|
||||
<Grid key={mode} item xs={6}>
|
||||
<BoxStyle
|
||||
sx={{
|
||||
bgcolor: mode === 'light' ? 'common.white' : 'grey.800',
|
||||
...(isSelected && {
|
||||
color: 'primary.main',
|
||||
boxShadow: (theme) => theme.customShadows.z20,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Iconify
|
||||
icon={index === 0 ? 'ph:sun-duotone' : 'ph:moon-duotone'}
|
||||
width={28}
|
||||
height={28}
|
||||
/>
|
||||
<BoxMask value={mode} />
|
||||
</BoxStyle>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// @mui
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { CardActionArea, Stack } from '@mui/material';
|
||||
// hooks
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
//
|
||||
import Iconify from '../Iconify';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const BoxStyle = styled(CardActionArea)(({ theme }) => ({
|
||||
padding: theme.spacing(2),
|
||||
color: theme.palette.text.disabled,
|
||||
border: `solid 1px ${theme.palette.grey[500_12]}`,
|
||||
backgroundColor: theme.palette.background.neutral,
|
||||
borderRadius: Number(theme.shape.borderRadius) * 1.25,
|
||||
}));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export default function SettingStretch() {
|
||||
const { themeStretch, onToggleStretch } = useSettings();
|
||||
|
||||
const ICON_SIZE = {
|
||||
width: themeStretch ? 24 : 18,
|
||||
height: themeStretch ? 24 : 18,
|
||||
};
|
||||
|
||||
return (
|
||||
<BoxStyle
|
||||
onClick={onToggleStretch}
|
||||
sx={{
|
||||
...(themeStretch && {
|
||||
color: (theme) => theme.palette.primary.main,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
sx={{
|
||||
px: 1,
|
||||
mx: 'auto',
|
||||
width: 0.5,
|
||||
height: 40,
|
||||
borderRadius: 1,
|
||||
color: 'action.active',
|
||||
bgcolor: 'background.default',
|
||||
boxShadow: (theme) => theme.customShadows.z12,
|
||||
transition: (theme) => theme.transitions.create('width'),
|
||||
...(themeStretch && {
|
||||
width: 1,
|
||||
color: 'primary.main',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Iconify
|
||||
icon={themeStretch ? 'eva:arrow-ios-back-fill' : 'eva:arrow-ios-forward-fill'}
|
||||
{...ICON_SIZE}
|
||||
/>
|
||||
<Iconify
|
||||
icon={themeStretch ? 'eva:arrow-ios-forward-fill' : 'eva:arrow-ios-back-fill'}
|
||||
{...ICON_SIZE}
|
||||
/>
|
||||
</Stack>
|
||||
</BoxStyle>
|
||||
);
|
||||
}
|
||||
69
frontend/dashboard/src/components/settings/ToggleButton.tsx
Normal file
69
frontend/dashboard/src/components/settings/ToggleButton.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
// @mui
|
||||
import { alpha, styled } from '@mui/material/styles';
|
||||
import { Tooltip } from '@mui/material';
|
||||
// utils
|
||||
import cssStyles from '../../utils/cssStyles';
|
||||
//
|
||||
import Iconify from '../Iconify';
|
||||
import { IconButtonAnimate } from '../animate';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const RootStyle = styled('span')(({ theme }) => ({
|
||||
...cssStyles(theme).bgBlur({ opacity: 0.64 }),
|
||||
right: 0,
|
||||
top: '50%',
|
||||
position: 'fixed',
|
||||
marginTop: theme.spacing(-3),
|
||||
padding: theme.spacing(0.5),
|
||||
zIndex: theme.zIndex.drawer + 2,
|
||||
borderRadius: '24px 0 20px 24px',
|
||||
boxShadow: `-12px 12px 32px -4px ${alpha(
|
||||
theme.palette.mode === 'light' ? theme.palette.grey[600] : theme.palette.common.black,
|
||||
0.36
|
||||
)}`,
|
||||
}));
|
||||
|
||||
const DotStyle = styled('span')(({ theme }) => ({
|
||||
top: 8,
|
||||
width: 8,
|
||||
height: 8,
|
||||
right: 10,
|
||||
borderRadius: '50%',
|
||||
position: 'absolute',
|
||||
backgroundColor: theme.palette.error.main,
|
||||
}));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
notDefault: boolean;
|
||||
onToggle: VoidFunction;
|
||||
};
|
||||
|
||||
export default function ToggleButton({ notDefault, open, onToggle }: Props) {
|
||||
return (
|
||||
<RootStyle>
|
||||
{notDefault && !open && <DotStyle />}
|
||||
|
||||
<Tooltip title="Settings" placement="left">
|
||||
<IconButtonAnimate
|
||||
color="inherit"
|
||||
onClick={onToggle}
|
||||
sx={{
|
||||
p: 1.25,
|
||||
transition: (theme) => theme.transitions.create('all'),
|
||||
'&:hover': {
|
||||
color: 'primary.main',
|
||||
bgcolor: (theme) =>
|
||||
alpha(theme.palette.primary.main, theme.palette.action.hoverOpacity),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Iconify icon="eva:options-2-fill" width={20} height={20} />
|
||||
</IconButtonAnimate>
|
||||
</Tooltip>
|
||||
</RootStyle>
|
||||
);
|
||||
}
|
||||
189
frontend/dashboard/src/components/settings/index.tsx
Normal file
189
frontend/dashboard/src/components/settings/index.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { AnimatePresence, m } from 'framer-motion';
|
||||
import { useState, useEffect } from 'react';
|
||||
// @mui
|
||||
import { alpha, styled } from '@mui/material/styles';
|
||||
import { Backdrop, Divider, Typography, Stack, FormControlLabel, Radio } from '@mui/material';
|
||||
// hooks
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
// utils
|
||||
import cssStyles from '../../utils/cssStyles';
|
||||
// config
|
||||
import { NAVBAR, defaultSettings } from '../../config';
|
||||
//
|
||||
import Iconify from '../Iconify';
|
||||
import Scrollbar from '../Scrollbar';
|
||||
import { IconButtonAnimate, varFade } from '../animate';
|
||||
//
|
||||
import ToggleButton from './ToggleButton';
|
||||
import SettingMode from './SettingMode';
|
||||
import SettingLayout from './SettingLayout';
|
||||
import SettingStretch from './SettingStretch';
|
||||
import SettingDirection from './SettingDirection';
|
||||
import SettingFullscreen from './SettingFullscreen';
|
||||
import SettingColorPresets from './SettingColorPresets';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const RootStyle = styled(m.div)(({ theme }) => ({
|
||||
...cssStyles(theme).bgBlur({ color: theme.palette.background.paper, opacity: 0.92 }),
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
position: 'fixed',
|
||||
overflow: 'hidden',
|
||||
width: NAVBAR.BASE_WIDTH,
|
||||
flexDirection: 'column',
|
||||
margin: theme.spacing(2),
|
||||
paddingBottom: theme.spacing(3),
|
||||
zIndex: theme.zIndex.drawer + 3,
|
||||
borderRadius: Number(theme.shape.borderRadius) * 1.5,
|
||||
boxShadow: `-24px 12px 32px -4px ${alpha(
|
||||
theme.palette.mode === 'light' ? theme.palette.grey[500] : theme.palette.common.black,
|
||||
0.16
|
||||
)}`,
|
||||
}));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export default function Settings() {
|
||||
const {
|
||||
themeMode,
|
||||
themeDirection,
|
||||
themeColorPresets,
|
||||
themeStretch,
|
||||
themeLayout,
|
||||
onResetSetting,
|
||||
} = useSettings();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const notDefault =
|
||||
themeMode !== defaultSettings.themeMode ||
|
||||
themeDirection !== defaultSettings.themeDirection ||
|
||||
themeColorPresets !== defaultSettings.themeColorPresets ||
|
||||
themeLayout !== defaultSettings.themeLayout ||
|
||||
themeStretch !== defaultSettings.themeStretch;
|
||||
|
||||
const varSidebar =
|
||||
themeDirection !== 'rtl'
|
||||
? varFade({
|
||||
distance: NAVBAR.BASE_WIDTH,
|
||||
durationIn: 0.32,
|
||||
durationOut: 0.32,
|
||||
}).inRight
|
||||
: varFade({
|
||||
distance: NAVBAR.BASE_WIDTH,
|
||||
durationIn: 0.32,
|
||||
durationOut: 0.32,
|
||||
}).inLeft;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleToggle = () => {
|
||||
setOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop
|
||||
open={open}
|
||||
onClick={handleClose}
|
||||
sx={{ background: 'transparent', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||
/>
|
||||
|
||||
{!open && <ToggleButton open={open} notDefault={notDefault} onToggle={handleToggle} />}
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
<RootStyle {...varSidebar}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
sx={{ py: 2, pr: 1, pl: 2.5 }}
|
||||
>
|
||||
<Typography variant="subtitle1">Settings</Typography>
|
||||
<div>
|
||||
<IconButtonAnimate onClick={onResetSetting}>
|
||||
<Iconify icon={'ic:round-refresh'} width={20} height={20} />
|
||||
</IconButtonAnimate>
|
||||
<IconButtonAnimate onClick={handleClose}>
|
||||
<Iconify icon={'eva:close-fill'} width={20} height={20} />
|
||||
</IconButtonAnimate>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ borderStyle: 'dashed' }} />
|
||||
|
||||
<Scrollbar sx={{ flexGrow: 1 }}>
|
||||
<Stack spacing={3} sx={{ p: 3 }}>
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="subtitle2">Mode</Typography>
|
||||
<SettingMode />
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="subtitle2">Direction</Typography>
|
||||
<SettingDirection />
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="subtitle2">Layout</Typography>
|
||||
<SettingLayout />
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="subtitle2">Presets</Typography>
|
||||
<SettingColorPresets />
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="subtitle2">Stretch</Typography>
|
||||
<SettingStretch />
|
||||
</Stack>
|
||||
|
||||
<SettingFullscreen />
|
||||
</Stack>
|
||||
</Scrollbar>
|
||||
</RootStyle>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export function BoxMask({ value }: Props) {
|
||||
return (
|
||||
<FormControlLabel
|
||||
label=""
|
||||
value={value}
|
||||
control={<Radio sx={{ display: 'none' }} />}
|
||||
sx={{
|
||||
m: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
45
frontend/dashboard/src/components/settings/type.ts
Normal file
45
frontend/dashboard/src/components/settings/type.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export type ThemeMode = 'light' | 'dark';
|
||||
export type ThemeDirection = 'rtl' | 'ltr';
|
||||
export type ThemeColorPresets = 'default' | 'purple' | 'cyan' | 'blue' | 'orange' | 'red';
|
||||
export type ThemeLayout = 'vertical' | 'horizontal';
|
||||
export type ThemeStretch = boolean;
|
||||
|
||||
type ColorVariants = {
|
||||
name: string;
|
||||
lighter: string;
|
||||
light: string;
|
||||
main: string;
|
||||
dark: string;
|
||||
darker: string;
|
||||
contrastText: string;
|
||||
};
|
||||
|
||||
export type SettingsValueProps = {
|
||||
themeMode: ThemeMode;
|
||||
themeDirection: ThemeDirection;
|
||||
themeColorPresets: ThemeColorPresets;
|
||||
themeStretch: ThemeStretch;
|
||||
themeLayout: ThemeLayout;
|
||||
};
|
||||
|
||||
export type SettingsContextProps = {
|
||||
themeMode: ThemeMode;
|
||||
themeDirection: ThemeDirection;
|
||||
themeColorPresets: ThemeColorPresets;
|
||||
themeLayout: ThemeLayout;
|
||||
themeStretch: boolean;
|
||||
setColor: ColorVariants;
|
||||
colorOption: {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
onToggleMode: VoidFunction;
|
||||
onToggleStretch: VoidFunction;
|
||||
onResetSetting: VoidFunction;
|
||||
onChangeMode: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onChangeDirection: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onChangeColor: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onChangeLayout: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
38
frontend/dashboard/src/components/upload/BlockContent.tsx
Normal file
38
frontend/dashboard/src/components/upload/BlockContent.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
// @mui
|
||||
import { Box, Typography, Stack } from '@mui/material';
|
||||
// assets
|
||||
import { UploadIllustration } from '../../assets';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export default function BlockContent() {
|
||||
return (
|
||||
<Stack
|
||||
spacing={2}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
sx={{ width: 1, textAlign: { xs: 'center', md: 'left' } }}
|
||||
>
|
||||
<UploadIllustration sx={{ width: 220 }} />
|
||||
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography gutterBottom variant="h5">
|
||||
Drop or Select file
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
Drop files here or click
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="span"
|
||||
sx={{ color: 'primary.main', textDecoration: 'underline' }}
|
||||
>
|
||||
browse
|
||||
</Typography>
|
||||
thorough your machine
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
134
frontend/dashboard/src/components/upload/MultiFilePreview.tsx
Normal file
134
frontend/dashboard/src/components/upload/MultiFilePreview.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import isString from 'lodash/isString';
|
||||
import { m, AnimatePresence } from 'framer-motion';
|
||||
// @mui
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import { List, Stack, Button, IconButton, ListItemText, ListItem } from '@mui/material';
|
||||
// utils
|
||||
import { fData } from '../../utils/formatNumber';
|
||||
// type
|
||||
import { UploadMultiFileProps, CustomFile } from './type';
|
||||
//
|
||||
import Image from '../Image';
|
||||
import Iconify from '../Iconify';
|
||||
import { varFade } from '../animate';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const getFileData = (file: CustomFile | string) => {
|
||||
if (typeof file === 'string') {
|
||||
return {
|
||||
key: file,
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: file.name,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
preview: file.preview,
|
||||
};
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export default function MultiFilePreview({
|
||||
showPreview = false,
|
||||
files,
|
||||
onRemove,
|
||||
onRemoveAll,
|
||||
}: UploadMultiFileProps) {
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<List disablePadding sx={{ ...(hasFile && { my: 3 }) }}>
|
||||
<AnimatePresence>
|
||||
{files.map((file) => {
|
||||
const { key, name, size, preview } = getFileData(file as CustomFile);
|
||||
|
||||
if (showPreview) {
|
||||
return (
|
||||
<ListItem
|
||||
key={key}
|
||||
component={m.div}
|
||||
{...varFade().inRight}
|
||||
sx={{
|
||||
p: 0,
|
||||
m: 0.5,
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 1.25,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
display: 'inline-flex',
|
||||
border: (theme) => `solid 1px ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<Image alt="preview" src={isString(file) ? file : preview} ratio="1/1" />
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onRemove(file)}
|
||||
sx={{
|
||||
top: 6,
|
||||
p: '2px',
|
||||
right: 6,
|
||||
position: 'absolute',
|
||||
color: 'common.white',
|
||||
bgcolor: (theme) => alpha(theme.palette.grey[900], 0.72),
|
||||
'&:hover': {
|
||||
bgcolor: (theme) => alpha(theme.palette.grey[900], 0.48),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Iconify icon={'eva:close-fill'} />
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={key}
|
||||
component={m.div}
|
||||
{...varFade().inRight}
|
||||
sx={{
|
||||
my: 1,
|
||||
px: 2,
|
||||
py: 0.75,
|
||||
borderRadius: 0.75,
|
||||
border: (theme) => `solid 1px ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<Iconify
|
||||
icon={'eva:file-fill'}
|
||||
sx={{ width: 28, height: 28, color: 'text.secondary', mr: 2 }}
|
||||
/>
|
||||
|
||||
<ListItemText
|
||||
primary={isString(file) ? file : name}
|
||||
secondary={isString(file) ? '' : fData(size || 0)}
|
||||
primaryTypographyProps={{ variant: 'subtitle2' }}
|
||||
secondaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
|
||||
<IconButton edge="end" size="small" onClick={() => onRemove(file)}>
|
||||
<Iconify icon={'eva:close-fill'} />
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</List>
|
||||
|
||||
{hasFile && (
|
||||
<Stack direction="row" justifyContent="flex-end" spacing={1.5}>
|
||||
<Button color="inherit" size="small" onClick={onRemoveAll}>
|
||||
Remove all
|
||||
</Button>
|
||||
<Button size="small" variant="contained">
|
||||
Upload files
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
frontend/dashboard/src/components/upload/RejectionFiles.tsx
Normal file
47
frontend/dashboard/src/components/upload/RejectionFiles.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { FileRejection } from 'react-dropzone';
|
||||
// @mui
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import { Box, Paper, Typography } from '@mui/material';
|
||||
// type
|
||||
import { CustomFile } from './type';
|
||||
// utils
|
||||
import { fData } from '../../utils/formatNumber';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
fileRejections: FileRejection[];
|
||||
};
|
||||
|
||||
export default function RejectionFiles({ fileRejections }: Props) {
|
||||
return (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
py: 1,
|
||||
px: 2,
|
||||
mt: 3,
|
||||
borderColor: 'error.light',
|
||||
bgcolor: (theme) => alpha(theme.palette.error.main, 0.08),
|
||||
}}
|
||||
>
|
||||
{fileRejections.map(({ file, errors }) => {
|
||||
const { path, size }: CustomFile = file;
|
||||
|
||||
return (
|
||||
<Box key={path} sx={{ my: 1 }}>
|
||||
<Typography variant="subtitle2" noWrap>
|
||||
{path} - {fData(size)}
|
||||
</Typography>
|
||||
|
||||
{errors.map((error) => (
|
||||
<Typography key={error.code} variant="caption" component="p">
|
||||
- {error.message}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
114
frontend/dashboard/src/components/upload/UploadAvatar.tsx
Normal file
114
frontend/dashboard/src/components/upload/UploadAvatar.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import isString from 'lodash/isString';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
// @mui
|
||||
import { Typography } from '@mui/material';
|
||||
import { styled } from '@mui/material/styles';
|
||||
// type
|
||||
import { UploadProps } from './type';
|
||||
//
|
||||
import Image from '../Image';
|
||||
import Iconify from '../Iconify';
|
||||
import RejectionFiles from './RejectionFiles';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const RootStyle = styled('div')(({ theme }) => ({
|
||||
width: 144,
|
||||
height: 144,
|
||||
margin: 'auto',
|
||||
borderRadius: '50%',
|
||||
padding: theme.spacing(1),
|
||||
border: `1px dashed ${theme.palette.grey[500_32]}`,
|
||||
}));
|
||||
|
||||
const DropZoneStyle = styled('div')({
|
||||
zIndex: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
outline: 'none',
|
||||
display: 'flex',
|
||||
overflow: 'hidden',
|
||||
borderRadius: '50%',
|
||||
position: 'relative',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
'& > *': { width: '100%', height: '100%' },
|
||||
'&:hover': {
|
||||
cursor: 'pointer',
|
||||
'& .placeholder': {
|
||||
zIndex: 9,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const PlaceholderStyle = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
position: 'absolute',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
color: theme.palette.text.secondary,
|
||||
backgroundColor: theme.palette.background.neutral,
|
||||
transition: theme.transitions.create('opacity', {
|
||||
easing: theme.transitions.easing.easeInOut,
|
||||
duration: theme.transitions.duration.shorter,
|
||||
}),
|
||||
'&:hover': { opacity: 0.72 },
|
||||
}));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export default function UploadAvatar({ error, file, helperText, sx, ...other }: UploadProps) {
|
||||
const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({
|
||||
multiple: false,
|
||||
...other,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<RootStyle
|
||||
sx={{
|
||||
...((isDragReject || error) && {
|
||||
borderColor: 'error.light',
|
||||
}),
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<DropZoneStyle
|
||||
{...getRootProps()}
|
||||
sx={{
|
||||
...(isDragActive && { opacity: 0.72 }),
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
{file && (
|
||||
<Image alt="avatar" src={isString(file) ? file : file.preview} sx={{ zIndex: 8 }} />
|
||||
)}
|
||||
|
||||
<PlaceholderStyle
|
||||
className="placeholder"
|
||||
sx={{
|
||||
...(file && {
|
||||
opacity: 0,
|
||||
color: 'common.white',
|
||||
bgcolor: 'grey.900',
|
||||
'&:hover': { opacity: 0.72 },
|
||||
}),
|
||||
...((isDragReject || error) && {
|
||||
bgcolor: 'error.lighter',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Iconify icon={'ic:round-add-a-photo'} sx={{ width: 24, height: 24, mb: 1 }} />
|
||||
<Typography variant="caption">{file ? 'Update photo' : 'Upload photo'}</Typography>
|
||||
</PlaceholderStyle>
|
||||
</DropZoneStyle>
|
||||
</RootStyle>
|
||||
|
||||
{helperText && helperText}
|
||||
|
||||
{fileRejections.length > 0 && <RejectionFiles fileRejections={fileRejections} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
69
frontend/dashboard/src/components/upload/UploadMultiFile.tsx
Normal file
69
frontend/dashboard/src/components/upload/UploadMultiFile.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
// @mui
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { Box } from '@mui/material';
|
||||
// type
|
||||
import { UploadMultiFileProps } from './type';
|
||||
//
|
||||
import BlockContent from './BlockContent';
|
||||
import RejectionFiles from './RejectionFiles';
|
||||
import MultiFilePreview from './MultiFilePreview';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const DropZoneStyle = styled('div')(({ theme }) => ({
|
||||
outline: 'none',
|
||||
padding: theme.spacing(5, 1),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: theme.palette.background.neutral,
|
||||
border: `1px dashed ${theme.palette.grey[500_32]}`,
|
||||
'&:hover': { opacity: 0.72, cursor: 'pointer' },
|
||||
}));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export default function UploadMultiFile({
|
||||
error,
|
||||
showPreview = false,
|
||||
files,
|
||||
onRemove,
|
||||
onRemoveAll,
|
||||
helperText,
|
||||
sx,
|
||||
...other
|
||||
}: UploadMultiFileProps) {
|
||||
const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({
|
||||
...other,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', ...sx }}>
|
||||
<DropZoneStyle
|
||||
{...getRootProps()}
|
||||
sx={{
|
||||
...(isDragActive && { opacity: 0.72 }),
|
||||
...((isDragReject || error) && {
|
||||
color: 'error.main',
|
||||
borderColor: 'error.light',
|
||||
bgcolor: 'error.lighter',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
<BlockContent />
|
||||
</DropZoneStyle>
|
||||
|
||||
{fileRejections.length > 0 && <RejectionFiles fileRejections={fileRejections} />}
|
||||
|
||||
<MultiFilePreview
|
||||
files={files}
|
||||
showPreview={showPreview}
|
||||
onRemove={onRemove}
|
||||
onRemoveAll={onRemoveAll}
|
||||
/>
|
||||
|
||||
{helperText && helperText}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import isString from 'lodash/isString';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
// @mui
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { Box } from '@mui/material';
|
||||
// type
|
||||
import { UploadProps } from './type';
|
||||
//
|
||||
import Image from '../Image';
|
||||
import RejectionFiles from './RejectionFiles';
|
||||
import BlockContent from './BlockContent';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const DropZoneStyle = styled('div')(({ theme }) => ({
|
||||
outline: 'none',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
padding: theme.spacing(5, 1),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
transition: theme.transitions.create('padding'),
|
||||
backgroundColor: theme.palette.background.neutral,
|
||||
border: `1px dashed ${theme.palette.grey[500_32]}`,
|
||||
'&:hover': { opacity: 0.72, cursor: 'pointer' },
|
||||
}));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export default function UploadSingleFile({
|
||||
error = false,
|
||||
file,
|
||||
helperText,
|
||||
sx,
|
||||
...other
|
||||
}: UploadProps) {
|
||||
const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({
|
||||
multiple: false,
|
||||
...other,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', ...sx }}>
|
||||
<DropZoneStyle
|
||||
{...getRootProps()}
|
||||
sx={{
|
||||
...(isDragActive && { opacity: 0.72 }),
|
||||
...((isDragReject || error) && {
|
||||
color: 'error.main',
|
||||
borderColor: 'error.light',
|
||||
bgcolor: 'error.lighter',
|
||||
}),
|
||||
...(file && {
|
||||
padding: '12% 0',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
<BlockContent />
|
||||
|
||||
{file && (
|
||||
<Image
|
||||
alt="file preview"
|
||||
src={isString(file) ? file : file.preview}
|
||||
sx={{
|
||||
top: 8,
|
||||
left: 8,
|
||||
borderRadius: 1,
|
||||
position: 'absolute',
|
||||
width: 'calc(100% - 16px)',
|
||||
height: 'calc(100% - 16px)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DropZoneStyle>
|
||||
|
||||
{fileRejections.length > 0 && <RejectionFiles fileRejections={fileRejections} />}
|
||||
|
||||
{helperText && helperText}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
5
frontend/dashboard/src/components/upload/index.ts
Normal file
5
frontend/dashboard/src/components/upload/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './type';
|
||||
|
||||
export { default as UploadAvatar } from './UploadAvatar';
|
||||
export { default as UploadMultiFile } from './UploadMultiFile';
|
||||
export { default as UploadSingleFile } from './UploadSingleFile';
|
||||
29
frontend/dashboard/src/components/upload/type.ts
Normal file
29
frontend/dashboard/src/components/upload/type.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { DropzoneOptions } from 'react-dropzone';
|
||||
// @mui
|
||||
import { SxProps } from '@mui/material';
|
||||
import { Theme } from '@mui/material/styles';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export interface CustomFile extends File {
|
||||
path?: string;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
export interface UploadProps extends DropzoneOptions {
|
||||
error?: boolean;
|
||||
file: CustomFile | string | null;
|
||||
helperText?: ReactNode;
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
export interface UploadMultiFileProps extends DropzoneOptions {
|
||||
error?: boolean;
|
||||
files: (File | string)[];
|
||||
showPreview: boolean;
|
||||
onRemove: (file: File | string) => void;
|
||||
onRemoveAll: VoidFunction;
|
||||
sx?: SxProps<Theme>;
|
||||
helperText?: ReactNode;
|
||||
}
|
||||
Reference in New Issue
Block a user