mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-13 08:13:49 +10:00
🚀 release: v3.0.0
This commit is contained in:
0
apps/.gitkeep
Normal file
0
apps/.gitkeep
Normal file
2
apps/client/.env.example
Normal file
2
apps/client/.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_APP_VERSION=$npm_package_version
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID
|
||||
23
apps/client/.eslintrc.json
Normal file
23
apps/client/.eslintrc.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": ["plugin:@nrwl/nx/react-typescript", "../../.eslintrc.json", "next", "next/core-web-vitals"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {
|
||||
"@next/next/no-html-link-for-pages": ["error", "apps/client/pages"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
],
|
||||
"env": {
|
||||
"jest": true
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
.container {
|
||||
@apply flex w-auto items-center justify-center;
|
||||
@apply fixed inset-x-0 bottom-6;
|
||||
@apply transition-[margin-left,margin-right] duration-200;
|
||||
}
|
||||
|
||||
.pushLeft {
|
||||
@apply xl:ml-[30vw] 2xl:ml-[28vw];
|
||||
}
|
||||
|
||||
.pushRight {
|
||||
@apply xl:mr-[30vw] 2xl:mr-[28vw];
|
||||
}
|
||||
|
||||
.controller {
|
||||
@apply z-20 flex items-center justify-center shadow-lg;
|
||||
@apply flex rounded-l-full rounded-r-full px-4;
|
||||
@apply bg-neutral-50 dark:bg-neutral-800;
|
||||
@apply opacity-70 transition-opacity duration-200 hover:opacity-100;
|
||||
|
||||
> button {
|
||||
@apply px-2.5 py-2.5;
|
||||
}
|
||||
|
||||
> hr {
|
||||
@apply mx-3 h-5 w-0.5 bg-neutral-900/40 dark:bg-neutral-50/20;
|
||||
}
|
||||
}
|
||||
135
apps/client/components/build/Center/ArtboardController.tsx
Normal file
135
apps/client/components/build/Center/ArtboardController.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import {
|
||||
AlignHorizontalCenter,
|
||||
AlignVerticalCenter,
|
||||
Download,
|
||||
FilterCenterFocus,
|
||||
InsertPageBreak,
|
||||
Link,
|
||||
ViewSidebar,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from '@mui/icons-material';
|
||||
import { ButtonBase, Divider, Tooltip } from '@mui/material';
|
||||
import clsx from 'clsx';
|
||||
import { get } from 'lodash';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useMutation } from 'react-query';
|
||||
import { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch';
|
||||
|
||||
import { ServerError } from '@/services/axios';
|
||||
import { printResumeAsPdf, PrintResumeAsPdfParams } from '@/services/printer';
|
||||
import { togglePageBreakLine, togglePageOrientation, toggleSidebar } from '@/store/build/buildSlice';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import getResumeUrl from '@/utils/getResumeUrl';
|
||||
|
||||
import styles from './ArtboardController.module.scss';
|
||||
|
||||
const ArtboardController: React.FC<ReactZoomPanPinchRef> = ({ zoomIn, zoomOut, centerView }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const resume = useAppSelector((state) => state.resume);
|
||||
const { left, right } = useAppSelector((state) => state.build.sidebar);
|
||||
const orientation = useAppSelector((state) => state.build.page.orientation);
|
||||
|
||||
const { mutateAsync, isLoading } = useMutation<string, ServerError, PrintResumeAsPdfParams>(printResumeAsPdf);
|
||||
|
||||
const handleTogglePageBreakLine = () => dispatch(togglePageBreakLine());
|
||||
|
||||
const handleTogglePageOrientation = () => dispatch(togglePageOrientation());
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
dispatch(toggleSidebar({ sidebar: 'left' }));
|
||||
dispatch(toggleSidebar({ sidebar: 'right' }));
|
||||
};
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
const url = getResumeUrl(resume, { withHost: true });
|
||||
await navigator.clipboard.writeText(url);
|
||||
|
||||
toast.success(t('common.toast.success.resume-link-copied'));
|
||||
};
|
||||
|
||||
const handleExportPDF = async () => {
|
||||
const download = (await import('downloadjs')).default;
|
||||
|
||||
const slug = get(resume, 'slug');
|
||||
const username = get(resume, 'user.username');
|
||||
|
||||
const url = await mutateAsync({ username, slug });
|
||||
|
||||
download(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx({
|
||||
[styles.container]: true,
|
||||
[styles.pushLeft]: left.open,
|
||||
[styles.pushRight]: right.open,
|
||||
})}
|
||||
>
|
||||
<div className={styles.controller}>
|
||||
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.zoom-in')}>
|
||||
<ButtonBase onClick={() => zoomIn(0.25)}>
|
||||
<ZoomIn fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.zoom-out')}>
|
||||
<ButtonBase onClick={() => zoomOut(0.25)}>
|
||||
<ZoomOut fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.center-artboard')}>
|
||||
<ButtonBase onClick={() => centerView(0.95)}>
|
||||
<FilterCenterFocus fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.toggle-orientation')}>
|
||||
<ButtonBase onClick={handleTogglePageOrientation}>
|
||||
{orientation === 'vertical' ? (
|
||||
<AlignHorizontalCenter fontSize="medium" />
|
||||
) : (
|
||||
<AlignVerticalCenter fontSize="medium" />
|
||||
)}
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.toggle-page-break-line')}>
|
||||
<ButtonBase onClick={handleTogglePageBreakLine}>
|
||||
<InsertPageBreak fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.toggle-sidebars')}>
|
||||
<ButtonBase onClick={handleToggleSidebar}>
|
||||
<ViewSidebar fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.copy-link')}>
|
||||
<ButtonBase onClick={handleCopyLink}>
|
||||
<Link fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.export-pdf')}>
|
||||
<ButtonBase onClick={handleExportPDF} disabled={isLoading}>
|
||||
<Download fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArtboardController;
|
||||
13
apps/client/components/build/Center/Center.module.scss
Normal file
13
apps/client/components/build/Center/Center.module.scss
Normal file
@ -0,0 +1,13 @@
|
||||
.center {
|
||||
@apply mx-0 flex flex-grow pt-12 lg:pt-16;
|
||||
@apply transition-[margin-left,margin-right] duration-200;
|
||||
@apply bg-neutral-200 dark:bg-neutral-900;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
@apply h-full w-full #{!important};
|
||||
}
|
||||
|
||||
.artboard {
|
||||
@apply flex gap-8;
|
||||
}
|
||||
57
apps/client/components/build/Center/Center.tsx
Normal file
57
apps/client/components/build/Center/Center.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import clsx from 'clsx';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch';
|
||||
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
|
||||
import ArtboardController from './ArtboardController';
|
||||
import styles from './Center.module.scss';
|
||||
import Header from './Header';
|
||||
import Page from './Page';
|
||||
|
||||
const Center = () => {
|
||||
const orientation = useAppSelector((state) => state.build.page.orientation);
|
||||
|
||||
const resume = useAppSelector((state) => state.resume);
|
||||
const layout: string[][][] = get(resume, 'metadata.layout');
|
||||
|
||||
if (isEmpty(resume)) return null;
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.center)}>
|
||||
<Header />
|
||||
|
||||
<TransformWrapper
|
||||
centerOnInit
|
||||
minScale={0.25}
|
||||
initialScale={0.95}
|
||||
limitToBounds={false}
|
||||
centerZoomedOut={false}
|
||||
pinch={{ step: 1 }}
|
||||
wheel={{ step: 0.1 }}
|
||||
>
|
||||
{(controllerProps) => (
|
||||
<>
|
||||
<TransformComponent wrapperClass={styles.wrapper}>
|
||||
<div
|
||||
className={clsx({
|
||||
[styles.artboard]: true,
|
||||
'flex-col': orientation === 'vertical',
|
||||
})}
|
||||
>
|
||||
{layout.map((_, pageIndex) => (
|
||||
<Page key={pageIndex} page={pageIndex} showPageNumbers />
|
||||
))}
|
||||
</div>
|
||||
</TransformComponent>
|
||||
|
||||
<ArtboardController {...controllerProps} />
|
||||
</>
|
||||
)}
|
||||
</TransformWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Center;
|
||||
25
apps/client/components/build/Center/Header.module.scss
Normal file
25
apps/client/components/build/Center/Header.module.scss
Normal file
@ -0,0 +1,25 @@
|
||||
.header {
|
||||
@apply mx-0 flex justify-between shadow;
|
||||
@apply bg-neutral-800 text-neutral-100;
|
||||
@apply transition-[margin-left,margin-right] duration-200;
|
||||
|
||||
button > svg {
|
||||
@apply text-base text-neutral-100;
|
||||
}
|
||||
}
|
||||
|
||||
.pushLeft {
|
||||
@apply xl:ml-[30vw] 2xl:ml-[28vw];
|
||||
}
|
||||
|
||||
.pushRight {
|
||||
@apply xl:mr-[30vw] 2xl:mr-[28vw];
|
||||
}
|
||||
|
||||
.title {
|
||||
@apply flex items-center justify-center;
|
||||
|
||||
h1 {
|
||||
@apply ml-2;
|
||||
}
|
||||
}
|
||||
216
apps/client/components/build/Center/Header.tsx
Normal file
216
apps/client/components/build/Center/Header.tsx
Normal file
@ -0,0 +1,216 @@
|
||||
import {
|
||||
ChevronLeft as ChevronLeftIcon,
|
||||
ChevronRight as ChevronRightIcon,
|
||||
CopyAll,
|
||||
Delete,
|
||||
DriveFileRenameOutline,
|
||||
Home as HomeIcon,
|
||||
KeyboardArrowDown as KeyboardArrowDownIcon,
|
||||
Link as LinkIcon,
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
AppBar,
|
||||
IconButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Toolbar,
|
||||
Tooltip,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import { Resume } from '@reactive-resume/schema';
|
||||
import clsx from 'clsx';
|
||||
import get from 'lodash/get';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useMutation } from 'react-query';
|
||||
|
||||
import { RESUMES_QUERY } from '@/constants/index';
|
||||
import { ServerError } from '@/services/axios';
|
||||
import queryClient from '@/services/react-query';
|
||||
import { deleteResume, DeleteResumeParams, duplicateResume, DuplicateResumeParams } from '@/services/resume';
|
||||
import { setSidebarState, toggleSidebar } from '@/store/build/buildSlice';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
import getResumeUrl from '@/utils/getResumeUrl';
|
||||
|
||||
import styles from './Header.module.scss';
|
||||
|
||||
const Header = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const isDesktop = useMediaQuery(theme.breakpoints.up('lg'));
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||
|
||||
const { mutateAsync: duplicateMutation } = useMutation<Resume, ServerError, DuplicateResumeParams>(duplicateResume);
|
||||
|
||||
const { mutateAsync: deleteMutation } = useMutation<void, ServerError, DeleteResumeParams>(deleteResume);
|
||||
|
||||
const resume = useAppSelector((state) => state.resume);
|
||||
const { left, right } = useAppSelector((state) => state.build.sidebar);
|
||||
|
||||
const name = useMemo(() => get(resume, 'name'), [resume]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDesktop) {
|
||||
dispatch(setSidebarState({ sidebar: 'left', state: { open: true } }));
|
||||
dispatch(setSidebarState({ sidebar: 'right', state: { open: true } }));
|
||||
} else {
|
||||
dispatch(setSidebarState({ sidebar: 'left', state: { open: false } }));
|
||||
dispatch(setSidebarState({ sidebar: 'right', state: { open: false } }));
|
||||
}
|
||||
}, [isDesktop, dispatch]);
|
||||
|
||||
const toggleLeftSidebar = () => dispatch(toggleSidebar({ sidebar: 'left' }));
|
||||
|
||||
const toggleRightSidebar = () => dispatch(toggleSidebar({ sidebar: 'right' }));
|
||||
|
||||
const goBack = () => router.push('/dashboard');
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleRename = () => {
|
||||
handleClose();
|
||||
|
||||
dispatch(
|
||||
setModalState({
|
||||
modal: 'dashboard.rename-resume',
|
||||
state: {
|
||||
open: true,
|
||||
payload: {
|
||||
item: resume,
|
||||
onComplete: (newResume: Resume) => {
|
||||
queryClient.invalidateQueries(RESUMES_QUERY);
|
||||
|
||||
router.push(`/${resume.user.username}/${newResume.slug}/build`);
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleDuplicate = async () => {
|
||||
handleClose();
|
||||
|
||||
const newResume = await duplicateMutation({ id: resume.id });
|
||||
|
||||
queryClient.invalidateQueries(RESUMES_QUERY);
|
||||
|
||||
router.push(`/${resume.user.username}/${newResume.slug}/build`);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
handleClose();
|
||||
|
||||
await deleteMutation({ id: resume.id });
|
||||
|
||||
queryClient.invalidateQueries(RESUMES_QUERY);
|
||||
|
||||
goBack();
|
||||
};
|
||||
|
||||
const handleShareLink = async () => {
|
||||
handleClose();
|
||||
|
||||
const url = getResumeUrl(resume, { withHost: true });
|
||||
await navigator.clipboard.writeText(url);
|
||||
|
||||
toast.success(t('common.toast.success.resume-link-copied'));
|
||||
};
|
||||
|
||||
return (
|
||||
<AppBar elevation={0} position="fixed">
|
||||
<Toolbar
|
||||
variant="regular"
|
||||
className={clsx({
|
||||
[styles.header]: true,
|
||||
[styles.pushLeft]: left.open,
|
||||
[styles.pushRight]: right.open,
|
||||
})}
|
||||
>
|
||||
<IconButton onClick={toggleLeftSidebar}>{left.open ? <ChevronLeftIcon /> : <ChevronRightIcon />}</IconButton>
|
||||
|
||||
<div className={styles.title}>
|
||||
<IconButton className="opacity-50 hover:opacity-100" onClick={goBack}>
|
||||
<HomeIcon />
|
||||
</IconButton>
|
||||
|
||||
<span className="opacity-50">{'/'}</span>
|
||||
|
||||
<h1>{name}</h1>
|
||||
|
||||
<IconButton onClick={handleClick}>
|
||||
<KeyboardArrowDownIcon />
|
||||
</IconButton>
|
||||
|
||||
<Menu open={Boolean(anchorEl)} anchorEl={anchorEl} onClose={handleClose}>
|
||||
<MenuItem onClick={handleRename}>
|
||||
<ListItemIcon>
|
||||
<DriveFileRenameOutline className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('builder.header.menu.rename')}</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={handleDuplicate}>
|
||||
<ListItemIcon>
|
||||
<CopyAll className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('builder.header.menu.duplicate')}</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
{resume.public ? (
|
||||
<MenuItem onClick={handleShareLink}>
|
||||
<ListItemIcon>
|
||||
<LinkIcon className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('builder.header.menu.share-link')}</ListItemText>
|
||||
</MenuItem>
|
||||
) : (
|
||||
<Tooltip arrow placement="right" title={t('builder.header.menu.tooltips.share-link')}>
|
||||
<div>
|
||||
<MenuItem>
|
||||
<ListItemIcon>
|
||||
<LinkIcon className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('builder.header.menu.share-link')}</ListItemText>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip arrow placement="right" title={t('builder.header.menu.tooltips.delete')}>
|
||||
<MenuItem onClick={handleDelete}>
|
||||
<ListItemIcon>
|
||||
<Delete className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('builder.header.menu.delete')}</ListItemText>
|
||||
</MenuItem>
|
||||
</Tooltip>
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
<IconButton onClick={toggleRightSidebar}>{right.open ? <ChevronRightIcon /> : <ChevronLeftIcon />}</IconButton>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
34
apps/client/components/build/Center/Page.module.scss
Normal file
34
apps/client/components/build/Center/Page.module.scss
Normal file
@ -0,0 +1,34 @@
|
||||
.container {
|
||||
@apply flex flex-col items-center gap-2;
|
||||
@apply rounded-sm;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
|
||||
@apply relative z-50 grid shadow;
|
||||
@apply print:shadow-none;
|
||||
|
||||
:global(.printer-mode) & {
|
||||
@apply shadow-none;
|
||||
}
|
||||
|
||||
&.break::after {
|
||||
content: 'A4 Page Break';
|
||||
top: calc(297mm - 19px);
|
||||
|
||||
@apply absolute w-full border-b border-dashed border-neutral-800/75;
|
||||
@apply flex items-end justify-end pr-2 pb-0.5 text-xs font-bold text-neutral-800/75;
|
||||
@apply print:hidden;
|
||||
|
||||
:global(.preview-mode) &,
|
||||
:global(.printer-mode) & {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pageNumber {
|
||||
@apply text-center font-bold print:hidden;
|
||||
}
|
||||
59
apps/client/components/build/Center/Page.tsx
Normal file
59
apps/client/components/build/Center/Page.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { CustomCSS, Theme, Typography } from '@reactive-resume/schema';
|
||||
import clsx from 'clsx';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import templateMap from '@/templates/templateMap';
|
||||
import { generateThemeStyles, generateTypographyStyles } from '@/utils/styles';
|
||||
import { PageProps } from '@/utils/template';
|
||||
|
||||
import styles from './Page.module.scss';
|
||||
|
||||
type Props = PageProps & {
|
||||
showPageNumbers?: boolean;
|
||||
};
|
||||
|
||||
const Page: React.FC<Props> = ({ page, showPageNumbers = false }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const resume = useAppSelector((state) => state.resume);
|
||||
const breakLine: boolean = useAppSelector((state) => state.build.page.breakLine);
|
||||
|
||||
const theme: Theme = get(resume, 'metadata.theme');
|
||||
const customCSS: CustomCSS = get(resume, 'metadata.css');
|
||||
const template: string = get(resume, 'metadata.template');
|
||||
const typography: Typography = get(resume, 'metadata.typography');
|
||||
|
||||
const themeCSS = useMemo(() => !isEmpty(theme) && generateThemeStyles(theme), [theme]);
|
||||
const typographyCSS = useMemo(() => !isEmpty(typography) && generateTypographyStyles(typography), [typography]);
|
||||
const TemplatePage: React.FC<PageProps> = useMemo(() => get(templateMap, `${template}.component`, null), [template]);
|
||||
|
||||
return (
|
||||
<div data-page={page + 1} className={styles.container}>
|
||||
<div
|
||||
className={clsx({
|
||||
reset: true,
|
||||
[styles.page]: true,
|
||||
[styles.break]: breakLine,
|
||||
[css(themeCSS)]: true,
|
||||
[css(typographyCSS)]: true,
|
||||
[css(customCSS.value)]: customCSS.visible,
|
||||
})}
|
||||
>
|
||||
{TemplatePage && <TemplatePage page={page} />}
|
||||
</div>
|
||||
|
||||
{showPageNumbers && (
|
||||
<h4 className={styles.pageNumber}>
|
||||
{t('builder.common.glossary.page')} {page + 1}
|
||||
</h4>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@ -0,0 +1,45 @@
|
||||
.container {
|
||||
@apply h-screen w-[95vw] md:w-[70vw] lg:w-[50vw] xl:w-[30vw] 2xl:w-[28vw];
|
||||
@apply bg-neutral-50 text-neutral-900 dark:bg-neutral-900 dark:text-neutral-50;
|
||||
@apply relative flex border-r-2 border-neutral-50/10;
|
||||
|
||||
nav {
|
||||
@apply absolute inset-y-0 left-0;
|
||||
@apply w-14 py-4 md:w-16 md:px-2;
|
||||
@apply bg-neutral-100 shadow dark:bg-neutral-800;
|
||||
@apply flex flex-col items-center justify-between;
|
||||
|
||||
hr {
|
||||
@apply mt-2;
|
||||
}
|
||||
|
||||
> div {
|
||||
@apply grid gap-2;
|
||||
}
|
||||
|
||||
.sections svg {
|
||||
@apply opacity-75 transition-opacity hover:opacity-100;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
@apply overflow-y-scroll p-4;
|
||||
@apply absolute inset-y-0 left-12 right-0 md:left-16;
|
||||
|
||||
> section {
|
||||
@apply grid gap-4;
|
||||
@apply pt-5 pb-7 first:pt-0;
|
||||
@apply border-b border-neutral-900/10 last:border-b-0 dark:border-neutral-50/10;
|
||||
|
||||
hr {
|
||||
@apply my-2;
|
||||
}
|
||||
}
|
||||
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
124
apps/client/components/build/LeftSidebar/LeftSidebar.tsx
Normal file
124
apps/client/components/build/LeftSidebar/LeftSidebar.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import { Add, Star } from '@mui/icons-material';
|
||||
import { Button, Divider, IconButton, SwipeableDrawer, Tooltip, useMediaQuery, useTheme } from '@mui/material';
|
||||
import { Section as SectionRecord } from '@reactive-resume/schema';
|
||||
import get from 'lodash/get';
|
||||
import Link from 'next/link';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useMemo } from 'react';
|
||||
import { validate } from 'uuid';
|
||||
|
||||
import Logo from '@/components/shared/Logo';
|
||||
import { getCustomSections, left } from '@/config/sections';
|
||||
import { setSidebarState } from '@/store/build/buildSlice';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { addSection } from '@/store/resume/resumeSlice';
|
||||
|
||||
import styles from './LeftSidebar.module.scss';
|
||||
import Section from './sections/Section';
|
||||
|
||||
const LeftSidebar = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const isDesktop = useMediaQuery(theme.breakpoints.up('lg'));
|
||||
|
||||
const sections = useAppSelector((state) => state.resume.sections);
|
||||
const { open } = useAppSelector((state) => state.build.sidebar.left);
|
||||
|
||||
const customSections = useMemo(() => getCustomSections(sections), [sections]);
|
||||
|
||||
const handleOpen = () => dispatch(setSidebarState({ sidebar: 'left', state: { open: true } }));
|
||||
|
||||
const handleClose = () => dispatch(setSidebarState({ sidebar: 'left', state: { open: false } }));
|
||||
|
||||
const handleClick = (id: string) => {
|
||||
const elementId = validate(id) ? `#section-${id}` : `#${id}`;
|
||||
const section = document.querySelector(elementId);
|
||||
|
||||
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
|
||||
const handleAddSection = () => {
|
||||
const newSection: SectionRecord = {
|
||||
name: 'Custom Section',
|
||||
type: 'custom',
|
||||
visible: true,
|
||||
columns: 2,
|
||||
items: [],
|
||||
};
|
||||
|
||||
dispatch(addSection({ value: newSection }));
|
||||
};
|
||||
|
||||
return (
|
||||
<SwipeableDrawer
|
||||
open={open}
|
||||
anchor="left"
|
||||
onOpen={handleOpen}
|
||||
onClose={handleClose}
|
||||
PaperProps={{ className: '!shadow-lg' }}
|
||||
variant={isDesktop ? 'persistent' : 'temporary'}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<nav>
|
||||
<div>
|
||||
<Link href="/dashboard">
|
||||
<a className="inline-flex">
|
||||
<Logo size={40} />
|
||||
</a>
|
||||
</Link>
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
<div className={styles.sections}>
|
||||
{left.map(({ id, icon }) => (
|
||||
<Tooltip
|
||||
arrow
|
||||
key={id}
|
||||
placement="right"
|
||||
title={get(sections, `${id}.name`, t(`builder.leftSidebar.sections.${id}.heading`))}
|
||||
>
|
||||
<IconButton onClick={() => handleClick(id)}>{icon}</IconButton>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
{customSections.map(({ id }) => (
|
||||
<Tooltip key={id} title={get(sections, `${id}.name`, '')} placement="right" arrow>
|
||||
<IconButton onClick={() => handleClick(id)}>
|
||||
<Star />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div />
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
{left.map(({ id, component }) => (
|
||||
<section key={id} id={id}>
|
||||
{component}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{customSections.map(({ id }) => (
|
||||
<section key={id} id={`section-${id}`}>
|
||||
<Section path={`sections.${id}`} isEditable isHideable isDeletable />
|
||||
</section>
|
||||
))}
|
||||
|
||||
<div className="py-6 text-right">
|
||||
<Button fullWidth variant="outlined" startIcon={<Add />} onClick={handleAddSection}>
|
||||
{t('builder.common.actions.add', { token: t('builder.leftSidebar.sections.section.heading') })}
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</SwipeableDrawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeftSidebar;
|
||||
81
apps/client/components/build/LeftSidebar/sections/Basics.tsx
Normal file
81
apps/client/components/build/LeftSidebar/sections/Basics.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { PhotoFilter } from '@mui/icons-material';
|
||||
import { Button, Divider, Popover } from '@mui/material';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import ResumeInput from '@/components/shared/ResumeInput';
|
||||
|
||||
import PhotoFilters from './PhotoFilters';
|
||||
import PhotoUpload from './PhotoUpload';
|
||||
|
||||
const Basics = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path="sections.basics" name={t('builder.leftSidebar.sections.basics.heading')} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col items-center gap-4 sm:col-span-2 sm:flex-row">
|
||||
<PhotoUpload />
|
||||
|
||||
<div className="flex w-full flex-col-reverse gap-4 sm:flex-col sm:gap-2">
|
||||
<ResumeInput label={t('builder.leftSidebar.sections.basics.name.label')} path="basics.name" />
|
||||
|
||||
<Button variant="outlined" startIcon={<PhotoFilter />} onClick={handleClick}>
|
||||
{t('builder.leftSidebar.sections.basics.actions.photo-filters')}
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
>
|
||||
<PhotoFilters />
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResumeInput label={t('builder.common.form.email.label')} path="basics.email" className="sm:col-span-2" />
|
||||
<ResumeInput label={t('builder.common.form.phone.label')} path="basics.phone" />
|
||||
<ResumeInput label={t('builder.common.form.url.label')} path="basics.website" />
|
||||
|
||||
<Divider className="sm:col-span-2" />
|
||||
|
||||
<ResumeInput
|
||||
label={t('builder.leftSidebar.sections.basics.headline.label')}
|
||||
path="basics.headline"
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
<ResumeInput
|
||||
type="textarea"
|
||||
label={t('builder.common.form.summary.label')}
|
||||
path="basics.summary"
|
||||
className="sm:col-span-2"
|
||||
markdownSupported
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Basics;
|
||||
@ -0,0 +1,31 @@
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import ResumeInput from '@/components/shared/ResumeInput';
|
||||
|
||||
const Location = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path="sections.location" name={t('builder.leftSidebar.sections.location.heading')} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<ResumeInput
|
||||
label={t('builder.leftSidebar.sections.location.address.label')}
|
||||
path="basics.location.address"
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
<ResumeInput label={t('builder.leftSidebar.sections.location.city.label')} path="basics.location.city" />
|
||||
<ResumeInput label={t('builder.leftSidebar.sections.location.region.label')} path="basics.location.region" />
|
||||
<ResumeInput label={t('builder.leftSidebar.sections.location.country.label')} path="basics.location.country" />
|
||||
<ResumeInput
|
||||
label={t('builder.leftSidebar.sections.location.postal-code.label')}
|
||||
path="basics.location.postalCode"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Location;
|
||||
@ -0,0 +1,97 @@
|
||||
import { Circle, Square, SquareRounded } from '@mui/icons-material';
|
||||
import { Checkbox, Divider, FormControlLabel, Slider, ToggleButton, ToggleButtonGroup } from '@mui/material';
|
||||
import { Photo, PhotoShape } from '@reactive-resume/schema';
|
||||
import get from 'lodash/get';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setResumeState } from '@/store/resume/resumeSlice';
|
||||
|
||||
const PhotoFilters = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const photo: Photo = useAppSelector((state) => get(state.resume, 'basics.photo'));
|
||||
const size: number = get(photo, 'filters.size', 128);
|
||||
const shape: PhotoShape = get(photo, 'filters.shape', 'square');
|
||||
const grayscale: boolean = get(photo, 'filters.grayscale', false);
|
||||
const border: boolean = get(photo, 'filters.border', false);
|
||||
|
||||
const handleChangeSize = (size: number) =>
|
||||
dispatch(setResumeState({ path: 'basics.photo.filters.size', value: size }));
|
||||
|
||||
const handleChangeShape = (shape: PhotoShape) =>
|
||||
dispatch(setResumeState({ path: 'basics.photo.filters.shape', value: shape }));
|
||||
|
||||
const handleSetGrayscale = (value: boolean) =>
|
||||
dispatch(setResumeState({ path: 'basics.photo.filters.grayscale', value }));
|
||||
|
||||
const handleSetBorder = (value: boolean) => dispatch(setResumeState({ path: 'basics.photo.filters.border', value }));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-5 dark:bg-neutral-800">
|
||||
<div>
|
||||
<h4 className="font-medium">{t('builder.leftSidebar.sections.basics.photo-filters.size.heading')}</h4>
|
||||
|
||||
<div className="mx-2">
|
||||
<Slider
|
||||
min={32}
|
||||
max={512}
|
||||
step={2}
|
||||
marks={[
|
||||
{ value: 32, label: '32' },
|
||||
{ value: 128, label: '128' },
|
||||
{ value: 256, label: '256' },
|
||||
{ value: 512, label: '512' },
|
||||
]}
|
||||
value={size}
|
||||
onChange={(_, value: number) => handleChangeSize(value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium">{t('builder.leftSidebar.sections.basics.photo-filters.effects.heading')}</h4>
|
||||
|
||||
<div className="flex items-center">
|
||||
<FormControlLabel
|
||||
label={t('builder.leftSidebar.sections.basics.photo-filters.effects.grayscale.label') as string}
|
||||
control={
|
||||
<Checkbox color="secondary" checked={grayscale} onChange={(_, value) => handleSetGrayscale(value)} />
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
label={t('builder.leftSidebar.sections.basics.photo-filters.effects.border.label') as string}
|
||||
control={<Checkbox color="secondary" checked={border} onChange={(_, value) => handleSetBorder(value)} />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h4 className="font-medium">{t('builder.leftSidebar.sections.basics.photo-filters.shape.heading')}</h4>
|
||||
|
||||
<ToggleButtonGroup exclusive value={shape} onChange={(_, value) => handleChangeShape(value)}>
|
||||
<ToggleButton size="small" value="square" className="w-14">
|
||||
<Square fontSize="small" />
|
||||
</ToggleButton>
|
||||
|
||||
<ToggleButton size="small" value="rounded-square" className="w-14">
|
||||
<SquareRounded fontSize="small" />
|
||||
</ToggleButton>
|
||||
|
||||
<ToggleButton size="small" value="circle" className="w-14">
|
||||
<Circle fontSize="small" />
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhotoFilters;
|
||||
@ -0,0 +1,83 @@
|
||||
import { Avatar, IconButton, Skeleton, Tooltip } from '@mui/material';
|
||||
import { Photo, Resume } from '@reactive-resume/schema';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import React, { useRef } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useMutation } from 'react-query';
|
||||
|
||||
import { ServerError } from '@/services/axios';
|
||||
import { deletePhoto, DeletePhotoParams, uploadPhoto, UploadPhotoParams } from '@/services/resume';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setResumeState } from '@/store/resume/resumeSlice';
|
||||
|
||||
const FILE_UPLOAD_MAX_SIZE = 2000000; // 2 MB
|
||||
|
||||
const PhotoUpload: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const id: number = useAppSelector((state) => get(state.resume, 'id'));
|
||||
const photo: Photo = useAppSelector((state) => get(state.resume, 'basics.photo'));
|
||||
|
||||
const { mutateAsync: uploadMutation, isLoading } = useMutation<Resume, ServerError, UploadPhotoParams>(uploadPhoto);
|
||||
|
||||
const { mutateAsync: deleteMutation } = useMutation<Resume, ServerError, DeletePhotoParams>(deletePhoto);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (fileInputRef.current) {
|
||||
if (!isEmpty(photo.url)) {
|
||||
try {
|
||||
await deleteMutation({ id });
|
||||
} finally {
|
||||
dispatch(setResumeState({ path: 'basics.photo.url', value: '' }));
|
||||
}
|
||||
} else {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files && event.target.files[0]) {
|
||||
const file = event.target.files[0];
|
||||
|
||||
if (file.size > FILE_UPLOAD_MAX_SIZE) {
|
||||
toast.error(t('common.toast.error.upload-photo-size'));
|
||||
return;
|
||||
}
|
||||
|
||||
const resume = await uploadMutation({ id, file });
|
||||
|
||||
dispatch(setResumeState({ path: 'basics.photo.url', value: get(resume, 'basics.photo.url', '') }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IconButton onClick={handleClick}>
|
||||
{isLoading ? (
|
||||
<Skeleton variant="circular" width={96} height={96} />
|
||||
) : (
|
||||
<Tooltip
|
||||
title={
|
||||
isEmpty(photo.url)
|
||||
? t('builder.leftSidebar.sections.basics.photo-upload.tooltip.upload')
|
||||
: t('builder.leftSidebar.sections.basics.photo-upload.tooltip.remove')
|
||||
}
|
||||
>
|
||||
<Avatar sx={{ width: 96, height: 96 }} src={photo.url} />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<input hidden type="file" ref={fileInputRef} onChange={handleChange} accept="image/*" />
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhotoUpload;
|
||||
@ -0,0 +1,52 @@
|
||||
import { Add } from '@mui/icons-material';
|
||||
import { Button } from '@mui/material';
|
||||
import { ListItem } from '@reactive-resume/schema';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import List from '@/components/shared/List';
|
||||
import { useAppDispatch } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
import { duplicateItem } from '@/store/resume/resumeSlice';
|
||||
|
||||
const Profiles = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleAdd = () => {
|
||||
dispatch(setModalState({ modal: 'builder.sections.profile', state: { open: true } }));
|
||||
};
|
||||
|
||||
const handleEdit = (item: ListItem) => {
|
||||
dispatch(setModalState({ modal: 'builder.sections.profile', state: { open: true, payload: { item } } }));
|
||||
};
|
||||
|
||||
const handleDuplicate = (item: ListItem) => {
|
||||
dispatch(duplicateItem({ path: 'basics.profiles', value: item }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path="sections.profiles" name={t('builder.leftSidebar.sections.profiles.heading')} />
|
||||
|
||||
<List
|
||||
path="basics.profiles"
|
||||
titleKey="username"
|
||||
subtitleKey="network"
|
||||
onEdit={handleEdit}
|
||||
onDuplicate={handleDuplicate}
|
||||
/>
|
||||
|
||||
<footer className="flex justify-end">
|
||||
<Button variant="outlined" startIcon={<Add />} onClick={handleAdd}>
|
||||
{t('builder.common.actions.add', {
|
||||
section: t('builder.leftSidebar.sections.profiles.heading', { count: 1 }),
|
||||
})}
|
||||
</Button>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profiles;
|
||||
@ -0,0 +1,84 @@
|
||||
import { Add } from '@mui/icons-material';
|
||||
import { Button } from '@mui/material';
|
||||
import { ListItem } from '@reactive-resume/schema';
|
||||
import clsx from 'clsx';
|
||||
import get from 'lodash/get';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { validate } from 'uuid';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import List from '@/components/shared/List';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { ModalName, setModalState } from '@/store/modal/modalSlice';
|
||||
import { duplicateItem } from '@/store/resume/resumeSlice';
|
||||
|
||||
import SectionSettings from './SectionSettings';
|
||||
|
||||
type Props = {
|
||||
path: `sections.${string}`;
|
||||
name?: string;
|
||||
titleKey?: string;
|
||||
subtitleKey?: string;
|
||||
isEditable?: boolean;
|
||||
isHideable?: boolean;
|
||||
isDeletable?: boolean;
|
||||
};
|
||||
|
||||
const Section: React.FC<Props> = ({
|
||||
path,
|
||||
name = 'Section Name',
|
||||
titleKey = 'title',
|
||||
subtitleKey = 'subtitle',
|
||||
isEditable = false,
|
||||
isHideable = false,
|
||||
isDeletable = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const heading = useAppSelector<string>((state) => get(state.resume, `${path}.name`, name));
|
||||
const visibility = useAppSelector<boolean>((state) => get(state.resume, `${path}.visible`, true));
|
||||
|
||||
const handleAdd = () => {
|
||||
const id = path.split('.').at(-1);
|
||||
const modal: ModalName = validate(id) ? 'builder.sections.custom' : `builder.${path}`;
|
||||
|
||||
dispatch(setModalState({ modal, state: { open: true, payload: { path } } }));
|
||||
};
|
||||
|
||||
const handleEdit = (item: ListItem) => {
|
||||
const id = path.split('.').at(-1);
|
||||
const modal: ModalName = validate(id) ? 'builder.sections.custom' : `builder.${path}`;
|
||||
const payload = validate(id) ? { path, item } : { item };
|
||||
|
||||
dispatch(setModalState({ modal, state: { open: true, payload } }));
|
||||
};
|
||||
|
||||
const handleDuplicate = (item: ListItem) => dispatch(duplicateItem({ path: `${path}.items`, value: item }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path={path} name={name} isEditable={isEditable} isHideable={isHideable} isDeletable={isDeletable} />
|
||||
|
||||
<List
|
||||
path={`${path}.items`}
|
||||
titleKey={titleKey}
|
||||
subtitleKey={subtitleKey}
|
||||
onEdit={handleEdit}
|
||||
onDuplicate={handleDuplicate}
|
||||
className={clsx({ 'opacity-50': !visibility })}
|
||||
/>
|
||||
|
||||
<footer className="flex items-center justify-between">
|
||||
<SectionSettings path={path} />
|
||||
|
||||
<Button variant="outlined" startIcon={<Add />} onClick={handleAdd}>
|
||||
{t('builder.common.actions.add', { token: heading })}
|
||||
</Button>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Section;
|
||||
@ -0,0 +1,66 @@
|
||||
import { ViewWeek } from '@mui/icons-material';
|
||||
import { ButtonBase, Popover, ToggleButton, ToggleButtonGroup, Tooltip } from '@mui/material';
|
||||
import get from 'lodash/get';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setResumeState } from '@/store/resume/resumeSlice';
|
||||
|
||||
type Props = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
const SectionSettings: React.FC<Props> = ({ path }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||
|
||||
const columns = useAppSelector<number>((state) => get(state.resume, `${path}.columns`, 2));
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleSetColumns = (index: number) => dispatch(setResumeState({ path: `${path}.columns`, value: index }));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tooltip title={t('builder.common.columns.tooltip')}>
|
||||
<ButtonBase onClick={handleClick} sx={{ padding: 1, borderRadius: 1 }} className="opacity-50 hover:opacity-75">
|
||||
<ViewWeek /> <span className="ml-1.5 text-xs">{columns}</span>
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
>
|
||||
<div className="p-5 dark:bg-neutral-800">
|
||||
<h4 className="mb-2 font-medium">{t('builder.common.columns.heading')}</h4>
|
||||
|
||||
<ToggleButtonGroup exclusive value={columns} onChange={(_, value: number) => handleSetColumns(value)}>
|
||||
{[1, 2, 3, 4].map((index) => (
|
||||
<ToggleButton key={index} value={index} size="small" className="w-12">
|
||||
{index}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionSettings;
|
||||
@ -0,0 +1,50 @@
|
||||
.container {
|
||||
@apply h-screen w-[95vw] md:w-[70vw] lg:w-[50vw] xl:w-[30vw] 2xl:w-[28vw];
|
||||
@apply bg-neutral-50 text-neutral-900 dark:bg-neutral-900 dark:text-neutral-50;
|
||||
@apply relative flex border-l-2 border-neutral-50/10;
|
||||
|
||||
nav {
|
||||
@apply absolute inset-y-0 right-0;
|
||||
@apply w-12 py-4 md:w-16 md:px-2;
|
||||
@apply bg-neutral-100 shadow dark:bg-neutral-800;
|
||||
@apply flex flex-col items-center justify-between;
|
||||
|
||||
hr {
|
||||
@apply mt-2;
|
||||
}
|
||||
|
||||
> div {
|
||||
@apply grid gap-2;
|
||||
}
|
||||
|
||||
.sections svg {
|
||||
@apply opacity-75 transition-opacity hover:opacity-100;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
@apply overflow-y-scroll p-4;
|
||||
@apply absolute inset-y-0 right-12 left-0 md:right-16;
|
||||
|
||||
> section {
|
||||
@apply grid gap-4;
|
||||
@apply pt-5 pb-7 first:pt-0;
|
||||
@apply border-b border-neutral-900/10 last:border-b-0 dark:border-neutral-50/10;
|
||||
|
||||
hr {
|
||||
@apply my-2;
|
||||
}
|
||||
}
|
||||
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
@apply flex flex-col items-center justify-center gap-3 py-4;
|
||||
@apply text-center text-xs leading-normal opacity-40;
|
||||
}
|
||||
}
|
||||
83
apps/client/components/build/RightSidebar/RightSidebar.tsx
Normal file
83
apps/client/components/build/RightSidebar/RightSidebar.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { Divider, IconButton, SwipeableDrawer, Tooltip, useMediaQuery, useTheme } from '@mui/material';
|
||||
import { capitalize } from 'lodash';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import Avatar from '@/components/shared/Avatar';
|
||||
import Footer from '@/components/shared/Footer';
|
||||
import { right } from '@/config/sections';
|
||||
import { setSidebarState } from '@/store/build/buildSlice';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
|
||||
import styles from './RightSidebar.module.scss';
|
||||
|
||||
const RightSidebar = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const isDesktop = useMediaQuery(theme.breakpoints.up('lg'));
|
||||
|
||||
const { open } = useAppSelector((state) => state.build.sidebar.right);
|
||||
|
||||
const handleOpen = () => dispatch(setSidebarState({ sidebar: 'right', state: { open: true } }));
|
||||
|
||||
const handleClose = () => dispatch(setSidebarState({ sidebar: 'right', state: { open: false } }));
|
||||
|
||||
const handleClick = (id: string) => {
|
||||
const section = document.querySelector(`#${id}`);
|
||||
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
|
||||
return (
|
||||
<SwipeableDrawer
|
||||
open={open}
|
||||
anchor="right"
|
||||
onOpen={handleOpen}
|
||||
onClose={handleClose}
|
||||
PaperProps={{ className: '!shadow-lg' }}
|
||||
variant={isDesktop ? 'persistent' : 'temporary'}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<nav>
|
||||
<div>
|
||||
<Avatar size={40} />
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
<div className={styles.sections}>
|
||||
{right.map(({ id, icon }) => (
|
||||
<Tooltip
|
||||
key={id}
|
||||
arrow
|
||||
placement="right"
|
||||
title={t(`builder.rightSidebar.sections.${id}.heading`, { defaultValue: capitalize(id) })}
|
||||
>
|
||||
<IconButton onClick={() => handleClick(id)}>{icon}</IconButton>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div />
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
{right.map(({ id, component }) => (
|
||||
<section key={id} id={id}>
|
||||
{component}
|
||||
</section>
|
||||
))}
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<Footer />
|
||||
|
||||
<div>v{process.env.NEXT_PUBLIC_APP_VERSION}</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</SwipeableDrawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default RightSidebar;
|
||||
@ -0,0 +1,49 @@
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { CustomCSS as CustomCSSType } from '@reactive-resume/schema';
|
||||
import clsx from 'clsx';
|
||||
import get from 'lodash/get';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import React from 'react';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setResumeState } from '@/store/resume/resumeSlice';
|
||||
|
||||
const CustomCSS = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const customCSS: CustomCSSType = useAppSelector((state) => get(state.resume, 'metadata.css', {}));
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
dispatch(setResumeState({ path: 'metadata.css.value', value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path="metadata.css" name={t('builder.rightSidebar.sections.css.heading')} isHideable />
|
||||
|
||||
<Editor
|
||||
height="200px"
|
||||
language="css"
|
||||
value={customCSS.value}
|
||||
onChange={handleChange}
|
||||
className={clsx({ 'opacity-50': !customCSS.visible })}
|
||||
theme={theme.palette.mode === 'dark' ? 'vs-dark' : 'light'}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
overviewRulerLanes: 0,
|
||||
scrollBeyondLastColumn: 5,
|
||||
overviewRulerBorder: false,
|
||||
scrollBeyondLastLine: true,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomCSS;
|
||||
@ -0,0 +1,81 @@
|
||||
import { PictureAsPdf, Schema } from '@mui/icons-material';
|
||||
import { List, ListItem, ListItemButton, ListItemText } from '@mui/material';
|
||||
import get from 'lodash/get';
|
||||
import pick from 'lodash/pick';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useMutation } from 'react-query';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import { ServerError } from '@/services/axios';
|
||||
import { printResumeAsPdf, PrintResumeAsPdfParams } from '@/services/printer';
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
|
||||
const Export = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const resume = useAppSelector((state) => state.resume);
|
||||
|
||||
const { mutateAsync, isLoading } = useMutation<string, ServerError, PrintResumeAsPdfParams>(printResumeAsPdf);
|
||||
|
||||
const pdfListItemText = {
|
||||
normal: {
|
||||
primary: t('builder.rightSidebar.sections.export.pdf.normal.primary'),
|
||||
secondary: t('builder.rightSidebar.sections.export.pdf.normal.secondary'),
|
||||
},
|
||||
loading: {
|
||||
primary: t('builder.rightSidebar.sections.export.pdf.loading.primary'),
|
||||
secondary: t('builder.rightSidebar.sections.export.pdf.loading.secondary'),
|
||||
},
|
||||
};
|
||||
|
||||
const handleExportJSON = async () => {
|
||||
const { nanoid } = await import('nanoid');
|
||||
const download = (await import('downloadjs')).default;
|
||||
|
||||
const redactedResume = pick(resume, ['basics', 'sections', 'metadata', 'public']);
|
||||
const jsonString = JSON.stringify(redactedResume, null, 4);
|
||||
const filename = `RxResume_JSONExport_${nanoid()}.json`;
|
||||
|
||||
download(jsonString, filename, 'application/json');
|
||||
};
|
||||
|
||||
const handleExportPDF = async () => {
|
||||
const download = (await import('downloadjs')).default;
|
||||
|
||||
const slug = get(resume, 'slug');
|
||||
const username = get(resume, 'user.username');
|
||||
|
||||
const url = await mutateAsync({ username, slug });
|
||||
|
||||
download(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path="metadata.export" name={t('builder.rightSidebar.sections.export.heading')} />
|
||||
|
||||
<List sx={{ padding: 0 }}>
|
||||
<ListItem sx={{ padding: 0 }}>
|
||||
<ListItemButton className="gap-6" onClick={handleExportJSON}>
|
||||
<Schema />
|
||||
|
||||
<ListItemText
|
||||
primary={t('builder.rightSidebar.sections.export.json.primary')}
|
||||
secondary={t('builder.rightSidebar.sections.export.json.secondary')}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
<ListItem sx={{ padding: 0 }}>
|
||||
<ListItemButton className="gap-6" onClick={handleExportPDF} disabled={isLoading}>
|
||||
<PictureAsPdf />
|
||||
|
||||
<ListItemText {...(isLoading ? pdfListItemText.loading : pdfListItemText.normal)} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Export;
|
||||
@ -0,0 +1,43 @@
|
||||
.page {
|
||||
@apply relative border pl-4 pb-4 dark:border-neutral-100/10;
|
||||
@apply rounded bg-neutral-100 dark:bg-neutral-800;
|
||||
|
||||
.delete {
|
||||
@apply opacity-50 hover:opacity-75;
|
||||
@apply rotate-0 hover:rotate-90;
|
||||
@apply transition-[opacity,transform];
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply grid grid-cols-2 gap-2;
|
||||
}
|
||||
|
||||
.heading {
|
||||
@apply relative z-10 my-3;
|
||||
@apply text-xs font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
.column {
|
||||
@apply relative w-full px-4;
|
||||
|
||||
.heading {
|
||||
@apply relative z-10 my-3;
|
||||
@apply text-xs font-semibold;
|
||||
}
|
||||
|
||||
.base {
|
||||
@apply absolute inset-0 w-4/5;
|
||||
@apply rounded bg-neutral-200 dark:bg-neutral-700;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
@apply relative my-3 w-full px-4 py-2;
|
||||
@apply cursor-move break-all rounded text-xs capitalize;
|
||||
@apply bg-neutral-800/90 text-neutral-50 dark:bg-neutral-50/90 dark:text-neutral-800;
|
||||
|
||||
&.disabled {
|
||||
@apply opacity-60;
|
||||
}
|
||||
}
|
||||
141
apps/client/components/build/RightSidebar/sections/Layout.tsx
Normal file
141
apps/client/components/build/RightSidebar/sections/Layout.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import { Add, Close, Restore } from '@mui/icons-material';
|
||||
import { Button, IconButton, Tooltip } from '@mui/material';
|
||||
import clsx from 'clsx';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import get from 'lodash/get';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { DragDropContext, Draggable, DraggableLocation, Droppable, DropResult } from 'react-beautiful-dnd';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { addPage, deletePage, setResumeState } from '@/store/resume/resumeSlice';
|
||||
|
||||
import styles from './Layout.module.scss';
|
||||
|
||||
const getIndices = (location: DraggableLocation) => ({
|
||||
page: +location.droppableId.split('.')[0],
|
||||
column: +location.droppableId.split('.')[1],
|
||||
section: +location.index,
|
||||
});
|
||||
|
||||
const Layout = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const layout = useAppSelector((state) => state.resume.metadata.layout);
|
||||
const resumeSections = useAppSelector((state) => state.resume.sections);
|
||||
|
||||
const onDragEnd = (dropResult: DropResult) => {
|
||||
const { source: srcLoc, destination: destLoc } = dropResult;
|
||||
|
||||
if (!destLoc) return;
|
||||
|
||||
const newLayout = cloneDeep(layout);
|
||||
|
||||
const srcIndex = getIndices(srcLoc);
|
||||
const destIndex = getIndices(destLoc);
|
||||
const section = layout[srcIndex.page][srcIndex.column][srcIndex.section];
|
||||
|
||||
// Remove item at source
|
||||
newLayout[srcIndex.page][srcIndex.column].splice(srcIndex.section, 1);
|
||||
|
||||
// Insert item at destination
|
||||
newLayout[destIndex.page][destIndex.column].splice(destIndex.section, 0, section);
|
||||
|
||||
dispatch(setResumeState({ path: 'metadata.layout', value: newLayout }));
|
||||
};
|
||||
|
||||
const handleAddPage = () => dispatch(addPage());
|
||||
|
||||
const handleDeletePage = (page: number) => dispatch(deletePage({ page }));
|
||||
|
||||
const handleResetLayout = () => {
|
||||
for (let i = layout.length - 1; i > 0; i--) {
|
||||
handleDeletePage(i);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading
|
||||
path="metadata.layout"
|
||||
name={t('builder.rightSidebar.sections.layout.heading')}
|
||||
action={
|
||||
<Tooltip title={t('builder.rightSidebar.sections.layout.tooltip.reset-layout')}>
|
||||
<IconButton onClick={handleResetLayout}>
|
||||
<Restore />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
{/* Pages */}
|
||||
{layout.map((columns, pageIndex) => (
|
||||
<div key={pageIndex} className={styles.page}>
|
||||
<div className="flex items-center justify-between pr-3">
|
||||
<p className={styles.heading}>
|
||||
{t('builder.common.glossary.page')} {pageIndex + 1}
|
||||
</p>
|
||||
|
||||
<div className={clsx(styles.delete, { hidden: pageIndex === 0 })}>
|
||||
<Tooltip title={t('builder.common.actions.delete', { token: t('builder.common.glossary.page') })}>
|
||||
<IconButton size="small" onClick={() => handleDeletePage(pageIndex)}>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.container}>
|
||||
{/* Sections */}
|
||||
{columns.map((sections, columnIndex) => {
|
||||
const index = `${pageIndex}.${columnIndex}`;
|
||||
|
||||
return (
|
||||
<Droppable key={index} droppableId={index}>
|
||||
{(provided) => (
|
||||
<div ref={provided.innerRef} className={styles.column} {...provided.droppableProps}>
|
||||
<p className={styles.heading}>{columnIndex ? 'Sidebar' : 'Main'}</p>
|
||||
|
||||
<div className={styles.base} />
|
||||
|
||||
{/* Sections */}
|
||||
{sections.map((sectionId, sectionIndex) => (
|
||||
<Draggable key={sectionId} draggableId={sectionId} index={sectionIndex}>
|
||||
{(provided) => (
|
||||
<div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>
|
||||
<div
|
||||
className={clsx(styles.section, {
|
||||
[styles.disabled]: !get(resumeSections, `${sectionId}.visible`, true),
|
||||
})}
|
||||
>
|
||||
{get(resumeSections, `${sectionId}.name`)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<Button variant="outlined" startIcon={<Add />} onClick={handleAddPage}>
|
||||
{t('builder.common.actions.add', { token: t('builder.common.glossary.page') })}
|
||||
</Button>
|
||||
</div>
|
||||
</DragDropContext>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@ -0,0 +1,20 @@
|
||||
.container {
|
||||
@apply grid gap-4;
|
||||
|
||||
.section {
|
||||
@apply grid gap-2 rounded p-6;
|
||||
@apply bg-neutral-100 dark:bg-neutral-800;
|
||||
|
||||
h2 {
|
||||
@apply inline-flex items-center gap-2 text-base font-medium;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply mb-3 text-xs leading-loose;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
@apply hover:no-underline;
|
||||
}
|
||||
}
|
||||
56
apps/client/components/build/RightSidebar/sections/Links.tsx
Normal file
56
apps/client/components/build/RightSidebar/sections/Links.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { BugReport, Coffee, GitHub, Link, Savings } from '@mui/icons-material';
|
||||
import { Button } from '@mui/material';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import { DONATION_URL, GITHUB_ISSUES_URL, GITHUB_URL } from '@/constants/index';
|
||||
|
||||
import styles from './Links.module.scss';
|
||||
|
||||
const Links = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path="metadata.links" name={t('builder.rightSidebar.sections.links.heading')} />
|
||||
|
||||
<div className={styles.container}>
|
||||
<div className={styles.section}>
|
||||
<h2>
|
||||
<Savings fontSize="small" />
|
||||
{t('builder.rightSidebar.sections.links.donate.heading')}
|
||||
</h2>
|
||||
|
||||
<p>{t('builder.rightSidebar.sections.links.donate.body')}</p>
|
||||
|
||||
<a href={DONATION_URL} target="_blank" rel="noreferrer">
|
||||
<Button startIcon={<Coffee />}>{t('builder.rightSidebar.sections.links.donate.button')}</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<h2>
|
||||
<BugReport fontSize="small" />
|
||||
{t('builder.rightSidebar.sections.links.bugs-features.heading')}
|
||||
</h2>
|
||||
|
||||
<p>{t('builder.rightSidebar.sections.links.bugs-features.body')}</p>
|
||||
|
||||
<a href={GITHUB_ISSUES_URL} target="_blank" rel="noreferrer">
|
||||
<Button startIcon={<GitHub />}>{t('builder.rightSidebar.sections.links.bugs-features.button')}</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a href={GITHUB_URL} target="_blank" rel="noreferrer">
|
||||
<Button variant="text" startIcon={<Link />}>
|
||||
{t('builder.rightSidebar.sections.links.github')}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Links;
|
||||
198
apps/client/components/build/RightSidebar/sections/Settings.tsx
Normal file
198
apps/client/components/build/RightSidebar/sections/Settings.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
import { Anchor, DeleteForever, Palette } from '@mui/icons-material';
|
||||
import {
|
||||
Autocomplete,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
ListSubheader,
|
||||
Switch,
|
||||
TextField,
|
||||
} from '@mui/material';
|
||||
import { DateConfig, Resume } from '@reactive-resume/schema';
|
||||
import dayjs from 'dayjs';
|
||||
import get from 'lodash/get';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useMemo } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import ThemeSwitch from '@/components/shared/ThemeSwitch';
|
||||
import { Language, languageMap, languages } from '@/config/languages';
|
||||
import { ServerError } from '@/services/axios';
|
||||
import queryClient from '@/services/react-query';
|
||||
import { loadSampleData, LoadSampleDataParams, resetResume, ResetResumeParams } from '@/services/resume';
|
||||
import { setTheme, togglePageBreakLine, togglePageOrientation } from '@/store/build/buildSlice';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setResumeState } from '@/store/resume/resumeSlice';
|
||||
import { dateFormatOptions } from '@/utils/date';
|
||||
|
||||
const Settings = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const resume = useAppSelector((state) => state.resume);
|
||||
const theme = useAppSelector((state) => state.build.theme);
|
||||
const breakLine = useAppSelector((state) => state.build.page.breakLine);
|
||||
const orientation = useAppSelector((state) => state.build.page.orientation);
|
||||
|
||||
const id: number = useMemo(() => get(resume, 'id'), [resume]);
|
||||
const slug: string = useMemo(() => get(resume, 'slug'), [resume]);
|
||||
const username: string = useMemo(() => get(resume, 'user.username'), [resume]);
|
||||
const language: string = useMemo(() => get(resume, 'metadata.language'), [resume]);
|
||||
const dateConfig: DateConfig = useMemo(() => get(resume, 'metadata.date'), [resume]);
|
||||
|
||||
const isDarkMode = useMemo(() => theme === 'dark', [theme]);
|
||||
const exampleString = useMemo(() => `Eg. ${dayjs().format(dateConfig.format)}`, [dateConfig.format]);
|
||||
const themeString = useMemo(() => (isDarkMode ? 'Matte Black Everything' : 'As bright as your future'), [isDarkMode]);
|
||||
|
||||
const { mutateAsync: loadSampleDataMutation } = useMutation<Resume, ServerError, LoadSampleDataParams>(
|
||||
loadSampleData
|
||||
);
|
||||
const { mutateAsync: resetResumeMutation } = useMutation<Resume, ServerError, ResetResumeParams>(resetResume);
|
||||
|
||||
const handleSetTheme = (value: boolean) => dispatch(setTheme({ theme: value ? 'dark' : 'light' }));
|
||||
|
||||
const handleChangeDateFormat = (value: string) => dispatch(setResumeState({ path: 'metadata.date.format', value }));
|
||||
|
||||
const handleChangeLanguage = (value: Language) =>
|
||||
dispatch(setResumeState({ path: 'metadata.language', value: value.code }));
|
||||
|
||||
const handleLoadSampleData = async () => {
|
||||
await loadSampleDataMutation({ id });
|
||||
|
||||
queryClient.invalidateQueries(`resume/${username}/${slug}`);
|
||||
};
|
||||
|
||||
const handleResetResume = async () => {
|
||||
await resetResumeMutation({ id });
|
||||
|
||||
queryClient.invalidateQueries(`resume/${username}/${slug}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path="metadata.settings" name={t('builder.rightSidebar.sections.settings.heading')} />
|
||||
|
||||
<List sx={{ padding: 0 }}>
|
||||
{/* Global Settings */}
|
||||
<>
|
||||
<ListSubheader className="rounded">
|
||||
{t('builder.rightSidebar.sections.settings.global.heading')}
|
||||
</ListSubheader>
|
||||
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Palette />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('builder.rightSidebar.sections.settings.global.theme.primary')}
|
||||
secondary={themeString}
|
||||
/>
|
||||
<ThemeSwitch checked={isDarkMode} onChange={(_, value: boolean) => handleSetTheme(value)} />
|
||||
</ListItem>
|
||||
|
||||
<ListItem className="flex-col">
|
||||
<ListItemText
|
||||
className="w-full"
|
||||
primary={t('builder.rightSidebar.sections.settings.global.date.primary')}
|
||||
secondary={t('builder.rightSidebar.sections.settings.global.date.secondary')}
|
||||
/>
|
||||
<Autocomplete<string, false, boolean, false>
|
||||
disableClearable
|
||||
className="my-2 w-full"
|
||||
options={dateFormatOptions}
|
||||
value={dateConfig.format}
|
||||
onChange={(_, value) => handleChangeDateFormat(value)}
|
||||
renderInput={(params) => <TextField {...params} helperText={exampleString} />}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem className="flex-col">
|
||||
<ListItemText
|
||||
className="w-full"
|
||||
primary={t('builder.rightSidebar.sections.settings.global.language.primary')}
|
||||
secondary={t('builder.rightSidebar.sections.settings.global.language.secondary')}
|
||||
/>
|
||||
<Autocomplete<Language, false, boolean, false>
|
||||
disableClearable
|
||||
className="my-2 w-full"
|
||||
options={languages}
|
||||
value={languageMap[language]}
|
||||
getOptionLabel={(language) => {
|
||||
if (language.localName) {
|
||||
return `${language.name} (${language.localName})`;
|
||||
}
|
||||
|
||||
return language.name;
|
||||
}}
|
||||
isOptionEqualToValue={(a, b) => a.code === b.code}
|
||||
onChange={(_, value) => handleChangeLanguage(value)}
|
||||
renderInput={(params) => <TextField {...params} />}
|
||||
/>
|
||||
</ListItem>
|
||||
</>
|
||||
|
||||
{/* Page Settings */}
|
||||
<>
|
||||
<ListSubheader className="rounded">{t('builder.rightSidebar.sections.settings.page.heading')}</ListSubheader>
|
||||
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={t('builder.rightSidebar.sections.settings.page.orientation.primary')}
|
||||
secondary={t('builder.rightSidebar.sections.settings.page.orientation.secondary')}
|
||||
/>
|
||||
<Switch
|
||||
color="secondary"
|
||||
checked={orientation === 'horizontal'}
|
||||
onChange={() => dispatch(togglePageOrientation())}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={t('builder.rightSidebar.sections.settings.page.break-line.primary')}
|
||||
secondary={t('builder.rightSidebar.sections.settings.page.break-line.secondary')}
|
||||
/>
|
||||
<Switch color="secondary" checked={breakLine} onChange={() => dispatch(togglePageBreakLine())} />
|
||||
</ListItem>
|
||||
</>
|
||||
|
||||
{/* Resume Settings */}
|
||||
<>
|
||||
<ListSubheader className="rounded">
|
||||
{t('builder.rightSidebar.sections.settings.resume.heading')}
|
||||
</ListSubheader>
|
||||
|
||||
<ListItem>
|
||||
<ListItemButton onClick={handleLoadSampleData}>
|
||||
<ListItemIcon>
|
||||
<Anchor />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('builder.rightSidebar.sections.settings.resume.sample.primary')}
|
||||
secondary={t('builder.rightSidebar.sections.settings.resume.sample.secondary')}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemButton onClick={handleResetResume}>
|
||||
<ListItemIcon>
|
||||
<DeleteForever />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('builder.rightSidebar.sections.settings.resume.reset.primary')}
|
||||
secondary={t('builder.rightSidebar.sections.settings.resume.reset.secondary')}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</>
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
@ -0,0 +1,78 @@
|
||||
import { CopyAll } from '@mui/icons-material';
|
||||
import { Checkbox, FormControlLabel, IconButton, List, ListItem, ListItemText, Switch, TextField } from '@mui/material';
|
||||
import get from 'lodash/get';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setResumeState } from '@/store/resume/resumeSlice';
|
||||
import getResumeUrl from '@/utils/getResumeUrl';
|
||||
|
||||
const Sharing = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [showShortUrl, setShowShortUrl] = useState(false);
|
||||
|
||||
const resume = useAppSelector((state) => state.resume);
|
||||
const isPublic = useMemo(() => get(resume, 'public'), [resume]);
|
||||
const url = useMemo(() => getResumeUrl(resume, { withHost: true }), [resume]);
|
||||
const shortUrl = useMemo(() => getResumeUrl(resume, { withHost: true, shortUrl: true }), [resume]);
|
||||
|
||||
const handleSetVisibility = (value: boolean) => dispatch(setResumeState({ path: 'public', value }));
|
||||
|
||||
const handleCopyToClipboard = async () => {
|
||||
const text = showShortUrl ? shortUrl : url;
|
||||
|
||||
await navigator.clipboard.writeText(text);
|
||||
|
||||
toast.success(t('common.toast.success.resume-link-copied'));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path="metadata.sharing" name={t('builder.rightSidebar.sections.sharing.heading')} />
|
||||
|
||||
<List sx={{ padding: 0 }}>
|
||||
<ListItem className="flex flex-col" sx={{ padding: 0 }}>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<ListItemText
|
||||
primary={t('builder.rightSidebar.sections.sharing.visibility.title')}
|
||||
secondary={t('builder.rightSidebar.sections.sharing.visibility.subtitle')}
|
||||
/>
|
||||
<Switch color="secondary" checked={isPublic} onChange={(_, value) => handleSetVisibility(value)} />
|
||||
</div>
|
||||
|
||||
<div className="mt-2 w-full">
|
||||
<TextField
|
||||
disabled
|
||||
fullWidth
|
||||
value={showShortUrl ? shortUrl : url}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton onClick={handleCopyToClipboard}>
|
||||
<CopyAll />
|
||||
</IconButton>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex w-full">
|
||||
<FormControlLabel
|
||||
label={t('builder.rightSidebar.sections.sharing.short-url.label') as string}
|
||||
control={
|
||||
<Checkbox className="mr-1" checked={showShortUrl} onChange={(_, value) => setShowShortUrl(value)} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</ListItem>
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sharing;
|
||||
@ -0,0 +1,22 @@
|
||||
.container {
|
||||
@apply grid grid-cols-2 gap-4;
|
||||
}
|
||||
|
||||
.template {
|
||||
@apply grid text-center;
|
||||
|
||||
.preview {
|
||||
aspect-ratio: 1 / 1.4142;
|
||||
|
||||
@apply relative grid rounded;
|
||||
@apply border-2 border-transparent;
|
||||
|
||||
&.selected {
|
||||
@apply border-black dark:border-white;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply mt-1 text-xs font-medium;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
import { ButtonBase } from '@mui/material';
|
||||
import clsx from 'clsx';
|
||||
import get from 'lodash/get';
|
||||
import Image from 'next/image';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setResumeState } from '@/store/resume/resumeSlice';
|
||||
import templateMap, { TemplateMeta } from '@/templates/templateMap';
|
||||
|
||||
import styles from './Templates.module.scss';
|
||||
|
||||
const Templates = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const currentTemplate: string = useAppSelector((state) => get(state.resume, 'metadata.template'));
|
||||
|
||||
const handleChange = (template: TemplateMeta) => {
|
||||
dispatch(setResumeState({ path: 'metadata.template', value: template.id }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path="metadata.templates" name={t('builder.rightSidebar.sections.templates.heading')} />
|
||||
|
||||
<div className={styles.container}>
|
||||
{Object.values(templateMap).map((template) => (
|
||||
<div key={template.id} className={styles.template}>
|
||||
<div className={clsx(styles.preview, { [styles.selected]: template.id === currentTemplate })}>
|
||||
<ButtonBase onClick={() => handleChange(template)}>
|
||||
<Image src={template.preview} alt={template.name} className="rounded-sm" layout="fill" />
|
||||
</ButtonBase>
|
||||
</div>
|
||||
|
||||
<p className={styles.label}>{template.name}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Templates;
|
||||
@ -0,0 +1,8 @@
|
||||
.container {
|
||||
@apply grid sm:grid-cols-2 gap-4;
|
||||
}
|
||||
|
||||
.colorOptions {
|
||||
@apply col-span-2 mb-4;
|
||||
@apply grid grid-cols-8 gap-y-2 justify-items-center;
|
||||
}
|
||||
57
apps/client/components/build/RightSidebar/sections/Theme.tsx
Normal file
57
apps/client/components/build/RightSidebar/sections/Theme.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { Theme } from '@reactive-resume/schema';
|
||||
import get from 'lodash/get';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import ColorAvatar from '@/components/shared/ColorAvatar';
|
||||
import ColorPicker from '@/components/shared/ColorPicker';
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import { colorOptions } from '@/config/colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setResumeState } from '@/store/resume/resumeSlice';
|
||||
|
||||
import styles from './Theme.module.scss';
|
||||
|
||||
const Theme = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { background, text, primary } = useAppSelector<Theme>((state) => get(state.resume, 'metadata.theme'));
|
||||
|
||||
const handleChange = (property: string, color: string) => {
|
||||
dispatch(setResumeState({ path: `metadata.theme.${property}`, value: color }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path="metadata.theme" name={t('builder.rightSidebar.sections.theme.heading')} />
|
||||
|
||||
<div className={styles.container}>
|
||||
<div className={styles.colorOptions}>
|
||||
{colorOptions.map((color) => (
|
||||
<ColorAvatar key={color} color={color} onClick={(color) => handleChange('primary', color)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ColorPicker
|
||||
label={t('builder.rightSidebar.sections.theme.form.primary.label')}
|
||||
color={primary}
|
||||
className="col-span-2"
|
||||
onChange={(color) => handleChange('primary', color)}
|
||||
/>
|
||||
<ColorPicker
|
||||
label={t('builder.rightSidebar.sections.theme.form.background.label')}
|
||||
color={background}
|
||||
onChange={(color) => handleChange('background', color)}
|
||||
/>
|
||||
<ColorPicker
|
||||
label={t('builder.rightSidebar.sections.theme.form.text.label')}
|
||||
color={text}
|
||||
onChange={(color) => handleChange('text', color)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Theme;
|
||||
@ -0,0 +1,11 @@
|
||||
.container {
|
||||
@apply grid gap-4 xl:grid-cols-2;
|
||||
}
|
||||
|
||||
.subheading {
|
||||
@apply mt-2 font-medium;
|
||||
}
|
||||
|
||||
.slider {
|
||||
@apply px-6;
|
||||
}
|
||||
@ -0,0 +1,106 @@
|
||||
import { Autocomplete, Skeleton, Slider, TextField } from '@mui/material';
|
||||
import { Font, TypeCategory, TypeProperty, Typography as TypographyType } from '@reactive-resume/schema';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import Heading from '@/components/shared/Heading';
|
||||
import { FONTS_QUERY } from '@/constants/index';
|
||||
import { fetchFonts } from '@/services/fonts';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setResumeState } from '@/store/resume/resumeSlice';
|
||||
|
||||
import styles from './Typography.module.scss';
|
||||
|
||||
const TypographySkeleton: React.FC = () => (
|
||||
<>
|
||||
<Skeleton variant="text" />
|
||||
<div className={styles.container}>
|
||||
<Skeleton variant="rectangular" height={60} />
|
||||
<Skeleton variant="rectangular" height={60} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
type WidgetProps = {
|
||||
label: string;
|
||||
category: TypeCategory;
|
||||
};
|
||||
|
||||
const Widgets: React.FC<WidgetProps> = ({ label, category }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { family, size } = useAppSelector<TypographyType>((state) => get(state.resume, 'metadata.typography'));
|
||||
|
||||
const { data: fonts } = useQuery(FONTS_QUERY, fetchFonts, {
|
||||
select: (fonts) => fonts.sort((a, b) => a.category.localeCompare(b.category)),
|
||||
});
|
||||
|
||||
const handleChange = (property: TypeProperty, value: number | Font) => {
|
||||
if (value === null) return;
|
||||
|
||||
dispatch(
|
||||
setResumeState({
|
||||
path: `metadata.typography.${property}.${category}`,
|
||||
value: property === 'family' ? (value as Font).family : value,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
if (isEmpty(fonts)) return <TypographySkeleton />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h5 className={styles.subheading}>{label}</h5>
|
||||
|
||||
<div className={styles.container}>
|
||||
<div className={styles.slider}>
|
||||
<Slider
|
||||
min={12}
|
||||
max={36}
|
||||
step={1}
|
||||
marks={[
|
||||
{ value: 12, label: '12px' },
|
||||
{ value: 24, label: t('builder.rightSidebar.sections.typography.form.font-size.label') },
|
||||
{ value: 36, label: '36px' },
|
||||
]}
|
||||
valueLabelDisplay="auto"
|
||||
value={size[category]}
|
||||
onChange={(_, size: number) => handleChange('size', size)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Autocomplete<Font, false, boolean, false>
|
||||
options={fonts}
|
||||
disableClearable={true}
|
||||
groupBy={(font) => font.category}
|
||||
getOptionLabel={(font) => font.family}
|
||||
isOptionEqualToValue={(a, b) => a.family === b.family}
|
||||
value={fonts.find((font) => font.family === family[category])}
|
||||
onChange={(_, font: Font) => handleChange('family', font)}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label={t('builder.rightSidebar.sections.typography.form.font-family.label')} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Typography = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading path="metadata.typography" name={t('builder.rightSidebar.sections.typography.heading')} />
|
||||
|
||||
<Widgets label={t('builder.rightSidebar.sections.typography.widgets.headings.label')} category="heading" />
|
||||
<Widgets label={t('builder.rightSidebar.sections.typography.widgets.body.label')} category="body" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Typography;
|
||||
24
apps/client/components/dashboard/ResumeCard.module.scss
Normal file
24
apps/client/components/dashboard/ResumeCard.module.scss
Normal file
@ -0,0 +1,24 @@
|
||||
.resume {
|
||||
@apply flex flex-col gap-2;
|
||||
|
||||
.preview {
|
||||
aspect-ratio: 1 / 1.41;
|
||||
|
||||
@apply flex items-center justify-center shadow;
|
||||
@apply cursor-pointer rounded-sm bg-neutral-100 transition-opacity hover:opacity-80 dark:bg-neutral-800;
|
||||
}
|
||||
|
||||
footer {
|
||||
@apply flex items-center justify-between;
|
||||
|
||||
.meta {
|
||||
p:first-child {
|
||||
@apply text-sm font-semibold leading-relaxed;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
@apply text-xs leading-relaxed opacity-50;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
39
apps/client/components/dashboard/ResumeCard.tsx
Normal file
39
apps/client/components/dashboard/ResumeCard.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { SvgIconComponent } from '@mui/icons-material';
|
||||
import { ButtonBase } from '@mui/material';
|
||||
|
||||
import { useAppDispatch } from '@/store/hooks';
|
||||
import { ModalName, setModalState } from '@/store/modal/modalSlice';
|
||||
|
||||
import styles from './ResumeCard.module.scss';
|
||||
|
||||
type Props = {
|
||||
modal: ModalName;
|
||||
icon: SvgIconComponent;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
const ResumeCard: React.FC<Props> = ({ modal, icon: Icon, title, subtitle }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleClick = () => {
|
||||
dispatch(setModalState({ modal, state: { open: true } }));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={styles.resume}>
|
||||
<ButtonBase className={styles.preview} onClick={handleClick}>
|
||||
<Icon sx={{ fontSize: 64 }} />
|
||||
</ButtonBase>
|
||||
|
||||
<footer>
|
||||
<div className={styles.meta}>
|
||||
<p>{title}</p>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResumeCard;
|
||||
37
apps/client/components/dashboard/ResumePreview.module.scss
Normal file
37
apps/client/components/dashboard/ResumePreview.module.scss
Normal file
@ -0,0 +1,37 @@
|
||||
.resume {
|
||||
@apply flex flex-col gap-2;
|
||||
|
||||
.preview {
|
||||
aspect-ratio: 1 / 1.41;
|
||||
|
||||
@apply relative cursor-pointer rounded-sm shadow;
|
||||
@apply bg-neutral-100 transition-opacity hover:opacity-80 dark:bg-neutral-800;
|
||||
}
|
||||
|
||||
footer {
|
||||
@apply flex items-center justify-between overflow-hidden;
|
||||
|
||||
.meta {
|
||||
flex: 4;
|
||||
@apply flex flex-col overflow-hidden;
|
||||
|
||||
p {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
|
||||
&:first-child {
|
||||
@apply text-sm font-semibold leading-relaxed;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
@apply text-xs leading-relaxed opacity-50;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
flex: 1;
|
||||
|
||||
@apply h-full w-full cursor-pointer rounded text-lg;
|
||||
}
|
||||
}
|
||||
}
|
||||
190
apps/client/components/dashboard/ResumePreview.tsx
Normal file
190
apps/client/components/dashboard/ResumePreview.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
import {
|
||||
ContentCopy,
|
||||
DeleteOutline,
|
||||
DriveFileRenameOutline,
|
||||
Link as LinkIcon,
|
||||
MoreVert,
|
||||
OpenInNew,
|
||||
} from '@mui/icons-material';
|
||||
import { ButtonBase, ListItemIcon, ListItemText, Menu, MenuItem, Tooltip } from '@mui/material';
|
||||
import { Resume } from '@reactive-resume/schema';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useMutation } from 'react-query';
|
||||
|
||||
import { RESUMES_QUERY } from '@/constants/index';
|
||||
import { ServerError } from '@/services/axios';
|
||||
import queryClient from '@/services/react-query';
|
||||
import { deleteResume, DeleteResumeParams, duplicateResume, DuplicateResumeParams } from '@/services/resume';
|
||||
import { useAppDispatch } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
import { getRelativeTime } from '@/utils/date';
|
||||
import getResumeUrl from '@/utils/getResumeUrl';
|
||||
|
||||
import styles from './ResumePreview.module.scss';
|
||||
|
||||
type Props = {
|
||||
resume: Resume;
|
||||
};
|
||||
|
||||
const ResumePreview: React.FC<Props> = ({ resume }) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<Element | null>(null);
|
||||
|
||||
const { mutateAsync: duplicateMutation } = useMutation<Resume, ServerError, DuplicateResumeParams>(duplicateResume);
|
||||
|
||||
const { mutateAsync: deleteMutation } = useMutation<void, ServerError, DeleteResumeParams>(deleteResume);
|
||||
|
||||
const handleOpen = () => {
|
||||
handleClose();
|
||||
|
||||
router.push({
|
||||
pathname: '/[username]/[slug]/build',
|
||||
query: { username: resume.user.username, slug: resume.slug },
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenMenu = (event: React.MouseEvent<Element>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleRename = () => {
|
||||
handleClose();
|
||||
|
||||
dispatch(
|
||||
setModalState({
|
||||
modal: 'dashboard.rename-resume',
|
||||
state: {
|
||||
open: true,
|
||||
payload: {
|
||||
item: resume,
|
||||
onComplete: () => {
|
||||
queryClient.invalidateQueries(RESUMES_QUERY);
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleDuplicate = async () => {
|
||||
handleClose();
|
||||
|
||||
await duplicateMutation({ id: resume.id });
|
||||
|
||||
queryClient.invalidateQueries(RESUMES_QUERY);
|
||||
};
|
||||
|
||||
const handleShareLink = async () => {
|
||||
handleClose();
|
||||
|
||||
const url = getResumeUrl(resume, { withHost: true });
|
||||
await navigator.clipboard.writeText(url);
|
||||
|
||||
toast.success(t('common.toast.success.resume-link-copied'));
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
handleClose();
|
||||
|
||||
await deleteMutation({ id: resume.id });
|
||||
|
||||
queryClient.invalidateQueries(RESUMES_QUERY);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={styles.resume}>
|
||||
<Link
|
||||
passHref
|
||||
href={{
|
||||
pathname: '/[username]/[slug]/build',
|
||||
query: { username: resume.user.username, slug: resume.slug },
|
||||
}}
|
||||
>
|
||||
<ButtonBase className={styles.preview}>
|
||||
{resume.image ? (
|
||||
<Image src={resume.image} alt={resume.name} objectFit="cover" layout="fill" priority />
|
||||
) : null}
|
||||
</ButtonBase>
|
||||
</Link>
|
||||
|
||||
<footer>
|
||||
<div className={styles.meta}>
|
||||
<p>{resume.name}</p>
|
||||
<p>{t('dashboard.resume.timestamp', { timestamp: getRelativeTime(resume.updatedAt) })}</p>
|
||||
</div>
|
||||
|
||||
<ButtonBase className={styles.menu} onClick={handleOpenMenu}>
|
||||
<MoreVert />
|
||||
</ButtonBase>
|
||||
|
||||
<Menu anchorEl={anchorEl} onClose={handleClose} open={Boolean(anchorEl)}>
|
||||
<MenuItem onClick={handleOpen}>
|
||||
<ListItemIcon>
|
||||
<OpenInNew className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('dashboard.resume.menu.open')}</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={handleRename}>
|
||||
<ListItemIcon>
|
||||
<DriveFileRenameOutline className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('dashboard.resume.menu.rename')}</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={handleDuplicate}>
|
||||
<ListItemIcon>
|
||||
<ContentCopy className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('dashboard.resume.menu.duplicate')}</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
{resume.public ? (
|
||||
<MenuItem onClick={handleShareLink}>
|
||||
<ListItemIcon>
|
||||
<LinkIcon className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('dashboard.resume.menu.share-link')}</ListItemText>
|
||||
</MenuItem>
|
||||
) : (
|
||||
<Tooltip arrow placement="right" title={t('dashboard.resume.menu.tooltips.share-link')}>
|
||||
<div>
|
||||
<MenuItem>
|
||||
<ListItemIcon>
|
||||
<LinkIcon className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('dashboard.resume.menu.share-link')}</ListItemText>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip arrow placement="right" title={t('dashboard.resume.menu.tooltips.delete')}>
|
||||
<MenuItem onClick={handleDelete}>
|
||||
<ListItemIcon>
|
||||
<DeleteOutline className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('dashboard.resume.menu.delete')}</ListItemText>
|
||||
</MenuItem>
|
||||
</Tooltip>
|
||||
</Menu>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResumePreview;
|
||||
15
apps/client/components/shared/ArrayInput.module.scss
Normal file
15
apps/client/components/shared/ArrayInput.module.scss
Normal file
@ -0,0 +1,15 @@
|
||||
.header {
|
||||
@apply mb-4 flex items-center justify-between;
|
||||
|
||||
.label {
|
||||
@apply text-base font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
.inputGrid {
|
||||
@apply grid grid-cols-2 gap-4;
|
||||
|
||||
.delete {
|
||||
@apply opacity-25 hover:opacity-75;
|
||||
}
|
||||
}
|
||||
70
apps/client/components/shared/ArrayInput.tsx
Normal file
70
apps/client/components/shared/ArrayInput.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { Add, Delete } from '@mui/icons-material';
|
||||
import { IconButton, InputAdornment, TextField } from '@mui/material';
|
||||
import get from 'lodash/get';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FieldError } from 'react-hook-form';
|
||||
|
||||
import styles from './ArrayInput.module.scss';
|
||||
|
||||
type Props = {
|
||||
value: string[];
|
||||
label: string;
|
||||
onChange: (event: any) => void;
|
||||
errors: FieldError | FieldError[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const ArrayInput: React.FC<Props> = ({ value, label, onChange, errors, className }) => {
|
||||
const [items, setItems] = useState<string[]>(value);
|
||||
|
||||
const onAdd = () => setItems([...items, '']);
|
||||
|
||||
const onDelete = (index: number) => setItems(items.filter((_, idx) => idx !== index));
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>, index: number) => {
|
||||
const tempItems = [...items];
|
||||
tempItems[index] = event.target.value;
|
||||
setItems(tempItems);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onChange(items);
|
||||
}, [onChange, items]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<header className={styles.header}>
|
||||
<h3 className={styles.label}>
|
||||
{label} <small>({items.length})</small>
|
||||
</h3>
|
||||
|
||||
<IconButton onClick={onAdd}>
|
||||
<Add />
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
<div className={styles.inputGrid}>
|
||||
{items.map((value, index) => (
|
||||
<TextField
|
||||
key={index}
|
||||
value={value}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => handleChange(event, index)}
|
||||
error={!!get(errors, index, false)}
|
||||
helperText={get(errors, `${index}.message`, '')}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton edge="end" onClick={() => onDelete(index)} className={styles.delete}>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArrayInput;
|
||||
3
apps/client/components/shared/Avatar.module.scss
Normal file
3
apps/client/components/shared/Avatar.module.scss
Normal file
@ -0,0 +1,3 @@
|
||||
.avatar {
|
||||
@apply cursor-pointer rounded-full;
|
||||
}
|
||||
70
apps/client/components/shared/Avatar.tsx
Normal file
70
apps/client/components/shared/Avatar.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { Divider, IconButton, Menu, MenuItem } from '@mui/material';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { logout } from '@/store/auth/authSlice';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import getGravatarUrl from '@/utils/getGravatarUrl';
|
||||
|
||||
import styles from './Avatar.module.scss';
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
const Avatar: React.FC<Props> = ({ size = 64 }) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<Element | null>(null);
|
||||
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const email = user?.email || '';
|
||||
|
||||
const handleOpen = (event: React.MouseEvent<Element>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
dispatch(logout());
|
||||
handleClose();
|
||||
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton onClick={handleOpen}>
|
||||
<Image
|
||||
width={size}
|
||||
height={size}
|
||||
alt={user?.name}
|
||||
className={styles.avatar}
|
||||
src={getGravatarUrl(email, size)}
|
||||
/>
|
||||
</IconButton>
|
||||
|
||||
<Menu anchorEl={anchorEl} onClose={handleClose} open={Boolean(anchorEl)}>
|
||||
<MenuItem>
|
||||
<div>
|
||||
<span className="text-xs opacity-50">{t('common.avatar.menu.greeting')}</span>
|
||||
<p>{user?.name}</p>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={handleLogout}>{t('common.avatar.menu.logout')}</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Avatar;
|
||||
32
apps/client/components/shared/BaseModal.module.scss
Normal file
32
apps/client/components/shared/BaseModal.module.scss
Normal file
@ -0,0 +1,32 @@
|
||||
.content {
|
||||
@apply rounded p-6 text-sm shadow lg:w-1/2 xl:w-2/5;
|
||||
@apply absolute inset-4 sm:inset-x-4 sm:inset-y-auto lg:inset-auto;
|
||||
@apply overflow-scroll bg-neutral-50 dark:bg-neutral-900 lg:overflow-auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
@apply flex items-center justify-between;
|
||||
@apply w-full border-b pb-5 dark:border-white/10;
|
||||
|
||||
> div {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply flex items-center justify-center;
|
||||
@apply rotate-0 transition-transform hover:rotate-90;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-base font-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
@apply grid gap-4 pt-4 pb-6;
|
||||
}
|
||||
|
||||
.footer {
|
||||
@apply flex items-center justify-end gap-x-4;
|
||||
@apply w-full border-t pt-5 dark:border-white/10;
|
||||
}
|
||||
56
apps/client/components/shared/BaseModal.tsx
Normal file
56
apps/client/components/shared/BaseModal.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { Close as CloseIcon } from '@mui/icons-material';
|
||||
import { Fade, IconButton, Modal } from '@mui/material';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import styles from './BaseModal.module.scss';
|
||||
|
||||
type Props = {
|
||||
icon?: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
heading: string;
|
||||
handleClose: () => void;
|
||||
footerChildren?: React.ReactNode;
|
||||
};
|
||||
|
||||
const BaseModal: React.FC<Props> = ({ icon, isOpen, heading, children, handleClose, footerChildren }) => {
|
||||
const router = useRouter();
|
||||
const { pathname, query } = router;
|
||||
|
||||
const onClose = () => {
|
||||
router.push({ pathname, query }, '');
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
closeAfterTransition
|
||||
aria-labelledby={heading}
|
||||
classes={{ root: 'flex items-center justify-center' }}
|
||||
>
|
||||
<Fade in={isOpen}>
|
||||
<div className={styles.content}>
|
||||
<header className={styles.header}>
|
||||
<div>
|
||||
{icon}
|
||||
{icon && <span className="mx-1 opacity-25">/</span>}
|
||||
<h1>{heading}</h1>
|
||||
</div>
|
||||
|
||||
<IconButton size="small" onClick={onClose}>
|
||||
<CloseIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
<div className={styles.body}>{children}</div>
|
||||
|
||||
{footerChildren ? <footer className={styles.footer}>{footerChildren}</footer> : null}
|
||||
</div>
|
||||
</Fade>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaseModal;
|
||||
20
apps/client/components/shared/ColorAvatar.tsx
Normal file
20
apps/client/components/shared/ColorAvatar.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { Avatar, IconButton } from '@mui/material';
|
||||
import isFunction from 'lodash/isFunction';
|
||||
|
||||
type Props = {
|
||||
color: string;
|
||||
size?: number;
|
||||
onClick?: (color: string) => void;
|
||||
};
|
||||
|
||||
const ColorAvatar: React.FC<Props> = ({ color, size = 20, onClick }) => {
|
||||
const handleClick = () => isFunction(onClick) && onClick(color);
|
||||
|
||||
return (
|
||||
<IconButton onClick={handleClick}>
|
||||
<Avatar sx={{ bgcolor: color, width: size, height: size }}> </Avatar>
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorAvatar;
|
||||
68
apps/client/components/shared/ColorPicker.tsx
Normal file
68
apps/client/components/shared/ColorPicker.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { Popover, TextField } from '@mui/material';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
|
||||
import { hexColorPattern } from '@/config/colors';
|
||||
|
||||
import ColorAvatar from './ColorAvatar';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
color: string;
|
||||
className?: string;
|
||||
onChange: (color: string) => void;
|
||||
};
|
||||
|
||||
const ColorPicker: React.FC<Props> = ({ label, color, onChange, className }) => {
|
||||
const isValid = useMemo(() => hexColorPattern.test(color), [color]);
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLInputElement | null>(null);
|
||||
const isOpen = Boolean(anchorEl);
|
||||
|
||||
const handleOpen = (event: React.MouseEvent<HTMLInputElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (hexColorPattern.test(event.target.value)) {
|
||||
onChange(event.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
label={label}
|
||||
value={color}
|
||||
error={!isValid}
|
||||
onClick={handleOpen}
|
||||
onChange={handleChange}
|
||||
className={className}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<div className="mr-2">
|
||||
<ColorAvatar color={color} />
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
>
|
||||
<HexColorPicker color={color} onChange={onChange} className="overflow-hidden" />
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorPicker;
|
||||
26
apps/client/components/shared/Footer.tsx
Normal file
26
apps/client/components/shared/Footer.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import clsx from 'clsx';
|
||||
import { Trans, useTranslation } from 'next-i18next';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Footer: React.FC<Props> = ({ className }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<footer className={clsx('text-xs', className)}>
|
||||
<p>{t('common.footer.license')}</p>
|
||||
<p>
|
||||
<Trans t={t} i18nKey="common.footer.credit">
|
||||
A passion project by
|
||||
<a href="https://www.amruthpillai.com/" target="_blank" rel="noreferrer">
|
||||
Amruth Pillai
|
||||
</a>
|
||||
</Trans>
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
17
apps/client/components/shared/Heading.module.scss
Normal file
17
apps/client/components/shared/Heading.module.scss
Normal file
@ -0,0 +1,17 @@
|
||||
.container {
|
||||
@apply flex items-center justify-between;
|
||||
|
||||
h1 {
|
||||
@apply text-2xl;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@apply flex gap-2 opacity-75 transition-opacity lg:opacity-50 dark:lg:opacity-25;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.actions {
|
||||
@apply opacity-75;
|
||||
}
|
||||
}
|
||||
}
|
||||
100
apps/client/components/shared/Heading.tsx
Normal file
100
apps/client/components/shared/Heading.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { Check, Delete, DriveFileRenameOutline, Grade, Visibility, VisibilityOff } from '@mui/icons-material';
|
||||
import { IconButton, TextField, Tooltip } from '@mui/material';
|
||||
import clsx from 'clsx';
|
||||
import get from 'lodash/get';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import sections from '@/config/sections';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { deleteSection, setResumeState } from '@/store/resume/resumeSlice';
|
||||
|
||||
import styles from './Heading.module.scss';
|
||||
|
||||
type Props = {
|
||||
path?: string;
|
||||
name?: string;
|
||||
isEditable?: boolean;
|
||||
isHideable?: boolean;
|
||||
isDeletable?: boolean;
|
||||
action?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Heading: React.FC<Props> = ({
|
||||
path,
|
||||
name,
|
||||
isEditable = false,
|
||||
isHideable = false,
|
||||
isDeletable = false,
|
||||
action,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const heading = useAppSelector((state) => get(state.resume, `${path}.name`, name));
|
||||
const visibility = useAppSelector((state) => get(state.resume, `${path}.visible`, true));
|
||||
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
|
||||
const id = useMemo(() => path && path.split('.').at(-1), [path]);
|
||||
|
||||
const icon = sections.find((x) => x.id === id)?.icon || <Grade />;
|
||||
|
||||
const toggleVisibility = () => {
|
||||
dispatch(setResumeState({ path: `${path}.visible`, value: !visibility }));
|
||||
};
|
||||
|
||||
const toggleEditMode = () => setEditMode(!editMode);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
dispatch(setResumeState({ path: `${path}.name`, value: event.target.value }));
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
dispatch(deleteSection({ path }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<div className="opacity-50">{icon}</div>
|
||||
{editMode ? (
|
||||
<TextField size="small" value={heading} className="w-3/4" onChange={handleChange} />
|
||||
) : (
|
||||
<h1>{heading}</h1>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx(styles.actions, {
|
||||
'!opacity-75': editMode,
|
||||
})}
|
||||
>
|
||||
{isEditable && (
|
||||
<Tooltip title={t('builder.common.tooltip.rename-section')}>
|
||||
<IconButton onClick={toggleEditMode}>{editMode ? <Check /> : <DriveFileRenameOutline />}</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isHideable && (
|
||||
<Tooltip title={t('builder.common.tooltip.toggle-visibility')}>
|
||||
<IconButton onClick={toggleVisibility}>{visibility ? <Visibility /> : <VisibilityOff />}</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isDeletable && (
|
||||
<Tooltip title={t('builder.common.tooltip.delete-section')}>
|
||||
<IconButton onClick={handleDelete}>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{action}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Heading;
|
||||
7
apps/client/components/shared/List.module.scss
Normal file
7
apps/client/components/shared/List.module.scss
Normal file
@ -0,0 +1,7 @@
|
||||
.container {
|
||||
@apply rounded-lg border dark:border-neutral-50/10;
|
||||
|
||||
.empty {
|
||||
@apply py-8 text-center;
|
||||
}
|
||||
}
|
||||
95
apps/client/components/shared/List.tsx
Normal file
95
apps/client/components/shared/List.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { ListItem as ListItemType } from '@reactive-resume/schema';
|
||||
import clsx from 'clsx';
|
||||
import get from 'lodash/get';
|
||||
import isArray from 'lodash/isArray';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import isFunction from 'lodash/isFunction';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useCallback } from 'react';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { deleteItem, setResumeState } from '@/store/resume/resumeSlice';
|
||||
|
||||
import styles from './List.module.scss';
|
||||
import ListItem from './ListItem';
|
||||
|
||||
type Props = {
|
||||
path: string;
|
||||
titleKey?: string;
|
||||
subtitleKey?: string;
|
||||
onEdit?: (item: ListItemType) => void;
|
||||
onDuplicate?: (item: ListItemType) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const List: React.FC<Props> = ({
|
||||
path,
|
||||
titleKey = 'title',
|
||||
subtitleKey = 'subtitle',
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const list: Array<ListItemType> = useAppSelector((state) => get(state.resume, path, []));
|
||||
|
||||
const handleEdit = (item: ListItemType) => {
|
||||
isFunction(onEdit) && onEdit(item);
|
||||
};
|
||||
|
||||
const handleDuplicate = (item: ListItemType) => {
|
||||
isFunction(onDuplicate) && onDuplicate(item);
|
||||
};
|
||||
|
||||
const handleDelete = (item: ListItemType) => {
|
||||
dispatch(deleteItem({ path, value: item }));
|
||||
};
|
||||
|
||||
const handleMove = useCallback(
|
||||
(dragIndex: number, hoverIndex: number) => {
|
||||
const dragItem = list[dragIndex];
|
||||
const newList = [...list];
|
||||
|
||||
newList.splice(dragIndex, 1);
|
||||
newList.splice(hoverIndex, 0, dragItem);
|
||||
|
||||
dispatch(setResumeState({ path, value: newList }));
|
||||
},
|
||||
[list, dispatch, path]
|
||||
);
|
||||
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<div className={clsx(styles.container, className)}>
|
||||
{isEmpty(list) && <div className={styles.empty}>{t('builder.common.list.empty-text')}</div>}
|
||||
|
||||
{list.map((item, index) => {
|
||||
const title = get(item, titleKey, '');
|
||||
const subtitleObj = get(item, subtitleKey);
|
||||
const subtitle: string = isArray(subtitleObj) ? subtitleObj.join(', ') : subtitleObj;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
onMove={handleMove}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onDuplicate={handleDuplicate}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DndProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default List;
|
||||
18
apps/client/components/shared/ListItem.module.scss
Normal file
18
apps/client/components/shared/ListItem.module.scss
Normal file
@ -0,0 +1,18 @@
|
||||
.item {
|
||||
@apply flex items-center justify-between;
|
||||
@apply py-5 pl-5 pr-2;
|
||||
@apply border-b border-neutral-900/10 last:border-0 dark:border-neutral-50/10;
|
||||
@apply cursor-move transition-opacity;
|
||||
|
||||
.meta {
|
||||
@apply grid gap-1;
|
||||
|
||||
.title {
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
@apply text-xs opacity-50;
|
||||
}
|
||||
}
|
||||
}
|
||||
157
apps/client/components/shared/ListItem.tsx
Normal file
157
apps/client/components/shared/ListItem.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
import { DeleteOutline, DriveFileRenameOutline, FileCopy, MoreVert } from '@mui/icons-material';
|
||||
import { Divider, IconButton, ListItemIcon, ListItemText, Menu, MenuItem, Tooltip } from '@mui/material';
|
||||
import { ListItem as ListItemType } from '@reactive-resume/schema';
|
||||
import clsx from 'clsx';
|
||||
import isFunction from 'lodash/isFunction';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { DropTargetMonitor, useDrag, useDrop, XYCoord } from 'react-dnd';
|
||||
|
||||
import styles from './ListItem.module.scss';
|
||||
|
||||
interface DragItem {
|
||||
id: string;
|
||||
type: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
item: ListItemType;
|
||||
index: number;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onMove?: (dragIndex: number, hoverIndex: number) => void;
|
||||
onEdit?: (item: ListItemType) => void;
|
||||
onDelete?: (item: ListItemType) => void;
|
||||
onDuplicate?: (item: ListItemType) => void;
|
||||
};
|
||||
|
||||
const ListItem: React.FC<Props> = ({ item, index, title, subtitle, onMove, onEdit, onDelete, onDuplicate }) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [anchorEl, setAnchorEl] = useState<Element | null>(null);
|
||||
|
||||
const [{ handlerId }, drop] = useDrop({
|
||||
accept: 'ListItem',
|
||||
collect(monitor) {
|
||||
return { handlerId: monitor.getHandlerId() };
|
||||
},
|
||||
hover(item: DragItem, monitor: DropTargetMonitor) {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
const dragIndex = item.index;
|
||||
const hoverIndex = index;
|
||||
|
||||
if (dragIndex === hoverIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
|
||||
|
||||
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
|
||||
isFunction(onMove) && onMove(dragIndex, hoverIndex);
|
||||
|
||||
item.index = hoverIndex;
|
||||
},
|
||||
});
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: 'ListItem',
|
||||
item: () => {
|
||||
return { id: item.id, index };
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
});
|
||||
|
||||
const handleOpen = (event: React.MouseEvent<Element>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleEdit = (item: ListItemType) => {
|
||||
isFunction(onEdit) && onEdit(item);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleDelete = (item: ListItemType) => {
|
||||
isFunction(onDelete) && onDelete(item);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleDuplicate = (item: ListItemType) => {
|
||||
isFunction(onDuplicate) && onDuplicate(item);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
drag(drop(ref));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-handler-id={handlerId}
|
||||
className={clsx(styles.item, {
|
||||
['opacity-25']: isDragging,
|
||||
})}
|
||||
>
|
||||
<div className={styles.meta}>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
<h2 className={styles.subtitle}>{subtitle}</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<IconButton onClick={handleOpen}>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
|
||||
<Menu anchorEl={anchorEl} onClose={handleClose} open={Boolean(anchorEl)}>
|
||||
<MenuItem onClick={() => handleEdit(item)}>
|
||||
<ListItemIcon>
|
||||
<DriveFileRenameOutline className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Edit</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={() => handleDuplicate(item)}>
|
||||
<ListItemIcon>
|
||||
<FileCopy className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Duplicate</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Tooltip
|
||||
arrow
|
||||
placement="right"
|
||||
title="Are you sure you want to delete this item? This is an irreversible action."
|
||||
>
|
||||
<div>
|
||||
<MenuItem onClick={() => handleDelete(item)}>
|
||||
<ListItemIcon>
|
||||
<DeleteOutline className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Delete</ListItemText>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListItem;
|
||||
32
apps/client/components/shared/Loading.module.scss
Normal file
32
apps/client/components/shared/Loading.module.scss
Normal file
@ -0,0 +1,32 @@
|
||||
.loading {
|
||||
animation: progress 2s linear infinite;
|
||||
|
||||
@apply fixed top-0 z-50;
|
||||
@apply bg-primary-500 shadow-primary-500/50 h-0.5 w-screen shadow;
|
||||
}
|
||||
|
||||
@keyframes progress {
|
||||
0% {
|
||||
left: 0%;
|
||||
right: 100%;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
10% {
|
||||
left: 0%;
|
||||
right: 75%;
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
90% {
|
||||
left: 75%;
|
||||
right: 0%;
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
100% {
|
||||
left: 100%;
|
||||
right: 0%;
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
18
apps/client/components/shared/Loading.tsx
Normal file
18
apps/client/components/shared/Loading.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { useIsFetching, useIsMutating } from 'react-query';
|
||||
|
||||
import styles from './Loading.module.scss';
|
||||
|
||||
const Loading: React.FC = () => {
|
||||
const { isReady } = useRouter();
|
||||
const isFetching = useIsFetching();
|
||||
const isMutating = useIsMutating();
|
||||
|
||||
if (!isFetching && !isMutating && isReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={styles.loading} />;
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
11
apps/client/components/shared/Logo.tsx
Normal file
11
apps/client/components/shared/Logo.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
type Props = {
|
||||
size?: 256 | 64 | 48 | 40 | 32;
|
||||
};
|
||||
|
||||
const Logo: React.FC<Props> = ({ size = 64 }) => {
|
||||
return <Image alt="Reactive Resume" src="/images/logo.svg" className="rounded" width={size} height={size} />;
|
||||
};
|
||||
|
||||
export default Logo;
|
||||
18
apps/client/components/shared/Markdown.tsx
Normal file
18
apps/client/components/shared/Markdown.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import clsx from 'clsx';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
type Props = {
|
||||
children?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Markdown: React.FC<Props> = ({ className, children }) => {
|
||||
return (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} className={clsx('markdown', className)}>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default Markdown;
|
||||
20
apps/client/components/shared/MarkdownSupported.tsx
Normal file
20
apps/client/components/shared/MarkdownSupported.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { Link } from '@mui/material';
|
||||
import { Trans, useTranslation } from 'next-i18next';
|
||||
|
||||
const MarkdownSupported: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span className="inline-block pt-1 opacity-75">
|
||||
<Trans t={t} i18nKey="common.markdown.help-text">
|
||||
This section supports
|
||||
<Link href="https://www.markdownguide.org/cheat-sheet/" target="_blank" rel="noreferrer">
|
||||
markdown
|
||||
</Link>
|
||||
formatting.
|
||||
</Trans>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownSupported;
|
||||
5
apps/client/components/shared/NoSSR.tsx
Normal file
5
apps/client/components/shared/NoSSR.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const NoSSR: React.FC = ({ children }) => <>{children}</>;
|
||||
|
||||
export default dynamic(() => Promise.resolve(NoSSR), { ssr: false });
|
||||
51
apps/client/components/shared/ResumeInput.tsx
Normal file
51
apps/client/components/shared/ResumeInput.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { TextField } from '@mui/material';
|
||||
import get from 'lodash/get';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setResumeState } from '@/store/resume/resumeSlice';
|
||||
|
||||
import MarkdownSupported from './MarkdownSupported';
|
||||
|
||||
interface Props {
|
||||
type?: 'text' | 'textarea';
|
||||
label: string;
|
||||
path: string;
|
||||
className?: string;
|
||||
markdownSupported?: boolean;
|
||||
}
|
||||
|
||||
const ResumeInput: React.FC<Props> = ({ type = 'text', label, path, className, markdownSupported = false }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const stateValue = useAppSelector((state) => get(state.resume, path, ''));
|
||||
|
||||
useEffect(() => {
|
||||
setValue(stateValue);
|
||||
}, [stateValue]);
|
||||
|
||||
const [value, setValue] = useState<string>(stateValue);
|
||||
|
||||
const onChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setValue(event.target.value);
|
||||
dispatch(setResumeState({ path, value: event.target.value }));
|
||||
};
|
||||
|
||||
if (type === 'textarea') {
|
||||
return (
|
||||
<TextField
|
||||
rows={5}
|
||||
multiline
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
helperText={markdownSupported && <MarkdownSupported />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <TextField type={type} label={label} value={value} onChange={onChange} className={className} />;
|
||||
};
|
||||
|
||||
export default ResumeInput;
|
||||
50
apps/client/components/shared/ThemeSwitch.tsx
Normal file
50
apps/client/components/shared/ThemeSwitch.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { styled, Switch } from '@mui/material';
|
||||
|
||||
const ThemeSwitch = styled(Switch)(({ theme }) => ({
|
||||
width: 62,
|
||||
height: 34,
|
||||
padding: 7,
|
||||
'& .MuiSwitch-switchBase': {
|
||||
margin: 1,
|
||||
padding: 0,
|
||||
transform: 'translateX(6px)',
|
||||
'&.Mui-checked': {
|
||||
color: '#fff',
|
||||
transform: 'translateX(22px)',
|
||||
'& .MuiSwitch-thumb:before': {
|
||||
backgroundImage: `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="20" width="20" viewBox="0 0 20 20"><path fill="${encodeURIComponent(
|
||||
'#fff'
|
||||
)}" d="M4.2 2.5l-.7 1.8-1.8.7 1.8.7.7 1.8.6-1.8L6.7 5l-1.9-.7-.6-1.8zm15 8.3a6.7 6.7 0 11-6.6-6.6 5.8 5.8 0 006.6 6.6z"/></svg>')`,
|
||||
},
|
||||
'& + .MuiSwitch-track': {
|
||||
opacity: 1,
|
||||
backgroundColor: theme.palette.mode === 'dark' ? '#8796A5' : '#aab4be',
|
||||
},
|
||||
},
|
||||
},
|
||||
'& .MuiSwitch-thumb': {
|
||||
backgroundColor: theme.palette.mode === 'dark' ? '#003892' : '#001e3c',
|
||||
width: 32,
|
||||
height: 32,
|
||||
'&:before': {
|
||||
content: "''",
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
left: 0,
|
||||
top: 0,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
backgroundImage: `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="20" width="20" viewBox="0 0 20 20"><path fill="${encodeURIComponent(
|
||||
'#fff'
|
||||
)}" d="M9.305 1.667V3.75h1.389V1.667h-1.39zm-4.707 1.95l-.982.982L5.09 6.072l.982-.982-1.473-1.473zm10.802 0L13.927 5.09l.982.982 1.473-1.473-.982-.982zM10 5.139a4.872 4.872 0 00-4.862 4.86A4.872 4.872 0 0010 14.862 4.872 4.872 0 0014.86 10 4.872 4.872 0 0010 5.139zm0 1.389A3.462 3.462 0 0113.471 10a3.462 3.462 0 01-3.473 3.472A3.462 3.462 0 016.527 10 3.462 3.462 0 0110 6.528zM1.665 9.305v1.39h2.083v-1.39H1.666zm14.583 0v1.39h2.084v-1.39h-2.084zM5.09 13.928L3.616 15.4l.982.982 1.473-1.473-.982-.982zm9.82 0l-.982.982 1.473 1.473.982-.982-1.473-1.473zM9.305 16.25v2.083h1.389V16.25h-1.39z"/></svg>')`,
|
||||
},
|
||||
},
|
||||
'& .MuiSwitch-track': {
|
||||
opacity: 1,
|
||||
backgroundColor: theme.palette.mode === 'dark' ? '#8796A5' : '#aab4be',
|
||||
borderRadius: 20 / 2,
|
||||
},
|
||||
}));
|
||||
|
||||
export default ThemeSwitch;
|
||||
20
apps/client/config/colors.ts
Normal file
20
apps/client/config/colors.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export const hexColorPattern = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i;
|
||||
|
||||
export const colorOptions: string[] = [
|
||||
'#f44336',
|
||||
'#e91e63',
|
||||
'#9c27b0',
|
||||
'#673ab7',
|
||||
'#3f51b5',
|
||||
'#4896d5',
|
||||
'#03a9f4',
|
||||
'#00bcd4',
|
||||
'#009688',
|
||||
'#4caf50',
|
||||
'#8bc34a',
|
||||
'#cddc39',
|
||||
'#ffeb3b',
|
||||
'#ffc107',
|
||||
'#222222',
|
||||
'#dddddd',
|
||||
];
|
||||
20
apps/client/config/languages.ts
Normal file
20
apps/client/config/languages.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export type Language = {
|
||||
code: string;
|
||||
name: string;
|
||||
localName?: string;
|
||||
};
|
||||
|
||||
export const languages: Language[] = [
|
||||
{
|
||||
code: 'en',
|
||||
name: 'English',
|
||||
},
|
||||
];
|
||||
|
||||
export const languageMap = languages.reduce(
|
||||
(acc, lang) => ({
|
||||
...acc,
|
||||
[lang.code]: lang,
|
||||
}),
|
||||
{}
|
||||
);
|
||||
31
apps/client/config/screenshots.ts
Normal file
31
apps/client/config/screenshots.ts
Normal file
@ -0,0 +1,31 @@
|
||||
type Screenshot = {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
export const screenshots: Screenshot[] = [
|
||||
{
|
||||
src: '/images/screenshots/dashboard.png',
|
||||
alt: 'Create multiple resumes under one account',
|
||||
},
|
||||
{
|
||||
src: '/images/screenshots/import-external.png',
|
||||
alt: 'Import your data from LinkedIn, JSON Resume or Reactive Resume',
|
||||
},
|
||||
{
|
||||
src: '/images/screenshots/builder.png',
|
||||
alt: 'Variety of features to personalize your resume to your liking',
|
||||
},
|
||||
{
|
||||
src: '/images/screenshots/add-section.png',
|
||||
alt: 'Multiple pre-built sections which can be renamed, or just create your own section',
|
||||
},
|
||||
{
|
||||
src: '/images/screenshots/page-layout.png',
|
||||
alt: 'Create multiple pages, manage section layouts as easy as dragging them around',
|
||||
},
|
||||
{
|
||||
src: '/images/screenshots/preview.png',
|
||||
alt: 'Get a unique link to your resume which can be shared with anyone for the latest information',
|
||||
},
|
||||
];
|
||||
181
apps/client/config/sections.tsx
Normal file
181
apps/client/config/sections.tsx
Normal file
@ -0,0 +1,181 @@
|
||||
import {
|
||||
Architecture,
|
||||
CardGiftcard,
|
||||
Category,
|
||||
Coffee,
|
||||
Download,
|
||||
EmojiEvents,
|
||||
FontDownload,
|
||||
Groups,
|
||||
Language,
|
||||
Link as LinkIcon,
|
||||
Map,
|
||||
Margin,
|
||||
MenuBook,
|
||||
Palette,
|
||||
Person,
|
||||
Sailing,
|
||||
School,
|
||||
Settings as SettingsIcon,
|
||||
Share,
|
||||
Style,
|
||||
Twitter,
|
||||
VolunteerActivism,
|
||||
Work,
|
||||
} from '@mui/icons-material';
|
||||
import { Section as SectionRecord } from '@reactive-resume/schema';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
|
||||
import Basics from '@/components/build/LeftSidebar/sections/Basics';
|
||||
import Location from '@/components/build/LeftSidebar/sections/Location';
|
||||
import Profiles from '@/components/build/LeftSidebar/sections/Profiles';
|
||||
import Section from '@/components/build/LeftSidebar/sections/Section';
|
||||
import CustomCSS from '@/components/build/RightSidebar/sections/CustomCSS';
|
||||
import Export from '@/components/build/RightSidebar/sections/Export';
|
||||
import Layout from '@/components/build/RightSidebar/sections/Layout';
|
||||
import Links from '@/components/build/RightSidebar/sections/Links';
|
||||
import Settings from '@/components/build/RightSidebar/sections/Settings';
|
||||
import Sharing from '@/components/build/RightSidebar/sections/Sharing';
|
||||
import Templates from '@/components/build/RightSidebar/sections/Templates';
|
||||
import Theme from '@/components/build/RightSidebar/sections/Theme';
|
||||
import Typography from '@/components/build/RightSidebar/sections/Typography';
|
||||
import { SidebarSection } from '@/types/app';
|
||||
|
||||
export const left: SidebarSection[] = [
|
||||
{
|
||||
id: 'basics',
|
||||
icon: <Person />,
|
||||
component: <Basics />,
|
||||
},
|
||||
{
|
||||
id: 'location',
|
||||
icon: <Map />,
|
||||
component: <Location />,
|
||||
},
|
||||
{
|
||||
id: 'profiles',
|
||||
icon: <Twitter />,
|
||||
component: <Profiles />,
|
||||
},
|
||||
{
|
||||
id: 'work',
|
||||
icon: <Work />,
|
||||
component: <Section path="sections.work" titleKey="name" subtitleKey="position" isEditable isHideable />,
|
||||
},
|
||||
{
|
||||
id: 'education',
|
||||
icon: <School />,
|
||||
component: <Section path="sections.education" titleKey="institution" subtitleKey="area" isEditable isHideable />,
|
||||
},
|
||||
{
|
||||
id: 'awards',
|
||||
icon: <EmojiEvents />,
|
||||
component: <Section path="sections.awards" titleKey="title" subtitleKey="awarder" isEditable isHideable />,
|
||||
},
|
||||
{
|
||||
id: 'certifications',
|
||||
icon: <CardGiftcard />,
|
||||
component: <Section path="sections.certifications" titleKey="name" subtitleKey="issuer" isEditable isHideable />,
|
||||
},
|
||||
{
|
||||
id: 'publications',
|
||||
icon: <MenuBook />,
|
||||
component: <Section path="sections.publications" titleKey="name" subtitleKey="publisher" isEditable isHideable />,
|
||||
},
|
||||
{
|
||||
id: 'skills',
|
||||
icon: <Architecture />,
|
||||
component: <Section path="sections.skills" titleKey="name" subtitleKey="level" isEditable isHideable />,
|
||||
},
|
||||
{
|
||||
id: 'languages',
|
||||
icon: <Language />,
|
||||
component: <Section path="sections.languages" titleKey="name" subtitleKey="level" isEditable isHideable />,
|
||||
},
|
||||
{
|
||||
id: 'interests',
|
||||
icon: <Sailing />,
|
||||
component: <Section path="sections.interests" titleKey="name" subtitleKey="keywords" isEditable isHideable />,
|
||||
},
|
||||
{
|
||||
id: 'volunteer',
|
||||
icon: <VolunteerActivism />,
|
||||
component: (
|
||||
<Section path="sections.volunteer" titleKey="organization" subtitleKey="position" isEditable isHideable />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'projects',
|
||||
icon: <Coffee />,
|
||||
component: <Section path="sections.projects" titleKey="name" subtitleKey="description" isEditable isHideable />,
|
||||
},
|
||||
{
|
||||
id: 'references',
|
||||
icon: <Groups />,
|
||||
component: <Section path="sections.references" titleKey="name" subtitleKey="relationship" isEditable isHideable />,
|
||||
},
|
||||
];
|
||||
|
||||
export const right: SidebarSection[] = [
|
||||
{
|
||||
id: 'templates',
|
||||
icon: <Category />,
|
||||
component: <Templates />,
|
||||
},
|
||||
{
|
||||
id: 'layout',
|
||||
icon: <Margin />,
|
||||
component: <Layout />,
|
||||
},
|
||||
{
|
||||
id: 'typography',
|
||||
icon: <FontDownload />,
|
||||
component: <Typography />,
|
||||
},
|
||||
{
|
||||
id: 'theme',
|
||||
icon: <Palette />,
|
||||
component: <Theme />,
|
||||
},
|
||||
{
|
||||
id: 'css',
|
||||
icon: <Style />,
|
||||
component: <CustomCSS />,
|
||||
},
|
||||
{
|
||||
id: 'sharing',
|
||||
icon: <Share />,
|
||||
component: <Sharing />,
|
||||
},
|
||||
{
|
||||
id: 'export',
|
||||
icon: <Download />,
|
||||
component: <Export />,
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
icon: <SettingsIcon />,
|
||||
component: <Settings />,
|
||||
},
|
||||
{
|
||||
id: 'links',
|
||||
icon: <LinkIcon />,
|
||||
component: <Links />,
|
||||
},
|
||||
];
|
||||
|
||||
export const getCustomSections = (sections: Record<string, SectionRecord>): Array<SectionRecord> => {
|
||||
if (isEmpty(sections)) return [];
|
||||
|
||||
return Object.entries(sections).reduce((acc, [id, section]) => {
|
||||
if (section.type === 'custom') {
|
||||
return [...acc, { ...section, id }];
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, [] as Array<SectionRecord>);
|
||||
};
|
||||
|
||||
const sections = [...left, ...right];
|
||||
|
||||
export default sections;
|
||||
72
apps/client/config/theme.ts
Normal file
72
apps/client/config/theme.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { createTheme } from '@mui/material';
|
||||
import { grey } from '@mui/material/colors';
|
||||
import { teal } from 'tailwindcss/colors';
|
||||
|
||||
const theme = createTheme({
|
||||
typography: {
|
||||
fontSize: 12,
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
defaultProps: {
|
||||
size: 'small',
|
||||
variant: 'contained',
|
||||
disableElevation: true,
|
||||
},
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none',
|
||||
padding: '6px 20px',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTextField: {
|
||||
defaultProps: {
|
||||
variant: 'outlined',
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
zIndex: 30,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTooltip: {
|
||||
styleOverrides: {
|
||||
tooltip: {
|
||||
fontSize: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
zIndex: 40,
|
||||
},
|
||||
paper: {
|
||||
border: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const lightTheme = createTheme({
|
||||
...theme,
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: { main: grey[800] },
|
||||
secondary: { main: teal[600] },
|
||||
},
|
||||
});
|
||||
|
||||
export const darkTheme = createTheme({
|
||||
...theme,
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: { main: grey[100] },
|
||||
secondary: { main: teal[400] },
|
||||
},
|
||||
});
|
||||
11
apps/client/constants/index.ts
Normal file
11
apps/client/constants/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
// React Queries
|
||||
export const FONTS_QUERY = 'fonts';
|
||||
export const RESUMES_QUERY = 'resumes';
|
||||
|
||||
// Date Formats
|
||||
export const FILENAME_TIMESTAMP = 'DDMMYYYYHHmmss';
|
||||
|
||||
// Links
|
||||
export const DONATION_URL = 'https://www.buymeacoffee.com/AmruthPillai';
|
||||
export const GITHUB_URL = 'https://github.com/AmruthPillai/Reactive-Resume';
|
||||
export const GITHUB_ISSUES_URL = 'https://github.com/AmruthPillai/Reactive-Resume/issues/new/choose';
|
||||
6
apps/client/index.d.ts
vendored
Normal file
6
apps/client/index.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
declare module '*.svg' {
|
||||
const content: any;
|
||||
export const ReactComponent: any;
|
||||
export default content;
|
||||
}
|
||||
10
apps/client/jest.config.js
Normal file
10
apps/client/jest.config.js
Normal file
@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
displayName: 'client',
|
||||
preset: '../../jest.preset.js',
|
||||
transform: {
|
||||
'^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nrwl/react/plugins/jest',
|
||||
'^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nrwl/next/babel'] }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||
coverageDirectory: '../../coverage/apps/client',
|
||||
};
|
||||
91
apps/client/modals/auth/ForgotPasswordModal.tsx
Normal file
91
apps/client/modals/auth/ForgotPasswordModal.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { joiResolver } from '@hookform/resolvers/joi';
|
||||
import { Password } from '@mui/icons-material';
|
||||
import { Button, TextField } from '@mui/material';
|
||||
import Joi from 'joi';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useMutation } from 'react-query';
|
||||
|
||||
import BaseModal from '@/components/shared/BaseModal';
|
||||
import { forgotPassword, ForgotPasswordParams } from '@/services/auth';
|
||||
import { ServerError } from '@/services/axios';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
|
||||
type FormData = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
const defaultState: FormData = {
|
||||
email: '',
|
||||
};
|
||||
|
||||
const schema = Joi.object({
|
||||
email: Joi.string()
|
||||
.email({ tlds: { allow: false } })
|
||||
.required(),
|
||||
});
|
||||
|
||||
const ForgotPasswordModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { open: isOpen } = useAppSelector((state) => state.modal['auth.forgot']);
|
||||
|
||||
const { mutate, isLoading } = useMutation<void, ServerError, ForgotPasswordParams>(forgotPassword);
|
||||
|
||||
const { reset, control, handleSubmit } = useForm<FormData>({
|
||||
defaultValues: defaultState,
|
||||
resolver: joiResolver(schema),
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(setModalState({ modal: 'auth.forgot', state: { open: false } }));
|
||||
reset();
|
||||
};
|
||||
|
||||
const onSubmit = ({ email }: FormData) => {
|
||||
mutate({ email }, { onSettled: handleClose });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseModal
|
||||
icon={<Password />}
|
||||
isOpen={isOpen}
|
||||
heading={t('modals.auth.forgot-password.heading')}
|
||||
handleClose={handleClose}
|
||||
footerChildren={
|
||||
<Button type="submit" disabled={isLoading} onClick={handleSubmit(onSubmit)}>
|
||||
{t('modals.auth.forgot-password.actions.send-email')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<p>{t('modals.auth.forgot-password.body')}</p>
|
||||
|
||||
<form className="grid gap-4 xl:w-2/3">
|
||||
<Controller
|
||||
name="email"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
autoFocus
|
||||
label={t('modals.auth.forgot-password.form.email.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<p className="text-xs">{t('modals.auth.forgot-password.help-text')}</p>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPasswordModal;
|
||||
182
apps/client/modals/auth/LoginModal.tsx
Normal file
182
apps/client/modals/auth/LoginModal.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
import { joiResolver } from '@hookform/resolvers/joi';
|
||||
import { Google, Login, Visibility, VisibilityOff } from '@mui/icons-material';
|
||||
import { Button, IconButton, InputAdornment, TextField } from '@mui/material';
|
||||
import Joi from 'joi';
|
||||
import { Trans, useTranslation } from 'next-i18next';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { GoogleLoginResponse, useGoogleLogin } from 'react-google-login';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useIsMutating, useMutation } from 'react-query';
|
||||
|
||||
import BaseModal from '@/components/shared/BaseModal';
|
||||
import { login, LoginParams, loginWithGoogle, LoginWithGoogleParams } from '@/services/auth';
|
||||
import { ServerError } from '@/services/axios';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
|
||||
type FormData = {
|
||||
identifier: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const defaultState: FormData = {
|
||||
identifier: '',
|
||||
password: '',
|
||||
};
|
||||
|
||||
const schema = Joi.object({
|
||||
identifier: Joi.string().required(),
|
||||
password: Joi.string().min(6).required(),
|
||||
});
|
||||
|
||||
const LoginModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const isMutating = useIsMutating();
|
||||
const isLoading = useMemo(() => isMutating > 0, [isMutating]);
|
||||
|
||||
const { open: isOpen } = useAppSelector((state) => state.modal['auth.login']);
|
||||
|
||||
const { reset, control, handleSubmit } = useForm<FormData>({
|
||||
defaultValues: defaultState,
|
||||
resolver: joiResolver(schema),
|
||||
});
|
||||
|
||||
const { mutateAsync: loginMutation } = useMutation<void, ServerError, LoginParams>(login);
|
||||
|
||||
const { mutateAsync: loginWithGoogleMutation } = useMutation<void, ServerError, LoginWithGoogleParams>(
|
||||
loginWithGoogle
|
||||
);
|
||||
|
||||
const { signIn } = useGoogleLogin({
|
||||
clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
|
||||
onSuccess: async (response: GoogleLoginResponse) => {
|
||||
await loginWithGoogleMutation({ accessToken: response.accessToken });
|
||||
|
||||
handleClose();
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(setModalState({ modal: 'auth.login', state: { open: false } }));
|
||||
reset();
|
||||
};
|
||||
|
||||
const onSubmit = async ({ identifier, password }: FormData) => {
|
||||
await loginMutation(
|
||||
{ identifier, password },
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleCreateAccount = () => {
|
||||
handleClose();
|
||||
dispatch(setModalState({ modal: 'auth.register', state: { open: true } }));
|
||||
};
|
||||
|
||||
const handleRecoverAccount = () => {
|
||||
handleClose();
|
||||
dispatch(setModalState({ modal: 'auth.forgot', state: { open: true } }));
|
||||
};
|
||||
|
||||
const handleLoginWithGoogle = () => {
|
||||
signIn();
|
||||
};
|
||||
|
||||
const PasswordVisibility = (): React.ReactElement => {
|
||||
const handleToggle = () => setShowPassword((showPassword) => !showPassword);
|
||||
|
||||
return (
|
||||
<InputAdornment position="end">
|
||||
<IconButton edge="end" onClick={handleToggle}>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
icon={<Login />}
|
||||
isOpen={isOpen}
|
||||
heading={t('modals.auth.login.heading')}
|
||||
handleClose={handleClose}
|
||||
footerChildren={
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outlined"
|
||||
disabled={isLoading}
|
||||
startIcon={<Google />}
|
||||
onClick={handleLoginWithGoogle}
|
||||
>
|
||||
{t('modals.auth.login.actions.login-google')}
|
||||
</Button>
|
||||
|
||||
<Button type="submit" onClick={handleSubmit(onSubmit)} disabled={isLoading}>
|
||||
{t('modals.auth.login.actions.login')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p>{t('modals.auth.login.body')}</p>
|
||||
|
||||
<form className="grid gap-4 xl:w-2/3">
|
||||
<Controller
|
||||
name="identifier"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
autoFocus
|
||||
label={t('modals.auth.login.form.username.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || t('modals.auth.login.form.username.help-text')}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="password"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
label={t('modals.auth.login.form.password.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
InputProps={{ endAdornment: <PasswordVisibility /> }}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<p className="text-xs">
|
||||
<Trans t={t} i18nKey="modals.auth.login.register-text">
|
||||
If you don't have one, you can <a onClick={handleCreateAccount}>create an account</a> here.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="text-xs">
|
||||
<Trans t={t} i18nKey="modals.auth.login.recover-text">
|
||||
In case you have forgotten your password, you can <a onClick={handleRecoverAccount}>recover your account</a>
|
||||
here.
|
||||
</Trans>
|
||||
</p>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginModal;
|
||||
170
apps/client/modals/auth/RegisterModal.tsx
Normal file
170
apps/client/modals/auth/RegisterModal.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
import { joiResolver } from '@hookform/resolvers/joi';
|
||||
import { HowToReg } from '@mui/icons-material';
|
||||
import { Button, TextField } from '@mui/material';
|
||||
import Joi from 'joi';
|
||||
import { Trans, useTranslation } from 'next-i18next';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useMutation } from 'react-query';
|
||||
|
||||
import BaseModal from '@/components/shared/BaseModal';
|
||||
import { register as registerUser, RegisterParams } from '@/services/auth';
|
||||
import { ServerError } from '@/services/axios';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
|
||||
type FormData = {
|
||||
name: string;
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
};
|
||||
|
||||
const defaultState: FormData = {
|
||||
name: '',
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
};
|
||||
|
||||
const schema = Joi.object({
|
||||
name: Joi.string().required(),
|
||||
username: Joi.string()
|
||||
.lowercase()
|
||||
.min(3)
|
||||
.regex(/^[a-z0-9-]+$/, 'only lowercase characters, numbers and hyphens')
|
||||
.required(),
|
||||
email: Joi.string()
|
||||
.email({ tlds: { allow: false } })
|
||||
.required(),
|
||||
password: Joi.string().min(6).required(),
|
||||
confirmPassword: Joi.string().min(6).required().valid(Joi.ref('password')),
|
||||
});
|
||||
|
||||
const RegisterModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { open: isOpen } = useAppSelector((state) => state.modal['auth.register']);
|
||||
|
||||
const { reset, control, handleSubmit } = useForm<FormData>({
|
||||
defaultValues: defaultState,
|
||||
resolver: joiResolver(schema),
|
||||
});
|
||||
|
||||
const { mutateAsync, isLoading } = useMutation<void, ServerError, RegisterParams>(registerUser);
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(setModalState({ modal: 'auth.register', state: { open: false } }));
|
||||
reset();
|
||||
};
|
||||
|
||||
const onSubmit = async ({ name, username, email, password }: FormData) => {
|
||||
await mutateAsync({ name, username, email, password });
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleLogin = () => {
|
||||
handleClose();
|
||||
dispatch(setModalState({ modal: 'auth.login', state: { open: true } }));
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
icon={<HowToReg />}
|
||||
isOpen={isOpen}
|
||||
heading={t('modals.auth.register.heading')}
|
||||
handleClose={handleClose}
|
||||
footerChildren={
|
||||
<Button type="submit" onClick={handleSubmit(onSubmit)} disabled={isLoading}>
|
||||
{t('modals.auth.register.actions.register')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p>{t('modals.auth.register.body')}</p>
|
||||
|
||||
<form className="grid gap-4 md:grid-cols-2">
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
autoFocus
|
||||
label={t('modals.auth.register.form.name.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="username"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label={t('modals.auth.register.form.username.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="email"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
type="email"
|
||||
label={t('modals.auth.register.form.email.label')}
|
||||
className="col-span-2"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="password"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
type="password"
|
||||
label={t('modals.auth.register.form.password.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="confirmPassword"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
type="password"
|
||||
label={t('modals.auth.register.form.confirm-password.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<p className="text-xs">
|
||||
<Trans t={t} i18nKey="modals.auth.register.loginText">
|
||||
If you already have an account, you can <a onClick={handleLogin}>login here</a>.
|
||||
</Trans>
|
||||
</p>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterModal;
|
||||
112
apps/client/modals/auth/ResetPasswordModal.tsx
Normal file
112
apps/client/modals/auth/ResetPasswordModal.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { joiResolver } from '@hookform/resolvers/joi';
|
||||
import { LockReset } from '@mui/icons-material';
|
||||
import { Button, TextField } from '@mui/material';
|
||||
import Joi from 'joi';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useMutation } from 'react-query';
|
||||
|
||||
import BaseModal from '@/components/shared/BaseModal';
|
||||
import { resetPassword, ResetPasswordParams } from '@/services/auth';
|
||||
import { ServerError } from '@/services/axios';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { ModalState, setModalState } from '@/store/modal/modalSlice';
|
||||
|
||||
type Payload = {
|
||||
resetToken?: string;
|
||||
};
|
||||
|
||||
type FormData = {
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
};
|
||||
|
||||
const defaultState: FormData = {
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
};
|
||||
|
||||
const schema = Joi.object({
|
||||
password: Joi.string().min(6).required(),
|
||||
confirmPassword: Joi.string().min(6).required().valid(Joi.ref('password')),
|
||||
});
|
||||
|
||||
const ResetPasswordModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { open: isOpen, payload } = useAppSelector((state) => state.modal['auth.reset']) as ModalState;
|
||||
const resetData = get(payload, 'item', {}) as Payload;
|
||||
|
||||
const { mutateAsync, isLoading } = useMutation<void, ServerError, ResetPasswordParams>(resetPassword);
|
||||
|
||||
const { reset, control, handleSubmit } = useForm<FormData>({
|
||||
defaultValues: defaultState,
|
||||
resolver: joiResolver(schema),
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(setModalState({ modal: 'auth.reset', state: { open: false } }));
|
||||
reset();
|
||||
};
|
||||
|
||||
const onSubmit = async ({ password }: FormData) => {
|
||||
if (isEmpty(resetData.resetToken)) return;
|
||||
|
||||
await mutateAsync({ resetToken: resetData.resetToken, password });
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
icon={<LockReset />}
|
||||
isOpen={isOpen}
|
||||
heading={t('modals.auth.reset-password.heading')}
|
||||
handleClose={handleClose}
|
||||
footerChildren={
|
||||
<Button type="submit" disabled={isLoading} onClick={handleSubmit(onSubmit)}>
|
||||
{t('modals.auth.reset-password.actions.set-password')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p>{t('modals.auth.reset-password.body')}</p>
|
||||
|
||||
<form className="grid gap-4 md:grid-cols-2">
|
||||
<Controller
|
||||
name="password"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
autoFocus
|
||||
type="password"
|
||||
label={t('modals.auth.reset-password.form.password.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="confirmPassword"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
type="password"
|
||||
label={t('modals.auth.reset-password.form.confirm-password.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPasswordModal;
|
||||
185
apps/client/modals/builder/sections/AwardModal.tsx
Normal file
185
apps/client/modals/builder/sections/AwardModal.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import { joiResolver } from '@hookform/resolvers/joi';
|
||||
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
|
||||
import DatePicker from '@mui/lab/DatePicker';
|
||||
import { Button, TextField } from '@mui/material';
|
||||
import { Award, SectionPath } from '@reactive-resume/schema';
|
||||
import dayjs from 'dayjs';
|
||||
import Joi from 'joi';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import BaseModal from '@/components/shared/BaseModal';
|
||||
import MarkdownSupported from '@/components/shared/MarkdownSupported';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
import { addItem, editItem } from '@/store/resume/resumeSlice';
|
||||
|
||||
type FormData = Award;
|
||||
|
||||
const path: SectionPath = 'sections.awards';
|
||||
|
||||
const defaultState: FormData = {
|
||||
title: '',
|
||||
awarder: '',
|
||||
date: '',
|
||||
url: '',
|
||||
summary: '',
|
||||
};
|
||||
|
||||
const schema = Joi.object<FormData>().keys({
|
||||
id: Joi.string(),
|
||||
title: Joi.string().required(),
|
||||
awarder: Joi.string().required(),
|
||||
date: Joi.string().allow(''),
|
||||
url: Joi.string()
|
||||
.pattern(/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/, { name: 'valid URL' })
|
||||
.allow(''),
|
||||
summary: Joi.string().allow(''),
|
||||
});
|
||||
|
||||
const AwardModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
|
||||
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
|
||||
|
||||
const item: FormData = get(payload, 'item', null);
|
||||
const isEditMode = useMemo(() => !!item, [item]);
|
||||
|
||||
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
|
||||
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
|
||||
|
||||
const { reset, control, handleSubmit } = useForm<FormData>({
|
||||
defaultValues: defaultState,
|
||||
resolver: joiResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormData) => {
|
||||
if (isEditMode) {
|
||||
dispatch(editItem({ path: `${path}.items`, value: formData }));
|
||||
} else {
|
||||
dispatch(addItem({ path: `${path}.items`, value: formData }));
|
||||
}
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(
|
||||
setModalState({
|
||||
modal: `builder.${path}`,
|
||||
state: { open: false },
|
||||
})
|
||||
);
|
||||
|
||||
reset(defaultState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEmpty(item)) {
|
||||
reset(item);
|
||||
}
|
||||
}, [item, reset]);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
|
||||
isOpen={isOpen}
|
||||
handleClose={handleClose}
|
||||
heading={isEditMode ? editText : addText}
|
||||
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
|
||||
>
|
||||
<form className="my-2 grid grid-cols-2 gap-4">
|
||||
<Controller
|
||||
name="title"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
autoFocus
|
||||
label={t('builder.common.form.title.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="awarder"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
label={t('builder.leftSidebar.sections.awards.form.awarder.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="date"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<DatePicker
|
||||
{...field}
|
||||
openTo="year"
|
||||
label={t('builder.common.form.date.label')}
|
||||
views={['year', 'month', 'day']}
|
||||
onChange={(date: Date, keyboardInputValue: string) => {
|
||||
isEmpty(keyboardInputValue) && field.onChange('');
|
||||
dayjs(date).isValid() && field.onChange(date.toISOString());
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || params.inputProps?.placeholder}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="url"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label={t('builder.common.form.url.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="summary"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
label={t('builder.common.form.summary.label')}
|
||||
className="col-span-2"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || <MarkdownSupported />}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AwardModal;
|
||||
185
apps/client/modals/builder/sections/CertificateModal.tsx
Normal file
185
apps/client/modals/builder/sections/CertificateModal.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import { joiResolver } from '@hookform/resolvers/joi';
|
||||
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
|
||||
import DatePicker from '@mui/lab/DatePicker';
|
||||
import { Button, TextField } from '@mui/material';
|
||||
import { Certificate, SectionPath } from '@reactive-resume/schema';
|
||||
import dayjs from 'dayjs';
|
||||
import Joi from 'joi';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import BaseModal from '@/components/shared/BaseModal';
|
||||
import MarkdownSupported from '@/components/shared/MarkdownSupported';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
import { addItem, editItem } from '@/store/resume/resumeSlice';
|
||||
|
||||
type FormData = Certificate;
|
||||
|
||||
const path: SectionPath = 'sections.certifications';
|
||||
|
||||
const defaultState: FormData = {
|
||||
name: '',
|
||||
issuer: '',
|
||||
date: '',
|
||||
url: '',
|
||||
summary: '',
|
||||
};
|
||||
|
||||
const schema = Joi.object<FormData>().keys({
|
||||
id: Joi.string(),
|
||||
name: Joi.string().required(),
|
||||
issuer: Joi.string().required(),
|
||||
date: Joi.string().allow(''),
|
||||
url: Joi.string()
|
||||
.pattern(/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/, { name: 'valid URL' })
|
||||
.allow(''),
|
||||
summary: Joi.string().allow(''),
|
||||
});
|
||||
|
||||
const CertificateModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
|
||||
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
|
||||
const item: FormData = get(payload, 'item', null);
|
||||
|
||||
const isEditMode = useMemo(() => !!item, [item]);
|
||||
|
||||
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
|
||||
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
|
||||
|
||||
const { reset, control, handleSubmit } = useForm<FormData>({
|
||||
defaultValues: defaultState,
|
||||
resolver: joiResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormData) => {
|
||||
if (isEditMode) {
|
||||
dispatch(editItem({ path: `${path}.items`, value: formData }));
|
||||
} else {
|
||||
dispatch(addItem({ path: `${path}.items`, value: formData }));
|
||||
}
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(
|
||||
setModalState({
|
||||
modal: `builder.${path}`,
|
||||
state: { open: false },
|
||||
})
|
||||
);
|
||||
|
||||
reset(defaultState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEmpty(item)) {
|
||||
reset(item);
|
||||
}
|
||||
}, [item, reset]);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
|
||||
isOpen={isOpen}
|
||||
handleClose={handleClose}
|
||||
heading={isEditMode ? editText : addText}
|
||||
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
|
||||
>
|
||||
<form className="my-2 grid grid-cols-2 gap-4">
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
autoFocus
|
||||
label={t('builder.common.form.name.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="issuer"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
label={t('builder.leftSidebar.sections.certifications.form.issuer.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="date"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<DatePicker
|
||||
{...field}
|
||||
openTo="year"
|
||||
label={t('builder.common.form.date.label')}
|
||||
views={['year', 'month', 'day']}
|
||||
onChange={(date: Date, keyboardInputValue: string) => {
|
||||
isEmpty(keyboardInputValue) && field.onChange('');
|
||||
dayjs(date).isValid() && field.onChange(date.toISOString());
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || params.inputProps?.placeholder}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="url"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label={t('builder.common.form.url.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="summary"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
label={t('builder.common.form.summary.label')}
|
||||
className="col-span-2"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || <MarkdownSupported />}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CertificateModal;
|
||||
290
apps/client/modals/builder/sections/CustomModal.tsx
Normal file
290
apps/client/modals/builder/sections/CustomModal.tsx
Normal file
@ -0,0 +1,290 @@
|
||||
import { joiResolver } from '@hookform/resolvers/joi';
|
||||
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
|
||||
import DatePicker from '@mui/lab/DatePicker';
|
||||
import { Button, Slider, TextField } from '@mui/material';
|
||||
import { Custom } from '@reactive-resume/schema';
|
||||
import dayjs from 'dayjs';
|
||||
import Joi from 'joi';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import ArrayInput from '@/components/shared/ArrayInput';
|
||||
import BaseModal from '@/components/shared/BaseModal';
|
||||
import MarkdownSupported from '@/components/shared/MarkdownSupported';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
import { addItem, editItem } from '@/store/resume/resumeSlice';
|
||||
|
||||
type FormData = Custom;
|
||||
|
||||
export type CustomModalPayload = {
|
||||
path: string;
|
||||
item?: Custom;
|
||||
};
|
||||
|
||||
const defaultState: FormData = {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
date: {
|
||||
start: '',
|
||||
end: '',
|
||||
},
|
||||
url: '',
|
||||
level: '',
|
||||
levelNum: 0,
|
||||
summary: '',
|
||||
keywords: [],
|
||||
};
|
||||
|
||||
const schema = Joi.object<FormData>().keys({
|
||||
id: Joi.string(),
|
||||
title: Joi.string().required(),
|
||||
subtitle: Joi.string().allow(''),
|
||||
date: Joi.object().keys({
|
||||
start: Joi.string().allow(''),
|
||||
end: Joi.string().allow(''),
|
||||
}),
|
||||
url: Joi.string()
|
||||
.pattern(/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/, { name: 'valid URL' })
|
||||
.allow(''),
|
||||
level: Joi.string().allow(''),
|
||||
levelNum: Joi.number().min(0).max(10),
|
||||
summary: Joi.string().allow(''),
|
||||
keywords: Joi.array().items(Joi.string().optional()),
|
||||
});
|
||||
|
||||
const CustomModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
|
||||
const { open: isOpen, payload } = useAppSelector((state) => state.modal['builder.sections.custom']);
|
||||
|
||||
const path: string = get(payload, 'path', '');
|
||||
const item: FormData = get(payload, 'item', null);
|
||||
const isEditMode = useMemo(() => !!item, [item]);
|
||||
|
||||
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
|
||||
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
|
||||
|
||||
const { reset, control, handleSubmit } = useForm<FormData>({
|
||||
defaultValues: defaultState,
|
||||
resolver: joiResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormData) => {
|
||||
if (isEditMode) {
|
||||
dispatch(editItem({ path: `${path}.items`, value: formData }));
|
||||
} else {
|
||||
dispatch(addItem({ path: `${path}.items`, value: formData }));
|
||||
}
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(
|
||||
setModalState({
|
||||
modal: 'builder.sections.custom',
|
||||
state: { open: false },
|
||||
})
|
||||
);
|
||||
|
||||
reset(defaultState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEmpty(item)) {
|
||||
reset(item);
|
||||
}
|
||||
}, [item, reset]);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
|
||||
isOpen={isOpen}
|
||||
handleClose={handleClose}
|
||||
heading={isEditMode ? editText : addText}
|
||||
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
|
||||
>
|
||||
<form className="my-2 grid grid-cols-2 gap-4">
|
||||
<Controller
|
||||
name="title"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
autoFocus
|
||||
label={t('builder.common.form.title.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="subtitle"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label={t('builder.common.form.subtitle.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="date.start"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<DatePicker
|
||||
{...field}
|
||||
openTo="year"
|
||||
label={t('builder.common.form.start-date.label')}
|
||||
views={['year', 'month', 'day']}
|
||||
onChange={(date: Date, keyboardInputValue: string) => {
|
||||
isEmpty(keyboardInputValue) && field.onChange('');
|
||||
dayjs(date).isValid() && field.onChange(date.toISOString());
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || params.inputProps?.placeholder}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="date.end"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<DatePicker
|
||||
{...field}
|
||||
openTo="year"
|
||||
label={t('builder.common.form.end-date.label')}
|
||||
views={['year', 'month', 'day']}
|
||||
onChange={(date: Date, keyboardInputValue: string) => {
|
||||
isEmpty(keyboardInputValue) && field.onChange('');
|
||||
dayjs(date).isValid() && field.onChange(date.toISOString());
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || 'Leave this field blank, if still present'}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="url"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label={t('builder.common.form.url.label')}
|
||||
className="col-span-2"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="level"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label={t('builder.common.form.level.label')}
|
||||
className="col-span-2"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="levelNum"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="col-span-2">
|
||||
<h4 className="mb-3 font-semibold">{t('builder.common.form.levelNum.label')}</h4>
|
||||
|
||||
<div className="px-10">
|
||||
<Slider
|
||||
{...field}
|
||||
marks={[
|
||||
{
|
||||
value: 0,
|
||||
label: 'Disable',
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
label: 'Beginner',
|
||||
},
|
||||
{
|
||||
value: 10,
|
||||
label: 'Expert',
|
||||
},
|
||||
]}
|
||||
min={0}
|
||||
max={10}
|
||||
defaultValue={0}
|
||||
color="secondary"
|
||||
valueLabelDisplay="auto"
|
||||
aria-label={t('builder.common.form.levelNum.label')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="summary"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
label={t('builder.common.form.summary.label')}
|
||||
className="col-span-2"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || <MarkdownSupported />}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="keywords"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<ArrayInput
|
||||
label={t('builder.common.form.keywords.label')}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
errors={fieldState.error}
|
||||
className="col-span-2"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomModal;
|
||||
263
apps/client/modals/builder/sections/EducationModal.tsx
Normal file
263
apps/client/modals/builder/sections/EducationModal.tsx
Normal file
@ -0,0 +1,263 @@
|
||||
import { joiResolver } from '@hookform/resolvers/joi';
|
||||
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
|
||||
import DatePicker from '@mui/lab/DatePicker';
|
||||
import { Button, TextField } from '@mui/material';
|
||||
import { Education, SectionPath } from '@reactive-resume/schema';
|
||||
import dayjs from 'dayjs';
|
||||
import Joi from 'joi';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import ArrayInput from '@/components/shared/ArrayInput';
|
||||
import BaseModal from '@/components/shared/BaseModal';
|
||||
import MarkdownSupported from '@/components/shared/MarkdownSupported';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
import { addItem, editItem } from '@/store/resume/resumeSlice';
|
||||
|
||||
type FormData = Education;
|
||||
|
||||
const path: SectionPath = 'sections.education';
|
||||
|
||||
const defaultState: FormData = {
|
||||
institution: '',
|
||||
degree: '',
|
||||
area: '',
|
||||
score: '',
|
||||
date: {
|
||||
start: '',
|
||||
end: '',
|
||||
},
|
||||
url: '',
|
||||
summary: '',
|
||||
courses: [],
|
||||
};
|
||||
|
||||
const schema = Joi.object<FormData>().keys({
|
||||
id: Joi.string(),
|
||||
institution: Joi.string().required(),
|
||||
degree: Joi.string().required(),
|
||||
area: Joi.string().allow(''),
|
||||
score: Joi.string().allow(''),
|
||||
date: Joi.object().keys({
|
||||
start: Joi.string().allow(''),
|
||||
end: Joi.string().allow(''),
|
||||
}),
|
||||
url: Joi.string()
|
||||
.pattern(/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/, { name: 'valid URL' })
|
||||
.allow(''),
|
||||
summary: Joi.string().allow(''),
|
||||
courses: Joi.array().items(Joi.string().optional()),
|
||||
});
|
||||
|
||||
const EducationModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
|
||||
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
|
||||
|
||||
const item: FormData = get(payload, 'item', null);
|
||||
const isEditMode = useMemo(() => !!item, [item]);
|
||||
|
||||
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
|
||||
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
|
||||
|
||||
const { reset, control, handleSubmit } = useForm<FormData>({
|
||||
defaultValues: defaultState,
|
||||
resolver: joiResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormData) => {
|
||||
if (isEditMode) {
|
||||
dispatch(editItem({ path: `${path}.items`, value: formData }));
|
||||
} else {
|
||||
dispatch(addItem({ path: `${path}.items`, value: formData }));
|
||||
}
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(
|
||||
setModalState({
|
||||
modal: `builder.${path}`,
|
||||
state: { open: false },
|
||||
})
|
||||
);
|
||||
|
||||
reset(defaultState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEmpty(item)) {
|
||||
reset(item);
|
||||
}
|
||||
}, [item, reset]);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
|
||||
isOpen={isOpen}
|
||||
handleClose={handleClose}
|
||||
heading={isEditMode ? editText : addText}
|
||||
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
|
||||
>
|
||||
<form className="my-2 grid grid-cols-2 gap-4">
|
||||
<Controller
|
||||
name="institution"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
autoFocus
|
||||
label={t('builder.leftSidebar.sections.education.form.institution.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="degree"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
label={t('builder.leftSidebar.sections.education.form.degree.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="area"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label={t('builder.leftSidebar.sections.education.form.area-study.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="score"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label={t('builder.leftSidebar.sections.education.form.grade.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="date.start"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<DatePicker
|
||||
{...field}
|
||||
openTo="year"
|
||||
label={t('builder.common.form.start-date.label')}
|
||||
views={['year', 'month', 'day']}
|
||||
onChange={(date: Date, keyboardInputValue: string) => {
|
||||
isEmpty(keyboardInputValue) && field.onChange('');
|
||||
dayjs(date).isValid() && field.onChange(date.toISOString());
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || params.inputProps?.placeholder}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="date.end"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<DatePicker
|
||||
{...field}
|
||||
openTo="year"
|
||||
label={t('builder.common.form.end-date.label')}
|
||||
views={['year', 'month', 'day']}
|
||||
onChange={(date: Date, keyboardInputValue: string) => {
|
||||
isEmpty(keyboardInputValue) && field.onChange('');
|
||||
dayjs(date).isValid() && field.onChange(date.toISOString());
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || t('builder.common.form.end-date.help-text')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="url"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label={t('builder.common.form.url.label')}
|
||||
className="col-span-2"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="summary"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
label={t('builder.common.form.summary.label')}
|
||||
className="col-span-2"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || <MarkdownSupported />}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="courses"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<ArrayInput
|
||||
label={t('builder.leftSidebar.sections.education.form.courses.label')}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
errors={fieldState.error}
|
||||
className="col-span-2"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EducationModal;
|
||||
122
apps/client/modals/builder/sections/InterestModal.tsx
Normal file
122
apps/client/modals/builder/sections/InterestModal.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import { joiResolver } from '@hookform/resolvers/joi';
|
||||
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
|
||||
import { Button, TextField } from '@mui/material';
|
||||
import { Interest, SectionPath } from '@reactive-resume/schema';
|
||||
import Joi from 'joi';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import ArrayInput from '@/components/shared/ArrayInput';
|
||||
import BaseModal from '@/components/shared/BaseModal';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
import { addItem, editItem } from '@/store/resume/resumeSlice';
|
||||
|
||||
type FormData = Interest;
|
||||
|
||||
const path: SectionPath = 'sections.interests';
|
||||
|
||||
const defaultState: FormData = {
|
||||
name: '',
|
||||
keywords: [],
|
||||
};
|
||||
|
||||
const schema = Joi.object<FormData>().keys({
|
||||
id: Joi.string(),
|
||||
name: Joi.string().required(),
|
||||
keywords: Joi.array().items(Joi.string().optional()),
|
||||
});
|
||||
|
||||
const InterestModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
|
||||
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
|
||||
|
||||
const item: FormData = get(payload, 'item', null);
|
||||
const isEditMode = useMemo(() => !!item, [item]);
|
||||
|
||||
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
|
||||
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
|
||||
|
||||
const { reset, control, handleSubmit } = useForm<FormData>({
|
||||
defaultValues: defaultState,
|
||||
resolver: joiResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormData) => {
|
||||
if (isEditMode) {
|
||||
dispatch(editItem({ path: `${path}.items`, value: formData }));
|
||||
} else {
|
||||
dispatch(addItem({ path: `${path}.items`, value: formData }));
|
||||
}
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(
|
||||
setModalState({
|
||||
modal: `builder.${path}`,
|
||||
state: { open: false },
|
||||
})
|
||||
);
|
||||
|
||||
reset(defaultState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEmpty(item)) {
|
||||
reset(item);
|
||||
}
|
||||
}, [item, reset]);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
|
||||
isOpen={isOpen}
|
||||
handleClose={handleClose}
|
||||
heading={isEditMode ? editText : addText}
|
||||
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
|
||||
>
|
||||
<form className="my-2 grid grid-cols-2 gap-4">
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
autoFocus
|
||||
label={t('builder.common.form.name.label')}
|
||||
className="col-span-2"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="keywords"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<ArrayInput
|
||||
label={t('builder.common.form.keywords.label')}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
errors={fieldState.error}
|
||||
className="col-span-2"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default InterestModal;
|
||||
158
apps/client/modals/builder/sections/LanguageModal.tsx
Normal file
158
apps/client/modals/builder/sections/LanguageModal.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
import { joiResolver } from '@hookform/resolvers/joi';
|
||||
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
|
||||
import { Button, Slider, TextField } from '@mui/material';
|
||||
import { Language, SectionPath } from '@reactive-resume/schema';
|
||||
import Joi from 'joi';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import BaseModal from '@/components/shared/BaseModal';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
import { addItem, editItem } from '@/store/resume/resumeSlice';
|
||||
|
||||
type FormData = Language;
|
||||
|
||||
const path: SectionPath = 'sections.languages';
|
||||
|
||||
const defaultState: FormData = {
|
||||
name: '',
|
||||
level: '',
|
||||
levelNum: 0,
|
||||
};
|
||||
|
||||
const schema = Joi.object<FormData>().keys({
|
||||
id: Joi.string(),
|
||||
name: Joi.string().required(),
|
||||
level: Joi.string().required(),
|
||||
levelNum: Joi.number().min(0).max(10).required(),
|
||||
});
|
||||
|
||||
const LanguageModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
|
||||
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
|
||||
|
||||
const item: FormData = get(payload, 'item', null);
|
||||
const isEditMode = useMemo(() => !!item, [item]);
|
||||
|
||||
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
|
||||
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
|
||||
|
||||
const { reset, control, handleSubmit } = useForm<FormData>({
|
||||
defaultValues: defaultState,
|
||||
resolver: joiResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormData) => {
|
||||
if (isEditMode) {
|
||||
dispatch(editItem({ path: `${path}.items`, value: formData }));
|
||||
} else {
|
||||
dispatch(addItem({ path: `${path}.items`, value: formData }));
|
||||
}
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(
|
||||
setModalState({
|
||||
modal: `builder.${path}`,
|
||||
state: { open: false },
|
||||
})
|
||||
);
|
||||
|
||||
reset(defaultState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEmpty(item)) {
|
||||
reset(item);
|
||||
}
|
||||
}, [item, reset]);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
|
||||
isOpen={isOpen}
|
||||
handleClose={handleClose}
|
||||
heading={isEditMode ? editText : addText}
|
||||
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
|
||||
>
|
||||
<form className="my-2 grid grid-cols-2 gap-4">
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
autoFocus
|
||||
label={t('builder.common.form.name.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="level"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
label={t('builder.common.form.level.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="levelNum"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="col-span-2">
|
||||
<h4 className="mb-3 font-semibold">{t('builder.common.form.levelNum.label')}</h4>
|
||||
|
||||
<div className="px-10">
|
||||
<Slider
|
||||
{...field}
|
||||
marks={[
|
||||
{
|
||||
value: 0,
|
||||
label: 'Disable',
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
label: 'Beginner',
|
||||
},
|
||||
{
|
||||
value: 10,
|
||||
label: 'Expert',
|
||||
},
|
||||
]}
|
||||
min={0}
|
||||
max={10}
|
||||
defaultValue={0}
|
||||
color="secondary"
|
||||
valueLabelDisplay="auto"
|
||||
aria-label={t('builder.common.form.levelNum.label')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageModal;
|
||||
145
apps/client/modals/builder/sections/ProfileModal.tsx
Normal file
145
apps/client/modals/builder/sections/ProfileModal.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import { joiResolver } from '@hookform/resolvers/joi';
|
||||
import { Add, AlternateEmail, DriveFileRenameOutline } from '@mui/icons-material';
|
||||
import { Button, TextField } from '@mui/material';
|
||||
import { Profile } from '@reactive-resume/schema';
|
||||
import Joi from 'joi';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import BaseModal from '@/components/shared/BaseModal';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
import { addItem, editItem } from '@/store/resume/resumeSlice';
|
||||
|
||||
type FormData = Profile;
|
||||
|
||||
const path = 'sections.profile';
|
||||
|
||||
const defaultState: FormData = {
|
||||
network: '',
|
||||
username: '',
|
||||
url: 'https://',
|
||||
};
|
||||
|
||||
const schema = Joi.object<FormData>({
|
||||
id: Joi.string(),
|
||||
network: Joi.string().required(),
|
||||
username: Joi.string().required(),
|
||||
url: Joi.string()
|
||||
.pattern(/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/, { name: 'valid URL' })
|
||||
.default('https://')
|
||||
.allow(''),
|
||||
});
|
||||
|
||||
const ProfileModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
|
||||
|
||||
const item: FormData = get(payload, 'item', null);
|
||||
const isEditMode = useMemo(() => !!item, [item]);
|
||||
|
||||
const addText = t('builder.common.actions.add', {
|
||||
section: t('builder.leftSidebar.sections.profiles.heading', { count: 1 }),
|
||||
});
|
||||
const editText = t('builder.common.actions.edit', {
|
||||
section: t('builder.leftSidebar.sections.profiles.heading', { count: 1 }),
|
||||
});
|
||||
|
||||
const { reset, control, handleSubmit } = useForm<FormData>({
|
||||
defaultValues: defaultState,
|
||||
resolver: joiResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormData) => {
|
||||
if (isEditMode) {
|
||||
dispatch(editItem({ path: 'basics.profiles', value: formData }));
|
||||
} else {
|
||||
dispatch(addItem({ path: 'basics.profiles', value: formData }));
|
||||
}
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(
|
||||
setModalState({
|
||||
modal: `builder.${path}`,
|
||||
state: { open: false },
|
||||
})
|
||||
);
|
||||
|
||||
reset(defaultState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEmpty(item)) {
|
||||
reset(item);
|
||||
}
|
||||
}, [item, reset]);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
|
||||
isOpen={isOpen}
|
||||
heading={isEditMode ? editText : addText}
|
||||
handleClose={handleClose}
|
||||
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
|
||||
>
|
||||
<form className="my-2 grid grid-cols-2 gap-4">
|
||||
<Controller
|
||||
name="network"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
autoFocus
|
||||
label={t('builder.leftSidebar.sections.profiles.form.network.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="username"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
label={t('builder.leftSidebar.sections.profiles.form.username.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
InputProps={{
|
||||
startAdornment: <AlternateEmail className="mr-2" />,
|
||||
}}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="url"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label={t('builder.common.form.url.label')}
|
||||
className="col-span-2"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileModal;
|
||||
233
apps/client/modals/builder/sections/ProjectModal.tsx
Normal file
233
apps/client/modals/builder/sections/ProjectModal.tsx
Normal file
@ -0,0 +1,233 @@
|
||||
import { joiResolver } from '@hookform/resolvers/joi';
|
||||
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
|
||||
import DatePicker from '@mui/lab/DatePicker';
|
||||
import { Button, TextField } from '@mui/material';
|
||||
import { Project, SectionPath } from '@reactive-resume/schema';
|
||||
import dayjs from 'dayjs';
|
||||
import Joi from 'joi';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import ArrayInput from '@/components/shared/ArrayInput';
|
||||
import BaseModal from '@/components/shared/BaseModal';
|
||||
import MarkdownSupported from '@/components/shared/MarkdownSupported';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
import { addItem, editItem } from '@/store/resume/resumeSlice';
|
||||
|
||||
type FormData = Project;
|
||||
|
||||
const path: SectionPath = 'sections.projects';
|
||||
|
||||
const defaultState: FormData = {
|
||||
name: '',
|
||||
description: '',
|
||||
date: {
|
||||
start: '',
|
||||
end: '',
|
||||
},
|
||||
url: '',
|
||||
summary: '',
|
||||
keywords: [],
|
||||
};
|
||||
|
||||
const schema = Joi.object<FormData>().keys({
|
||||
id: Joi.string(),
|
||||
name: Joi.string().required(),
|
||||
description: Joi.string().required(),
|
||||
date: Joi.object().keys({
|
||||
start: Joi.string().allow(''),
|
||||
end: Joi.string().allow(''),
|
||||
}),
|
||||
url: Joi.string()
|
||||
.pattern(/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/, { name: 'valid URL' })
|
||||
.allow(''),
|
||||
summary: Joi.string().allow(''),
|
||||
keywords: Joi.array().items(Joi.string().optional()),
|
||||
});
|
||||
|
||||
const ProjectModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
|
||||
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
|
||||
|
||||
const item: FormData = get(payload, 'item', null);
|
||||
const isEditMode = useMemo(() => !!item, [item]);
|
||||
|
||||
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
|
||||
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
|
||||
|
||||
const { reset, control, handleSubmit } = useForm<FormData>({
|
||||
defaultValues: defaultState,
|
||||
resolver: joiResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormData) => {
|
||||
if (isEditMode) {
|
||||
dispatch(editItem({ path: `${path}.items`, value: formData }));
|
||||
} else {
|
||||
dispatch(addItem({ path: `${path}.items`, value: formData }));
|
||||
}
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(
|
||||
setModalState({
|
||||
modal: `builder.${path}`,
|
||||
state: { open: false },
|
||||
})
|
||||
);
|
||||
|
||||
reset(defaultState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEmpty(item)) {
|
||||
reset(item);
|
||||
}
|
||||
}, [item, reset]);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
|
||||
isOpen={isOpen}
|
||||
handleClose={handleClose}
|
||||
heading={isEditMode ? editText : addText}
|
||||
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
|
||||
>
|
||||
<form className="my-2 grid grid-cols-2 gap-4">
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
autoFocus
|
||||
label={t('builder.common.form.name.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
label={t('builder.common.form.description.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="date.start"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<DatePicker
|
||||
{...field}
|
||||
openTo="year"
|
||||
label={t('builder.common.form.start-date.label')}
|
||||
views={['year', 'month', 'day']}
|
||||
onChange={(date: Date, keyboardInputValue: string) => {
|
||||
isEmpty(keyboardInputValue) && field.onChange('');
|
||||
dayjs(date).isValid() && field.onChange(date.toISOString());
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || params.inputProps?.placeholder}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="date.end"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<DatePicker
|
||||
{...field}
|
||||
openTo="year"
|
||||
label={t('builder.common.form.end-date.label')}
|
||||
views={['year', 'month', 'day']}
|
||||
onChange={(date: Date, keyboardInputValue: string) => {
|
||||
isEmpty(keyboardInputValue) && field.onChange('');
|
||||
dayjs(date).isValid() && field.onChange(date.toISOString());
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || 'Leave this field blank, if still present'}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="url"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label={t('builder.common.form.url.label')}
|
||||
className="col-span-2"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="summary"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
label={t('builder.common.form.summary.label')}
|
||||
className="col-span-2"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || <MarkdownSupported />}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="keywords"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<ArrayInput
|
||||
label={t('builder.common.form.keywords.label')}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
errors={fieldState.error}
|
||||
className="col-span-2"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectModal;
|
||||
185
apps/client/modals/builder/sections/PublicationModal.tsx
Normal file
185
apps/client/modals/builder/sections/PublicationModal.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import { joiResolver } from '@hookform/resolvers/joi';
|
||||
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
|
||||
import DatePicker from '@mui/lab/DatePicker';
|
||||
import { Button, TextField } from '@mui/material';
|
||||
import { Publication, SectionPath } from '@reactive-resume/schema';
|
||||
import dayjs from 'dayjs';
|
||||
import Joi from 'joi';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import BaseModal from '@/components/shared/BaseModal';
|
||||
import MarkdownSupported from '@/components/shared/MarkdownSupported';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
import { addItem, editItem } from '@/store/resume/resumeSlice';
|
||||
|
||||
type FormData = Publication;
|
||||
|
||||
const path: SectionPath = 'sections.publications';
|
||||
|
||||
const defaultState: FormData = {
|
||||
name: '',
|
||||
publisher: '',
|
||||
date: '',
|
||||
url: '',
|
||||
summary: '',
|
||||
};
|
||||
|
||||
const schema = Joi.object<FormData>().keys({
|
||||
id: Joi.string(),
|
||||
name: Joi.string().required(),
|
||||
publisher: Joi.string().required(),
|
||||
date: Joi.string().allow(''),
|
||||
url: Joi.string()
|
||||
.pattern(/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/, { name: 'valid URL' })
|
||||
.allow(''),
|
||||
summary: Joi.string().allow(''),
|
||||
});
|
||||
|
||||
const PublicationModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
|
||||
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
|
||||
|
||||
const item: FormData = get(payload, 'item', null);
|
||||
const isEditMode = useMemo(() => !!item, [item]);
|
||||
|
||||
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
|
||||
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
|
||||
|
||||
const { reset, control, handleSubmit } = useForm<FormData>({
|
||||
defaultValues: defaultState,
|
||||
resolver: joiResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormData) => {
|
||||
if (isEditMode) {
|
||||
dispatch(editItem({ path: `${path}.items`, value: formData }));
|
||||
} else {
|
||||
dispatch(addItem({ path: `${path}.items`, value: formData }));
|
||||
}
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(
|
||||
setModalState({
|
||||
modal: `builder.${path}`,
|
||||
state: { open: false },
|
||||
})
|
||||
);
|
||||
|
||||
reset(defaultState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEmpty(item)) {
|
||||
reset(item);
|
||||
}
|
||||
}, [item, reset]);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
|
||||
isOpen={isOpen}
|
||||
handleClose={handleClose}
|
||||
heading={isEditMode ? editText : addText}
|
||||
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
|
||||
>
|
||||
<form className="my-2 grid grid-cols-2 gap-4">
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
autoFocus
|
||||
label={t('builder.common.form.name.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="publisher"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
label="{t('builder.leftSidebar.sections.publications.form.publisher.label')}"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="date"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<DatePicker
|
||||
{...field}
|
||||
openTo="year"
|
||||
label={t('builder.common.form.date.label')}
|
||||
views={['year', 'month', 'day']}
|
||||
onChange={(date: Date, keyboardInputValue: string) => {
|
||||
isEmpty(keyboardInputValue) && field.onChange('');
|
||||
dayjs(date).isValid() && field.onChange(date.toISOString());
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || params.inputProps?.placeholder}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="url"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label={t('builder.common.form.url.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="summary"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
label={t('builder.common.form.summary.label')}
|
||||
className="col-span-2"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || <MarkdownSupported />}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublicationModal;
|
||||
170
apps/client/modals/builder/sections/ReferenceModal.tsx
Normal file
170
apps/client/modals/builder/sections/ReferenceModal.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
import { joiResolver } from '@hookform/resolvers/joi';
|
||||
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
|
||||
import { Button, TextField } from '@mui/material';
|
||||
import { Reference, SectionPath } from '@reactive-resume/schema';
|
||||
import Joi from 'joi';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import BaseModal from '@/components/shared/BaseModal';
|
||||
import MarkdownSupported from '@/components/shared/MarkdownSupported';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
import { addItem, editItem } from '@/store/resume/resumeSlice';
|
||||
|
||||
type FormData = Reference;
|
||||
|
||||
const path: SectionPath = 'sections.references';
|
||||
|
||||
const defaultState: FormData = {
|
||||
name: '',
|
||||
relationship: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
summary: '',
|
||||
};
|
||||
|
||||
const schema = Joi.object<FormData>().keys({
|
||||
id: Joi.string(),
|
||||
name: Joi.string().required(),
|
||||
relationship: Joi.string().required(),
|
||||
phone: Joi.string().allow(''),
|
||||
email: Joi.string().allow(''),
|
||||
summary: Joi.string().allow(''),
|
||||
});
|
||||
|
||||
const ReferenceModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
|
||||
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
|
||||
|
||||
const item: FormData = get(payload, 'item', null);
|
||||
const isEditMode = useMemo(() => !!item, [item]);
|
||||
|
||||
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
|
||||
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
|
||||
|
||||
const { reset, control, handleSubmit } = useForm<FormData>({
|
||||
defaultValues: defaultState,
|
||||
resolver: joiResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormData) => {
|
||||
if (isEditMode) {
|
||||
dispatch(editItem({ path: `${path}.items`, value: formData }));
|
||||
} else {
|
||||
dispatch(addItem({ path: `${path}.items`, value: formData }));
|
||||
}
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(
|
||||
setModalState({
|
||||
modal: `builder.${path}`,
|
||||
state: { open: false },
|
||||
})
|
||||
);
|
||||
|
||||
reset(defaultState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEmpty(item)) {
|
||||
reset(item);
|
||||
}
|
||||
}, [item, reset]);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
|
||||
isOpen={isOpen}
|
||||
handleClose={handleClose}
|
||||
heading={isEditMode ? editText : addText}
|
||||
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
|
||||
>
|
||||
<form className="my-2 grid grid-cols-2 gap-4">
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
autoFocus
|
||||
label={t('builder.common.form.name.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="relationship"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
label={t('builder.leftSidebar.sections.references.form.relationship.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="phone"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label={t('builder.common.form.phone.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="email"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label={t('builder.common.form.email.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="summary"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
label={t('builder.common.form.summary.label')}
|
||||
className="col-span-2"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || <MarkdownSupported />}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReferenceModal;
|
||||
175
apps/client/modals/builder/sections/SkillModal.tsx
Normal file
175
apps/client/modals/builder/sections/SkillModal.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
import { joiResolver } from '@hookform/resolvers/joi';
|
||||
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
|
||||
import { Button, Slider, TextField } from '@mui/material';
|
||||
import { SectionPath, Skill } from '@reactive-resume/schema';
|
||||
import Joi from 'joi';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import ArrayInput from '@/components/shared/ArrayInput';
|
||||
import BaseModal from '@/components/shared/BaseModal';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
import { addItem, editItem } from '@/store/resume/resumeSlice';
|
||||
|
||||
type FormData = Skill;
|
||||
|
||||
const path: SectionPath = 'sections.skills';
|
||||
|
||||
const defaultState: FormData = {
|
||||
name: '',
|
||||
level: '',
|
||||
levelNum: 0,
|
||||
keywords: [],
|
||||
};
|
||||
|
||||
const schema = Joi.object<FormData>().keys({
|
||||
id: Joi.string(),
|
||||
name: Joi.string().required(),
|
||||
level: Joi.string().required(),
|
||||
levelNum: Joi.number().min(0).max(10).required(),
|
||||
keywords: Joi.array().items(Joi.string().optional()),
|
||||
});
|
||||
|
||||
const SkillModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
|
||||
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
|
||||
|
||||
const item: FormData = get(payload, 'item', null);
|
||||
const isEditMode = useMemo(() => !!item, [item]);
|
||||
|
||||
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
|
||||
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
|
||||
|
||||
const { reset, control, handleSubmit } = useForm<FormData>({
|
||||
defaultValues: defaultState,
|
||||
resolver: joiResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormData) => {
|
||||
if (isEditMode) {
|
||||
dispatch(editItem({ path: `${path}.items`, value: formData }));
|
||||
} else {
|
||||
dispatch(addItem({ path: `${path}.items`, value: formData }));
|
||||
}
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(
|
||||
setModalState({
|
||||
modal: `builder.${path}`,
|
||||
state: { open: false },
|
||||
})
|
||||
);
|
||||
|
||||
reset(defaultState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEmpty(item)) {
|
||||
reset(item);
|
||||
}
|
||||
}, [item, reset]);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
|
||||
isOpen={isOpen}
|
||||
handleClose={handleClose}
|
||||
heading={isEditMode ? editText : addText}
|
||||
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
|
||||
>
|
||||
<form className="my-2 grid grid-cols-2 gap-4">
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
autoFocus
|
||||
label={t('builder.common.form.name.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="level"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
label={t('builder.common.form.level.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="levelNum"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="col-span-2">
|
||||
<h4 className="mb-3 font-semibold">{t('builder.common.form.levelNum.label')}</h4>
|
||||
|
||||
<div className="px-10">
|
||||
<Slider
|
||||
{...field}
|
||||
marks={[
|
||||
{
|
||||
value: 0,
|
||||
label: 'Disable',
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
label: 'Beginner',
|
||||
},
|
||||
{
|
||||
value: 10,
|
||||
label: 'Expert',
|
||||
},
|
||||
]}
|
||||
min={0}
|
||||
max={10}
|
||||
defaultValue={0}
|
||||
color="secondary"
|
||||
valueLabelDisplay="auto"
|
||||
aria-label={t('builder.common.form.levelNum.label')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="keywords"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<ArrayInput
|
||||
label={t('builder.common.form.keywords.label')}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
errors={fieldState.error}
|
||||
className="col-span-2"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkillModal;
|
||||
216
apps/client/modals/builder/sections/VolunteerModal.tsx
Normal file
216
apps/client/modals/builder/sections/VolunteerModal.tsx
Normal file
@ -0,0 +1,216 @@
|
||||
import { joiResolver } from '@hookform/resolvers/joi';
|
||||
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
|
||||
import DatePicker from '@mui/lab/DatePicker';
|
||||
import { Button, TextField } from '@mui/material';
|
||||
import { SectionPath, Volunteer } from '@reactive-resume/schema';
|
||||
import dayjs from 'dayjs';
|
||||
import Joi from 'joi';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import BaseModal from '@/components/shared/BaseModal';
|
||||
import MarkdownSupported from '@/components/shared/MarkdownSupported';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
import { addItem, editItem } from '@/store/resume/resumeSlice';
|
||||
|
||||
type FormData = Volunteer;
|
||||
|
||||
const path: SectionPath = 'sections.volunteer';
|
||||
|
||||
const defaultState: FormData = {
|
||||
organization: '',
|
||||
position: '',
|
||||
date: {
|
||||
start: '',
|
||||
end: '',
|
||||
},
|
||||
url: '',
|
||||
summary: '',
|
||||
};
|
||||
|
||||
const schema = Joi.object<FormData>().keys({
|
||||
id: Joi.string(),
|
||||
organization: Joi.string().required(),
|
||||
position: Joi.string().required(),
|
||||
date: Joi.object().keys({
|
||||
start: Joi.string().allow(''),
|
||||
end: Joi.string().allow(''),
|
||||
}),
|
||||
url: Joi.string()
|
||||
.pattern(/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/, { name: 'valid URL' })
|
||||
.allow(''),
|
||||
summary: Joi.string().allow(''),
|
||||
});
|
||||
|
||||
const VolunteerModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
|
||||
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
|
||||
|
||||
const item: FormData = get(payload, 'item', null);
|
||||
const isEditMode = useMemo(() => !!item, [item]);
|
||||
|
||||
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
|
||||
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
|
||||
|
||||
const { reset, control, handleSubmit } = useForm<FormData>({
|
||||
defaultValues: defaultState,
|
||||
resolver: joiResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormData) => {
|
||||
if (isEditMode) {
|
||||
dispatch(editItem({ path: `${path}.items`, value: formData }));
|
||||
} else {
|
||||
dispatch(addItem({ path: `${path}.items`, value: formData }));
|
||||
}
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(
|
||||
setModalState({
|
||||
modal: `builder.${path}`,
|
||||
state: { open: false },
|
||||
})
|
||||
);
|
||||
|
||||
reset(defaultState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEmpty(item)) {
|
||||
reset(item);
|
||||
}
|
||||
}, [item, reset]);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
|
||||
isOpen={isOpen}
|
||||
handleClose={handleClose}
|
||||
heading={isEditMode ? editText : addText}
|
||||
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
|
||||
>
|
||||
<form className="my-2 grid grid-cols-2 gap-4">
|
||||
<Controller
|
||||
name="organization"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
autoFocus
|
||||
label={t('builder.leftSidebar.sections.volunteer.form.organization.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="position"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
label={t('builder.common.form.position.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="date.start"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<DatePicker
|
||||
{...field}
|
||||
openTo="year"
|
||||
label={t('builder.common.form.start-date.label')}
|
||||
views={['year', 'month', 'day']}
|
||||
onChange={(date: Date, keyboardInputValue: string) => {
|
||||
isEmpty(keyboardInputValue) && field.onChange('');
|
||||
dayjs(date).isValid() && field.onChange(date.toISOString());
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || params.inputProps?.placeholder}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="date.end"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<DatePicker
|
||||
{...field}
|
||||
openTo="year"
|
||||
label={t('builder.common.form.end-date.label')}
|
||||
views={['year', 'month', 'day']}
|
||||
onChange={(date: Date, keyboardInputValue: string) => {
|
||||
isEmpty(keyboardInputValue) && field.onChange('');
|
||||
dayjs(date).isValid() && field.onChange(date.toISOString());
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || 'Leave this field blank, if still present'}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="url"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label={t('builder.common.form.url.label')}
|
||||
className="col-span-2"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="summary"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
label={t('builder.common.form.summary.label')}
|
||||
className="col-span-2"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || <MarkdownSupported />}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default VolunteerModal;
|
||||
216
apps/client/modals/builder/sections/WorkModal.tsx
Normal file
216
apps/client/modals/builder/sections/WorkModal.tsx
Normal file
@ -0,0 +1,216 @@
|
||||
import { joiResolver } from '@hookform/resolvers/joi';
|
||||
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
|
||||
import DatePicker from '@mui/lab/DatePicker';
|
||||
import { Button, TextField } from '@mui/material';
|
||||
import { SectionPath, WorkExperience } from '@reactive-resume/schema';
|
||||
import dayjs from 'dayjs';
|
||||
import Joi from 'joi';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import BaseModal from '@/components/shared/BaseModal';
|
||||
import MarkdownSupported from '@/components/shared/MarkdownSupported';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
import { addItem, editItem } from '@/store/resume/resumeSlice';
|
||||
|
||||
type FormData = WorkExperience;
|
||||
|
||||
const path: SectionPath = 'sections.work';
|
||||
|
||||
const defaultState: FormData = {
|
||||
name: '',
|
||||
position: '',
|
||||
date: {
|
||||
start: '',
|
||||
end: '',
|
||||
},
|
||||
url: '',
|
||||
summary: '',
|
||||
};
|
||||
|
||||
const schema = Joi.object<FormData>().keys({
|
||||
id: Joi.string(),
|
||||
name: Joi.string().required(),
|
||||
position: Joi.string().required(),
|
||||
date: Joi.object().keys({
|
||||
start: Joi.string().allow(''),
|
||||
end: Joi.string().allow(''),
|
||||
}),
|
||||
url: Joi.string()
|
||||
.pattern(/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/, { name: 'valid URL' })
|
||||
.allow(''),
|
||||
summary: Joi.string().allow(''),
|
||||
});
|
||||
|
||||
const WorkModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
|
||||
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
|
||||
|
||||
const item: FormData = get(payload, 'item', null);
|
||||
const isEditMode = useMemo(() => !!item, [item]);
|
||||
|
||||
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
|
||||
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
|
||||
|
||||
const { reset, control, handleSubmit } = useForm<FormData>({
|
||||
defaultValues: defaultState,
|
||||
resolver: joiResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormData) => {
|
||||
if (isEditMode) {
|
||||
dispatch(editItem({ path: `${path}.items`, value: formData }));
|
||||
} else {
|
||||
dispatch(addItem({ path: `${path}.items`, value: formData }));
|
||||
}
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(
|
||||
setModalState({
|
||||
modal: `builder.${path}`,
|
||||
state: { open: false },
|
||||
})
|
||||
);
|
||||
|
||||
reset(defaultState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEmpty(item)) {
|
||||
reset(item);
|
||||
}
|
||||
}, [item, reset]);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
|
||||
isOpen={isOpen}
|
||||
handleClose={handleClose}
|
||||
heading={isEditMode ? editText : addText}
|
||||
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
|
||||
>
|
||||
<form className="my-2 grid grid-cols-2 gap-4">
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
autoFocus
|
||||
label={t('builder.common.form.name.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="position"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
required
|
||||
label={t('builder.common.form.position.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="date.start"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<DatePicker
|
||||
{...field}
|
||||
openTo="year"
|
||||
label={t('builder.common.form.start-date.label')}
|
||||
views={['year', 'month', 'day']}
|
||||
onChange={(date: Date, keyboardInputValue: string) => {
|
||||
isEmpty(keyboardInputValue) && field.onChange('');
|
||||
dayjs(date).isValid() && field.onChange(date.toISOString());
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || params.inputProps?.placeholder}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="date.end"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<DatePicker
|
||||
{...field}
|
||||
openTo="year"
|
||||
label={t('builder.common.form.end-date.label')}
|
||||
views={['year', 'month', 'day']}
|
||||
onChange={(date: Date, keyboardInputValue: string) => {
|
||||
isEmpty(keyboardInputValue) && field.onChange('');
|
||||
dayjs(date).isValid() && field.onChange(date.toISOString());
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || t('builder.common.form.end-date.help-text')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="url"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label={t('builder.common.form.url.label')}
|
||||
className="col-span-2"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="summary"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
label={t('builder.common.form.summary.label')}
|
||||
className="col-span-2"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message || <MarkdownSupported />}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkModal;
|
||||
143
apps/client/modals/dashboard/CreateResumeModal.tsx
Normal file
143
apps/client/modals/dashboard/CreateResumeModal.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import { joiResolver } from '@hookform/resolvers/joi';
|
||||
import { Add } from '@mui/icons-material';
|
||||
import { Button, FormControlLabel, FormGroup, Switch, TextField } from '@mui/material';
|
||||
import { Resume } from '@reactive-resume/schema';
|
||||
import Joi from 'joi';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useMutation } from 'react-query';
|
||||
|
||||
import BaseModal from '@/components/shared/BaseModal';
|
||||
import { RESUMES_QUERY } from '@/constants/index';
|
||||
import { ServerError } from '@/services/axios';
|
||||
import queryClient from '@/services/react-query';
|
||||
import { createResume, CreateResumeParams } from '@/services/resume';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
|
||||
type FormData = {
|
||||
name: string;
|
||||
slug: string;
|
||||
isPublic: boolean;
|
||||
};
|
||||
|
||||
const defaultState: FormData = {
|
||||
name: '',
|
||||
slug: '',
|
||||
isPublic: true,
|
||||
};
|
||||
|
||||
const schema = Joi.object({
|
||||
name: Joi.string().required(),
|
||||
slug: Joi.string()
|
||||
.lowercase()
|
||||
.min(3)
|
||||
.regex(/^[a-z0-9-]+$/, 'only lowercase characters, numbers and hyphens')
|
||||
.required(),
|
||||
isPublic: Joi.boolean().default(true).required(),
|
||||
});
|
||||
|
||||
const CreateResumeModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { open: isOpen } = useAppSelector((state) => state.modal['dashboard.create-resume']);
|
||||
|
||||
const { mutateAsync, isLoading } = useMutation<Resume, ServerError, CreateResumeParams>(createResume);
|
||||
|
||||
const { reset, watch, control, setValue, handleSubmit } = useForm<FormData>({
|
||||
defaultValues: defaultState,
|
||||
resolver: joiResolver(schema),
|
||||
});
|
||||
const name = watch('name');
|
||||
|
||||
useEffect(() => {
|
||||
const slug = name
|
||||
? name
|
||||
.toLowerCase()
|
||||
.replace(/[`~!@#$%^&*()_|+=?;:'",.<>{}[]\\\/]/gi, '')
|
||||
.replace(/[ ]/gi, '-')
|
||||
: '';
|
||||
|
||||
setValue('slug', slug);
|
||||
}, [name, setValue]);
|
||||
|
||||
const onSubmit = async ({ name, slug, isPublic }: FormData) => {
|
||||
try {
|
||||
await mutateAsync({ name, slug, public: isPublic });
|
||||
|
||||
queryClient.invalidateQueries(RESUMES_QUERY);
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(setModalState({ modal: 'dashboard.create-resume', state: { open: false } }));
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isOpen={isOpen}
|
||||
icon={<Add />}
|
||||
heading={t('modals.dashboard.create-resume.heading')}
|
||||
handleClose={handleClose}
|
||||
footerChildren={
|
||||
<Button type="submit" disabled={isLoading} onClick={handleSubmit(onSubmit)}>
|
||||
{t('modals.dashboard.create-resume.actions.create-resume')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p>{t('modals.dashboard.create-resume.body')}</p>
|
||||
|
||||
<form className="grid gap-4">
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
autoFocus
|
||||
label={t('modals.dashboard.create-resume.form.name.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="slug"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label={t('modals.dashboard.create-resume.form.slug.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormGroup>
|
||||
<FormControlLabel
|
||||
label={t('modals.dashboard.create-resume.form.public.label') as string}
|
||||
control={
|
||||
<Controller
|
||||
name="isPublic"
|
||||
control={control}
|
||||
render={({ field }) => <Switch defaultChecked color="secondary" {...field} />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormGroup>
|
||||
</form>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateResumeModal;
|
||||
197
apps/client/modals/dashboard/ImportExternalModal.tsx
Normal file
197
apps/client/modals/dashboard/ImportExternalModal.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
import { Code, ImportExport, LinkedIn, TrackChanges, UploadFile } from '@mui/icons-material';
|
||||
import { Button, Divider } from '@mui/material';
|
||||
import { Integration, Resume } from '@reactive-resume/schema';
|
||||
import { Trans, useTranslation } from 'next-i18next';
|
||||
import { useRef } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useMutation } from 'react-query';
|
||||
|
||||
import BaseModal from '@/components/shared/BaseModal';
|
||||
import { RESUMES_QUERY } from '@/constants/index';
|
||||
import { ServerError } from '@/services/axios';
|
||||
import { importFromExternal, ImportFromExternalParams } from '@/services/integrations';
|
||||
import queryClient from '@/services/react-query';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setModalState } from '@/store/modal/modalSlice';
|
||||
|
||||
const FILE_UPLOAD_MAX_SIZE = 2000000; // 2 MB
|
||||
|
||||
const ImportExternalModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const linkedinInputRef = useRef<HTMLInputElement>(null);
|
||||
const jsonResumeInputRef = useRef<HTMLInputElement>(null);
|
||||
const reactiveResumeInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { open: isOpen } = useAppSelector((state) => state.modal['dashboard.import-external']);
|
||||
|
||||
const { mutateAsync, isLoading } = useMutation<Resume, ServerError, ImportFromExternalParams>(importFromExternal);
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(setModalState({ modal: 'dashboard.import-external', state: { open: false } }));
|
||||
};
|
||||
|
||||
const handleClick = (integration: Integration) => {
|
||||
if (integration === 'linkedin') {
|
||||
if (linkedinInputRef.current) {
|
||||
linkedinInputRef.current.click();
|
||||
linkedinInputRef.current.value = '';
|
||||
}
|
||||
} else if (integration === 'json-resume') {
|
||||
if (jsonResumeInputRef.current) {
|
||||
jsonResumeInputRef.current.click();
|
||||
jsonResumeInputRef.current.value = '';
|
||||
}
|
||||
} else if (integration === 'reactive-resume') {
|
||||
if (reactiveResumeInputRef.current) {
|
||||
reactiveResumeInputRef.current.click();
|
||||
reactiveResumeInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>, integration: Integration) => {
|
||||
if (event.target.files && event.target.files[0]) {
|
||||
const file = event.target.files[0];
|
||||
|
||||
if (file.size > FILE_UPLOAD_MAX_SIZE) {
|
||||
toast.error(t('common.toast.error.upload-file-size'));
|
||||
return;
|
||||
}
|
||||
|
||||
await mutateAsync({ integration, file });
|
||||
|
||||
queryClient.invalidateQueries(RESUMES_QUERY);
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isOpen={isOpen}
|
||||
icon={<ImportExport />}
|
||||
heading={t('modals.dashboard.import-external.heading')}
|
||||
handleClose={handleClose}
|
||||
>
|
||||
<div className="grid gap-5">
|
||||
<h2 className="inline-flex items-center gap-2 text-lg font-medium">
|
||||
<LinkedIn />
|
||||
{t('modals.dashboard.import-external.linkedin.heading')}
|
||||
</h2>
|
||||
|
||||
<p className="mb-2">
|
||||
<Trans t={t} i18nKey="modals.dashboard.import-external.linkedin.body">
|
||||
You can save time by exporting your data from LinkedIn and using it to auto-fill fields on Reactive Resume.
|
||||
Head on over to the
|
||||
<a
|
||||
href="https://www.linkedin.com/psettings/member-data"
|
||||
className="underline"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Data Privacy
|
||||
</a>
|
||||
section on LinkedIn and request an archive of your data. Once it is available, upload the ZIP archive below.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={isLoading}
|
||||
startIcon={<UploadFile />}
|
||||
onClick={() => handleClick('linkedin')}
|
||||
>
|
||||
{t('modals.dashboard.import-external.linkedin.actions.upload-archive')}
|
||||
</Button>
|
||||
|
||||
<input
|
||||
hidden
|
||||
type="file"
|
||||
ref={linkedinInputRef}
|
||||
onChange={(event) => handleChange(event, 'linkedin')}
|
||||
accept="application/zip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider className="py-2" />
|
||||
|
||||
<div className="grid gap-5">
|
||||
<h2 className="inline-flex items-center gap-2 text-lg font-medium">
|
||||
<Code />
|
||||
{t('modals.dashboard.import-external.json-resume.heading')}
|
||||
</h2>
|
||||
|
||||
<p className="mb-2">
|
||||
<Trans t={t} i18nKey="modals.dashboard.import-external.json-resume.body">
|
||||
If you have a
|
||||
<a
|
||||
href="https://github.com/jsonresume/resume-schema"
|
||||
className="underline"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
validated JSON Resume
|
||||
</a>
|
||||
ready to go, you can use it to fast-track your development on Reactive Resume. Click the button below and
|
||||
upload a valid JSON file to begin.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={isLoading}
|
||||
startIcon={<UploadFile />}
|
||||
onClick={() => handleClick('json-resume')}
|
||||
>
|
||||
{t('modals.dashboard.import-external.json-resume.actions.upload-json')}
|
||||
</Button>
|
||||
|
||||
<input
|
||||
hidden
|
||||
type="file"
|
||||
ref={jsonResumeInputRef}
|
||||
onChange={(event) => handleChange(event, 'json-resume')}
|
||||
accept="application/json"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider className="py-2" />
|
||||
|
||||
<div className="grid gap-5">
|
||||
<h2 className="inline-flex items-center gap-2 text-lg font-medium">
|
||||
<TrackChanges />
|
||||
{t('modals.dashboard.import-external.reactive-resume.heading')}
|
||||
</h2>
|
||||
|
||||
<p className="mb-2">{t('modals.dashboard.import-external.reactive-resume.body')}</p>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={isLoading}
|
||||
startIcon={<UploadFile />}
|
||||
onClick={() => handleClick('reactive-resume')}
|
||||
>
|
||||
{t('modals.dashboard.import-external.reactive-resume.actions.upload-json')}
|
||||
</Button>
|
||||
|
||||
<input
|
||||
hidden
|
||||
type="file"
|
||||
ref={reactiveResumeInputRef}
|
||||
onChange={(event) => handleChange(event, 'reactive-resume')}
|
||||
accept="application/json"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportExternalModal;
|
||||
135
apps/client/modals/dashboard/RenameResumeModal.tsx
Normal file
135
apps/client/modals/dashboard/RenameResumeModal.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import { joiResolver } from '@hookform/resolvers/joi';
|
||||
import { DriveFileRenameOutline } from '@mui/icons-material';
|
||||
import { Button, TextField } from '@mui/material';
|
||||
import { Resume } from '@reactive-resume/schema';
|
||||
import Joi from 'joi';
|
||||
import get from 'lodash/get';
|
||||
import noop from 'lodash/noop';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useMutation } from 'react-query';
|
||||
|
||||
import BaseModal from '@/components/shared/BaseModal';
|
||||
import { RESUMES_QUERY } from '@/constants/index';
|
||||
import { ServerError } from '@/services/axios';
|
||||
import queryClient from '@/services/react-query';
|
||||
import { renameResume, RenameResumeParams } from '@/services/resume';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { ModalState, setModalState } from '@/store/modal/modalSlice';
|
||||
|
||||
type FormData = {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
const schema = Joi.object({
|
||||
name: Joi.string().required(),
|
||||
slug: Joi.string()
|
||||
.lowercase()
|
||||
.min(3)
|
||||
.regex(/^[a-z0-9-]+$/, 'only lowercase characters, numbers and hyphens')
|
||||
.required(),
|
||||
});
|
||||
|
||||
const RenameResumeModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { open: isOpen, payload } = useAppSelector((state) => state.modal['dashboard.rename-resume']) as ModalState;
|
||||
const resume: Resume = get(payload, 'item') as Resume;
|
||||
const onComplete = get(payload, 'onComplete', noop);
|
||||
|
||||
const { mutateAsync, isLoading } = useMutation<Resume, ServerError, RenameResumeParams>(renameResume);
|
||||
|
||||
const { reset, watch, control, setValue, handleSubmit } = useForm<FormData>({
|
||||
defaultValues: {
|
||||
name: resume?.name,
|
||||
slug: resume?.slug,
|
||||
},
|
||||
resolver: joiResolver(schema),
|
||||
});
|
||||
const name = watch('name');
|
||||
|
||||
useEffect(() => {
|
||||
const slug = name
|
||||
? name
|
||||
.toLowerCase()
|
||||
.replace(/[`~!@#$%^&*()_|+=?;:'",.<>{}[]\\\/]/gi, '')
|
||||
.replace(/[ ]/gi, '-')
|
||||
: '';
|
||||
|
||||
setValue('slug', slug);
|
||||
}, [name, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resume) return;
|
||||
|
||||
const { name, slug }: FormData = resume;
|
||||
|
||||
reset({ name, slug });
|
||||
}, [resume, reset]);
|
||||
|
||||
const onSubmit = async ({ name, slug }: FormData) => {
|
||||
if (!resume) return;
|
||||
|
||||
const newResume = await mutateAsync({ id: resume.id, name, slug });
|
||||
|
||||
onComplete && onComplete(newResume);
|
||||
|
||||
queryClient.invalidateQueries(RESUMES_QUERY);
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(setModalState({ modal: 'dashboard.rename-resume', state: { open: false } }));
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
icon={<DriveFileRenameOutline />}
|
||||
isOpen={isOpen}
|
||||
heading={t('modals.dashboard.rename-resume.heading')}
|
||||
handleClose={handleClose}
|
||||
footerChildren={
|
||||
<Button type="submit" disabled={isLoading} onClick={handleSubmit(onSubmit)}>
|
||||
{t('modals.dashboard.rename-resume.actions.rename-resume')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<form className="grid gap-4">
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
autoFocus
|
||||
label={t('modals.dashboard.rename-resume.form.name.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="slug"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label={t('modals.dashboard.rename-resume.form.slug.label')}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RenameResumeModal;
|
||||
79
apps/client/modals/index.tsx
Normal file
79
apps/client/modals/index.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useAppDispatch } from '@/store/hooks';
|
||||
import { ModalName, setModalState } from '@/store/modal/modalSlice';
|
||||
|
||||
import ForgotPasswordModal from './auth/ForgotPasswordModal';
|
||||
import LoginModal from './auth/LoginModal';
|
||||
import RegisterModal from './auth/RegisterModal';
|
||||
import ResetPasswordModal from './auth/ResetPasswordModal';
|
||||
import AwardModal from './builder/sections/AwardModal';
|
||||
import CertificateModal from './builder/sections/CertificateModal';
|
||||
import CustomModal from './builder/sections/CustomModal';
|
||||
import EducationModal from './builder/sections/EducationModal';
|
||||
import InterestModal from './builder/sections/InterestModal';
|
||||
import LanguageModal from './builder/sections/LanguageModal';
|
||||
import ProfileModal from './builder/sections/ProfileModal';
|
||||
import ProjectModal from './builder/sections/ProjectModal';
|
||||
import PublicationModal from './builder/sections/PublicationModal';
|
||||
import ReferenceModal from './builder/sections/ReferenceModal';
|
||||
import SkillModal from './builder/sections/SkillModal';
|
||||
import VolunteerModal from './builder/sections/VolunteerModal';
|
||||
import WorkModal from './builder/sections/WorkModal';
|
||||
import CreateResumeModal from './dashboard/CreateResumeModal';
|
||||
import ImportExternalModal from './dashboard/ImportExternalModal';
|
||||
import RenameResumeModal from './dashboard/RenameResumeModal';
|
||||
|
||||
type QueryParams = {
|
||||
modal?: ModalName;
|
||||
};
|
||||
|
||||
const ModalWrapper: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
const { modal, ...rest } = router.query as QueryParams;
|
||||
|
||||
if (!modal) return;
|
||||
|
||||
dispatch(setModalState({ modal, state: { open: true, payload: rest } }));
|
||||
}, [router.query, dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Authentication */}
|
||||
<LoginModal />
|
||||
<RegisterModal />
|
||||
<ForgotPasswordModal />
|
||||
<ResetPasswordModal />
|
||||
|
||||
{/* Dashboard */}
|
||||
<CreateResumeModal />
|
||||
<ImportExternalModal />
|
||||
<RenameResumeModal />
|
||||
|
||||
{/* Builder */}
|
||||
|
||||
{/* Sections */}
|
||||
<ProfileModal />
|
||||
<WorkModal />
|
||||
<EducationModal />
|
||||
<AwardModal />
|
||||
<CertificateModal />
|
||||
<PublicationModal />
|
||||
<SkillModal />
|
||||
<LanguageModal />
|
||||
<InterestModal />
|
||||
<VolunteerModal />
|
||||
<ProjectModal />
|
||||
<ReferenceModal />
|
||||
|
||||
{/* Custom Sections */}
|
||||
<CustomModal />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalWrapper;
|
||||
5
apps/client/next-env.d.ts
vendored
Normal file
5
apps/client/next-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
14
apps/client/next-i18next.config.js
Normal file
14
apps/client/next-i18next.config.js
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* @type {import('next-i18next').UserConfig}
|
||||
**/
|
||||
module.exports = {
|
||||
i18n: {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en'],
|
||||
},
|
||||
debug: false,
|
||||
nsSeparator: '.',
|
||||
initImmediate: false,
|
||||
localePath: './apps/client/public/locales',
|
||||
ns: ['common', 'modals', 'landing', 'dashboard', 'builder'],
|
||||
};
|
||||
53
apps/client/next.config.js
Normal file
53
apps/client/next.config.js
Normal file
@ -0,0 +1,53 @@
|
||||
const withPlugins = require('next-compose-plugins');
|
||||
const withNx = require('@nrwl/next/plugins/with-nx');
|
||||
const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true' });
|
||||
|
||||
const { i18n } = require('./next-i18next.config');
|
||||
|
||||
/**
|
||||
* @type {import('@nrwl/next/plugins/with-nx').WithNxOptions}
|
||||
**/
|
||||
const nextConfig = {
|
||||
i18n,
|
||||
|
||||
nx: {
|
||||
svgr: false,
|
||||
},
|
||||
|
||||
images: {
|
||||
domains: ['localhost', 'www.gravatar.com'],
|
||||
},
|
||||
|
||||
// Hack to make Tailwind darkMode 'class' strategy with CSS Modules
|
||||
// Ref: https://github.com/tailwindlabs/tailwindcss/issues/3258#issuecomment-968368156
|
||||
webpack: (config) => {
|
||||
const rules = config.module.rules.find((r) => !!r.oneOf);
|
||||
|
||||
rules.oneOf.forEach((loaders) => {
|
||||
if (Array.isArray(loaders.use)) {
|
||||
loaders.use.forEach((l) => {
|
||||
if (typeof l !== 'string' && typeof l.loader === 'string' && /(?<!post)css-loader/.test(l.loader)) {
|
||||
if (!l.options.modules) return;
|
||||
const { getLocalIdent, ...others } = l.options.modules;
|
||||
|
||||
l.options = {
|
||||
...l.options,
|
||||
modules: {
|
||||
...others,
|
||||
getLocalIdent: (ctx, localIdentName, localName, options) => {
|
||||
if (localName === 'dark') return localName;
|
||||
|
||||
return getLocalIdent(ctx, localIdentName, localName, options);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = withPlugins([withNx, withBundleAnalyzer], nextConfig);
|
||||
78
apps/client/pages/[username]/[slug]/build.tsx
Normal file
78
apps/client/pages/[username]/[slug]/build.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { Resume } from '@reactive-resume/schema';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { GetServerSideProps, NextPage } from 'next';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Head from 'next/head';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import i18nConfig from 'next-i18next.config';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import { fetchResumeByIdentifier } from '@/services/resume';
|
||||
import { useAppDispatch } from '@/store/hooks';
|
||||
import { setResume } from '@/store/resume/resumeSlice';
|
||||
import styles from '@/styles/pages/Build.module.scss';
|
||||
|
||||
const Center = dynamic(() => import('@/components/build/Center/Center'));
|
||||
const LeftSidebar = dynamic(() => import('@/components/build/LeftSidebar/LeftSidebar'));
|
||||
const RightSidebar = dynamic(() => import('@/components/build/RightSidebar/RightSidebar'));
|
||||
|
||||
type QueryParams = {
|
||||
username: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
username: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<Props> = async ({ query, locale }) => {
|
||||
const { username, slug } = query as QueryParams;
|
||||
|
||||
return {
|
||||
props: { username, slug, ...(await serverSideTranslations(locale, ['common', 'modals', 'builder'], i18nConfig)) },
|
||||
};
|
||||
};
|
||||
|
||||
const Build: NextPage<Props> = ({ username, slug }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { data: resume } = useQuery<Resume>(
|
||||
`resume/${username}/${slug}`,
|
||||
() => fetchResumeByIdentifier({ username, slug }),
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
onSuccess: (resume) => {
|
||||
dispatch(setResume(resume));
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (resume) dispatch(setResume(resume));
|
||||
}, [resume, dispatch]);
|
||||
|
||||
if (isEmpty(resume)) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Head>
|
||||
<title>
|
||||
{resume.name} | {t('common.title')}
|
||||
</title>
|
||||
</Head>
|
||||
|
||||
<LeftSidebar />
|
||||
<Center />
|
||||
<RightSidebar />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Build;
|
||||
131
apps/client/pages/[username]/[slug]/index.tsx
Normal file
131
apps/client/pages/[username]/[slug]/index.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import { Download, Downloading } from '@mui/icons-material';
|
||||
import { ButtonBase } from '@mui/material';
|
||||
import { Resume } from '@reactive-resume/schema';
|
||||
import clsx from 'clsx';
|
||||
import download from 'downloadjs';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { GetServerSideProps, NextPage } from 'next';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import i18nConfig from 'next-i18next.config';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import { useEffect } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
|
||||
import { ServerError } from '@/services/axios';
|
||||
import { printResumeAsPdf, PrintResumeAsPdfParams } from '@/services/printer';
|
||||
import { fetchResumeByIdentifier } from '@/services/resume';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setResume } from '@/store/resume/resumeSlice';
|
||||
import styles from '@/styles/pages/Preview.module.scss';
|
||||
|
||||
const Page = dynamic(() => import('@/components/build/Center/Page'));
|
||||
|
||||
type QueryParams = {
|
||||
slug?: string;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
slug?: string;
|
||||
resume?: Resume;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<Props> = async ({ query, locale }) => {
|
||||
const { username, slug } = query as QueryParams;
|
||||
|
||||
try {
|
||||
const resume = await fetchResumeByIdentifier({ username, slug, options: { withHost: true } });
|
||||
|
||||
return {
|
||||
props: { username, slug, resume, ...(await serverSideTranslations(locale, ['common'], i18nConfig)) },
|
||||
};
|
||||
} catch {
|
||||
return { props: { username, slug, ...(await serverSideTranslations(locale, ['common'], i18nConfig)) } };
|
||||
}
|
||||
};
|
||||
|
||||
const Preview: NextPage<Props> = ({ username, slug, resume: initialData }) => {
|
||||
const router = useRouter();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const resume = useAppSelector((state) => state.resume);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEmpty(initialData)) {
|
||||
dispatch(setResume(initialData));
|
||||
}
|
||||
}, [dispatch, initialData]);
|
||||
|
||||
useQuery<Resume>(`resume/${username}/${slug}`, () => fetchResumeByIdentifier({ username, slug }), {
|
||||
initialData,
|
||||
retry: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
onSuccess: (data) => {
|
||||
dispatch(setResume(data));
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorObj = JSON.parse(JSON.stringify(error));
|
||||
const statusCode: number = get(errorObj, 'status', 404);
|
||||
|
||||
if (statusCode === 404) {
|
||||
toast.error('The resume you were looking for does not exist, or maybe it never did?');
|
||||
|
||||
router.push('/');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync, isLoading } = useMutation<string, ServerError, PrintResumeAsPdfParams>(printResumeAsPdf);
|
||||
|
||||
if (isEmpty(resume)) return null;
|
||||
|
||||
const layout: string[][][] = get(resume, 'metadata.layout', []);
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const url = await mutateAsync({ username, slug });
|
||||
|
||||
download(url);
|
||||
} catch {
|
||||
toast.error('Something went wrong, please try again later.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx('preview-mode', styles.container)}>
|
||||
{layout.map((_, pageIndex) => (
|
||||
<Page key={pageIndex} page={pageIndex} />
|
||||
))}
|
||||
|
||||
<div className={clsx(styles.download, { 'opacity-75': isLoading })}>
|
||||
<ButtonBase onClick={handleDownload} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Downloading />
|
||||
<h4>Please wait</h4>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download />
|
||||
<h4>Download PDF</h4>
|
||||
</>
|
||||
)}
|
||||
</ButtonBase>
|
||||
</div>
|
||||
|
||||
<p className={styles.footer}>
|
||||
Made with <Link href="/">Reactive Resume</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Preview;
|
||||
69
apps/client/pages/[username]/[slug]/printer.tsx
Normal file
69
apps/client/pages/[username]/[slug]/printer.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { Resume } from '@reactive-resume/schema';
|
||||
import clsx from 'clsx';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { GetServerSideProps, NextPage } from 'next';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { fetchResumeByIdentifier } from '@/services/resume';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setResume } from '@/store/resume/resumeSlice';
|
||||
import styles from '@/styles/pages/Printer.module.scss';
|
||||
|
||||
const Page = dynamic(() => import('@/components/build/Center/Page'));
|
||||
|
||||
type QueryParams = {
|
||||
slug?: string;
|
||||
username?: string;
|
||||
secretKey?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
slug?: string;
|
||||
resume?: Resume;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<Props> = async ({ query }) => {
|
||||
const { username, slug, secretKey } = query as QueryParams;
|
||||
|
||||
try {
|
||||
if (isEmpty(secretKey)) throw new Error('There is no secret key!');
|
||||
|
||||
const resume = await fetchResumeByIdentifier({ username, slug, options: { secretKey, withHost: true } });
|
||||
|
||||
return { props: { resume } };
|
||||
} catch (error) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: '/',
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Printer: NextPage<Props> = ({ resume: initialData }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const resume = useAppSelector((state) => state.resume);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) dispatch(setResume(initialData));
|
||||
}, [dispatch, initialData]);
|
||||
|
||||
if (isEmpty(resume)) return null;
|
||||
|
||||
const layout: string[][][] = get(resume, 'metadata.layout', []);
|
||||
|
||||
return (
|
||||
<div className={clsx('printer-mode', styles.container)}>
|
||||
{layout.map((_, pageIndex) => (
|
||||
<Page key={pageIndex} page={pageIndex} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Printer;
|
||||
68
apps/client/pages/_app.tsx
Normal file
68
apps/client/pages/_app.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import '@/styles/globals.scss';
|
||||
|
||||
import DateAdapter from '@mui/lab/AdapterDayjs';
|
||||
import LocalizationProvider from '@mui/lab/LocalizationProvider';
|
||||
import type { AppProps } from 'next/app';
|
||||
import Head from 'next/head';
|
||||
import { appWithTranslation, useTranslation } from 'next-i18next';
|
||||
import i18nConfig from 'next-i18next.config';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { QueryClientProvider } from 'react-query';
|
||||
import { Provider as ReduxProvider } from 'react-redux';
|
||||
import { PersistGate } from 'redux-persist/integration/react';
|
||||
|
||||
import Loading from '@/components/shared/Loading';
|
||||
import ModalWrapper from '@/modals/index';
|
||||
import queryClient from '@/services/react-query';
|
||||
import store, { persistor } from '@/store/index';
|
||||
import WrapperRegistry from '@/wrappers/index';
|
||||
|
||||
export async function getStaticProps({ locale }) {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale, ['common', 'modals'], i18nConfig)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const App: React.FC<AppProps> = ({ Component, pageProps }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{t('common.title')}</title>
|
||||
<meta name="description" content={t('common.description')} />
|
||||
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
</Head>
|
||||
|
||||
<ReduxProvider store={store}>
|
||||
<LocalizationProvider dateAdapter={DateAdapter}>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WrapperRegistry>
|
||||
<Loading />
|
||||
|
||||
<Component {...pageProps} />
|
||||
|
||||
<ModalWrapper />
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
className: 'toast',
|
||||
}}
|
||||
/>
|
||||
</WrapperRegistry>
|
||||
</QueryClientProvider>
|
||||
</PersistGate>
|
||||
</LocalizationProvider>
|
||||
</ReduxProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default appWithTranslation(App, i18nConfig);
|
||||
76
apps/client/pages/dashboard.tsx
Normal file
76
apps/client/pages/dashboard.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { Add, ImportExport } from '@mui/icons-material';
|
||||
import type { NextPage } from 'next';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import i18nConfig from 'next-i18next.config';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import { RESUMES_QUERY } from '@/constants/index';
|
||||
import { fetchResumes } from '@/services/resume';
|
||||
import styles from '@/styles/pages/Dashboard.module.scss';
|
||||
|
||||
const ResumeCard = dynamic(() => import('@/components/dashboard/ResumeCard'));
|
||||
const ResumePreview = dynamic(() => import('@/components/dashboard/ResumePreview'));
|
||||
const Avatar = dynamic(() => import('@/components/shared/Avatar'));
|
||||
const Logo = dynamic(() => import('@/components/shared/Logo'));
|
||||
|
||||
export async function getServerSideProps({ locale }) {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale, ['common', 'modals', 'dashboard'], i18nConfig)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const Dashboard: NextPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data } = useQuery(RESUMES_QUERY, fetchResumes);
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Head>
|
||||
<title>
|
||||
{t('dashboard.title')} | {t('common.title')}
|
||||
</title>
|
||||
</Head>
|
||||
|
||||
<header>
|
||||
<Link href="/">
|
||||
<a>
|
||||
<Logo size={40} />
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<Avatar size={40} />
|
||||
</header>
|
||||
|
||||
<main className={styles.resumes}>
|
||||
<ResumeCard
|
||||
modal="dashboard.create-resume"
|
||||
icon={Add}
|
||||
title={t('dashboard.create-resume.title')}
|
||||
subtitle={t('dashboard.create-resume.subtitle')}
|
||||
/>
|
||||
|
||||
<ResumeCard
|
||||
modal="dashboard.import-external"
|
||||
icon={ImportExport}
|
||||
title={t('dashboard.import-external.title')}
|
||||
subtitle={t('dashboard.import-external.subtitle')}
|
||||
/>
|
||||
|
||||
{data.map((resume) => (
|
||||
<ResumePreview key={resume.id} resume={resume} />
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user