🚀 release v3.0.0

This commit is contained in:
Amruth Pillai
2022-03-06 22:48:29 +01:00
parent 00505a9e5d
commit 9c1380f401
373 changed files with 12050 additions and 15783 deletions

View File

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

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

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

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

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

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

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

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

View File

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

View File

@ -0,0 +1,126 @@
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);
if (section) {
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<string>(`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;

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

View File

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

View File

@ -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 | 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 | 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<string>('builder.leftSidebar.sections.basics.photo-filters.effects.grayscale.label')}
control={
<Checkbox color="secondary" checked={grayscale} onChange={(_, value) => handleSetGrayscale(value)} />
}
/>
<FormControlLabel
label={t<string>('builder.leftSidebar.sections.basics.photo-filters.effects.border.label')}
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;

View File

@ -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<string>('builder.leftSidebar.sections.basics.photo-upload.tooltip.upload')
: t<string>('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;

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,86 @@
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}`);
if (section) {
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<string>(`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.appVersion}</div>
</footer>
</main>
</div>
</SwipeableDrawer>
);
};
export default RightSidebar;

View File

@ -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 | undefined) => {
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;

View File

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

View File

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

View File

@ -0,0 +1,143 @@
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<string>('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<string>('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;

View File

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

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

View File

@ -0,0 +1,199 @@
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 | null) =>
dispatch(setResumeState({ path: 'metadata.date.format', value }));
const handleChangeLanguage = (value: Language | null) =>
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;

View File

@ -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<string>('builder.rightSidebar.sections.sharing.short-url.label')}
control={
<Checkbox className="mr-1" checked={showShortUrl} onChange={(_, value) => setShowShortUrl(value)} />
}
/>
</div>
</ListItem>
</List>
</>
);
};
export default Sharing;

View File

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

View File

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

View File

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

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

View File

@ -0,0 +1,11 @@
.container {
@apply grid gap-4 xl:grid-cols-2;
}
.subheading {
@apply mt-2 font-medium;
}
.slider {
@apply px-6;
}

View File

@ -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 | number[] | Font | null) => {
if (!value) return;
dispatch(
setResumeState({
path: `metadata.typography.${property}.${category}`,
value: property === 'family' ? (value as Font).family : value,
})
);
};
if (!fonts || 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 | 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 | null) => 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;

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

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

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

View 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<string>('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<string>('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;

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

View 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 = {
label: string;
value: string[];
className?: string;
onChange: (event: any) => void;
errors?: FieldError | FieldError[];
};
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;

View File

@ -0,0 +1,3 @@
.avatar {
@apply cursor-pointer rounded-full;
}

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

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

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

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

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

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

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

View 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<string>('builder.common.tooltip.rename-section')}>
<IconButton onClick={toggleEditMode}>{editMode ? <Check /> : <DriveFileRenameOutline />}</IconButton>
</Tooltip>
)}
{isHideable && (
<Tooltip title={t<string>('builder.common.tooltip.toggle-visibility')}>
<IconButton onClick={toggleVisibility}>{visibility ? <Visibility /> : <VisibilityOff />}</IconButton>
</Tooltip>
)}
{isDeletable && (
<Tooltip title={t<string>('builder.common.tooltip.delete-section')}>
<IconButton onClick={handleDelete}>
<Delete />
</IconButton>
</Tooltip>
)}
{action}
</div>
</div>
);
};
export default Heading;

View File

@ -0,0 +1,7 @@
.container {
@apply rounded-lg border dark:border-neutral-50/10;
.empty {
@apply py-8 text-center;
}
}

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

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

View 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<DragItem, any, any>({
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;

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

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

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

View File

@ -0,0 +1,21 @@
import clsx from 'clsx';
import { isEmpty } from 'lodash';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
type Props = {
children?: string;
className?: string;
};
const Markdown: React.FC<Props> = ({ className, children }) => {
if (!children || isEmpty(children)) return null;
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} className={clsx('markdown', className)}>
{children}
</ReactMarkdown>
);
};
export default Markdown;

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

View File

@ -0,0 +1,5 @@
import dynamic from 'next/dynamic';
const NoSSR: React.FC = ({ children }) => <>{children}</>;
export default dynamic(() => Promise.resolve(NoSSR), { ssr: false });

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

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