mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-15 09:11:57 +10:00
🚀 release v3.0.0
This commit is contained in:
@ -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;
|
||||
}
|
||||
}
|
||||
141
client/components/build/Center/ArtboardController.tsx
Normal file
141
client/components/build/Center/ArtboardController.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import {
|
||||
AlignHorizontalCenter,
|
||||
AlignVerticalCenter,
|
||||
Download,
|
||||
FilterCenterFocus,
|
||||
InsertPageBreak,
|
||||
Link,
|
||||
ViewSidebar,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from '@mui/icons-material';
|
||||
import { ButtonBase, Divider, Tooltip, useMediaQuery, useTheme } 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 theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const resume = useAppSelector((state) => state.resume);
|
||||
const isDesktop = useMediaQuery(theme.breakpoints.up('sm'));
|
||||
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<string>('builder.controller.tooltip.zoom-in')}>
|
||||
<ButtonBase onClick={() => zoomIn(0.25)}>
|
||||
<ZoomIn fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip arrow placement="top" title={t<string>('builder.controller.tooltip.zoom-out')}>
|
||||
<ButtonBase onClick={() => zoomOut(0.25)}>
|
||||
<ZoomOut fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip arrow placement="top" title={t<string>('builder.controller.tooltip.center-artboard')}>
|
||||
<ButtonBase onClick={() => centerView(0.95)}>
|
||||
<FilterCenterFocus fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Divider />
|
||||
|
||||
{isDesktop && (
|
||||
<>
|
||||
<Tooltip arrow placement="top" title={t<string>('builder.controller.tooltip.toggle-orientation')}>
|
||||
<ButtonBase onClick={handleTogglePageOrientation}>
|
||||
{orientation === 'vertical' ? (
|
||||
<AlignHorizontalCenter fontSize="medium" />
|
||||
) : (
|
||||
<AlignVerticalCenter fontSize="medium" />
|
||||
)}
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip arrow placement="top" title={t<string>('builder.controller.tooltip.toggle-page-break-line')}>
|
||||
<ButtonBase onClick={handleTogglePageBreakLine}>
|
||||
<InsertPageBreak fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip arrow placement="top" title={t<string>('builder.controller.tooltip.toggle-sidebars')}>
|
||||
<ButtonBase onClick={handleToggleSidebar}>
|
||||
<ViewSidebar fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltip arrow placement="top" title={t<string>('builder.controller.tooltip.copy-link')}>
|
||||
<ButtonBase onClick={handleCopyLink}>
|
||||
<Link fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip arrow placement="top" title={t<string>('builder.controller.tooltip.export-pdf')}>
|
||||
<ButtonBase onClick={handleExportPDF} disabled={isLoading}>
|
||||
<Download fontSize="medium" />
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArtboardController;
|
||||
13
client/components/build/Center/Center.module.scss
Normal file
13
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
client/components/build/Center/Center.tsx
Normal file
57
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
client/components/build/Center/Header.module.scss
Normal file
25
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
client/components/build/Center/Header.tsx
Normal file
216
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<string>('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<string>('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
client/components/build/Center/Page.module.scss
Normal file
34
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
client/components/build/Center/Page.tsx
Normal file
59
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> | null = useMemo(() => templateMap[template].component, [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;
|
||||
Reference in New Issue
Block a user