🚀 release: v3.0.0

This commit is contained in:
Amruth Pillai 2022-03-02 17:44:11 +01:00
parent 2175256310
commit 295172687b
No known key found for this signature in database
GPG Key ID: E3C57DF9B80855AD
352 changed files with 30932 additions and 0 deletions

8
.changeset/README.md Normal file
View File

@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

11
.changeset/config.json Normal file
View File

@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@1.7.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"ignore": [],
"baseBranch": "v3",
"access": "restricted",
"updateInternalDependencies": "patch"
}

13
.editorconfig Normal file
View File

@ -0,0 +1,13 @@
# Editor configuration, see http://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
max_line_length = off
trim_trailing_whitespace = false

32
.env.example Normal file
View File

@ -0,0 +1,32 @@
# Reactive Resume
TZ=UTC
NODE_ENV=development
SECRET_KEY=change-me
# Public URLs
APP_URL=http://localhost:3000
SERVER_URL=http://localhost:3100
# Database
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USERNAME=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DATABASE=reactive_resume
# Auth
JWT_SECRET=change-me
JWT_EXPIRY_TIME=604800
# Mail
MAIL_HOST=
MAIL_PORT=
MAIL_USERNAME=
MAIL_PASSWORD=
# Google OAuth
GOOGLE_CLIENT_ID=change-me
GOOGLE_CLIENT_SECRET=change-me
# Google Web Fonts
GOOGLE_API_KEY=change-me

58
.eslintrc.json Normal file
View File

@ -0,0 +1,58 @@
{
"root": true,
"ignorePatterns": ["**/*"],
"plugins": ["@nrwl/nx"],
"extends": ["prettier"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {
"@nrwl/nx/enforce-module-boundaries": [
"error",
{
"enforceBuildableLibDependency": true,
"allow": [],
"depConstraints": [
{
"sourceTag": "*",
"onlyDependOnLibsWithTags": ["*"]
}
]
}
]
}
},
{
"files": ["*.ts", "*.tsx"],
"extends": ["plugin:@nrwl/nx/typescript"],
"plugins": ["simple-import-sort", "unused-imports"],
"rules": {
// TypeScript ESLint
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-explicit-any": "off",
// Simple Import Sort
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
// Unused Imports
"no-unused-vars": "off",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "none",
"argsIgnorePattern": "^_"
}
]
}
},
{
"files": ["*.js", "*.jsx"],
"extends": ["plugin:@nrwl/nx/javascript"],
"rules": {}
}
]
}

44
.gitignore vendored Normal file
View File

@ -0,0 +1,44 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist
/tmp
/out-tsc
# dependencies
/node_modules
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db
# Environment Variables
.env
.env.*
!.env.example

5
.husky/pre-commit Executable file
View File

@ -0,0 +1,5 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx changeset version
npm run lint && npm run format

1
.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
lts/*

4
.prettierignore Normal file
View File

@ -0,0 +1,4 @@
# Add files here to ignore them from prettier formatting
/dist
/coverage

4
.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"printWidth": 120,
"singleQuote": true
}

9
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"firsttris.vscode-jest-runner",
"lokalise.i18n-ally",
"nrwl.angular-console"
]
}

13
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,13 @@
{
"css.validate": false,
"editor.codeActionsOnSave": { "source.fixAll.eslint": true },
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.wordWrap": "on",
"i18n-ally.keystyle": "nested",
"i18n-ally.localesPaths": ["apps/client/public/locales"],
"i18n-ally.namespace": true,
"i18n-ally.pathMatcher": "{locale}/{namespaces}.{ext}",
"i18n-ally.sortKeys": true,
"scss.validate": false
}

7
CHANGELOG.md Normal file
View File

@ -0,0 +1,7 @@
# Changelog | Reactive Resume
## 3.0.0
### Major Changes
- Initial Release of Reactive Resume v3

20
LICENSE.md Normal file
View File

@ -0,0 +1,20 @@
Copyright (c) 2020-2022 Amruth Pillai
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

84
README.md Normal file
View File

@ -0,0 +1,84 @@
<img src="https://i.imgur.com/pc8Ingg.png" alt="Reactive Resume" width="256px" height="256px" />
# Reactive Resume
### [Go to App](https://rxresu.me/) | [Sample Resume](https://google.com/)
Reactive Resume is a free and open source resume builder thats built to make the mundane tasks of creating, updating and sharing your resume as easy as 1, 2, 3. With this app, you can create multiple resumes, share them with recruiters through a unique link and print as PDF, all for free, no advertisements, without losing the integrity and privacy of your data.
You have complete control over what goes into your resume, how it looks, what colors, what templates, even the layout in which sections placed. Want a dark mode resume? Its as easy as editing 3 values and youre done. You dont need to wait to see your changes either. Everything you type, everything you change, appears immediately on your resume and gets updated in real time.
## Features
- Free, forever
- No Advertising
- No Tracking (no 🍪s too)
- Sync your data across devices
- Accessible in multiple languages
- Import data from [LinkedIn](https://www.linkedin.com/), [JSON Resume](https://jsonresume.org/)
- Manage multiple resumes with one account
- Open Source (with large community support)
- Send your resume to others with a unique sharable link
- Pick any font from [Google Fonts](https://fonts.google.com/) to use on your resume
- Choose from 6 vibrant templates and more coming soon
- Export your resume to JSON or PDF format with just one click
- Create an account using your email, or just Sign in with Google
- Mix and match colors to any degree, even a dark mode resume?
- Add sections, add pages and change layouts the way you want to
- Tailor-made Backend and Database, isolated from Google, Amazon etc.
- **Oh, and did I mention that it's free?**
## Build from Source
1. Clone the repository locally, or use GitHub Codespaces or CodeSandbox
```
git clone https://github.com/AmruthPillai/Reactive-Resume.git
cd Reactive-Resume
```
2. I prefer to use `pnpm`, but you can use anything to install dependencies (`npm`/`yarn`)
```
pnpm install
```
3. Copy the .env.example files to .env in multiple locations and fill it with values according to your setup
```
cp .env.example .env
cp apps/client/.env.example apps/client/.env
cp apps/server/.env.example apps/server/.env
```
4. Use Docker Compose to create a PostgreSQL instance and a `reactive_resume` database, or feel free to use your own and modify the variables used in `.env`
```
docker-compose up -d
```
5. Run the project and start building!
```
pnpm start
```
## Contributing
Please refer to the project's style and contribution guidelines for submitting pull requests.
In general, this project follows the "fork-and-pull" Git workflow.
1. **Fork** the repo on GitHub
2. **Clone** the project to your own machine
3. **Commit** changes to your own branch
4. **Push** your work back up to your fork
5. Submit a **Pull Request** so that we can review your changes
NOTE: Be sure to merge the latest from `main` before making a pull request!
## License
Reactive Resume is packaged and distributed using the [MIT License](https://choosealicense.com/licenses/mit/) which allows for commercial use, distribution, modification and private use provided that all copies of the software contain the same license and copyright.
_By the community, for the community._
A passion project by [Amruth Pillai](https://amruthpillai.com/)

0
apps/.gitkeep Normal file
View File

2
apps/client/.env.example Normal file
View File

@ -0,0 +1,2 @@
NEXT_PUBLIC_APP_VERSION=$npm_package_version
NEXT_PUBLIC_GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID

View File

@ -0,0 +1,23 @@
{
"extends": ["plugin:@nrwl/nx/react-typescript", "../../.eslintrc.json", "next", "next/core-web-vitals"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {
"@next/next/no-html-link-for-pages": ["error", "apps/client/pages"]
}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
],
"env": {
"jest": true
}
}

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,135 @@
import {
AlignHorizontalCenter,
AlignVerticalCenter,
Download,
FilterCenterFocus,
InsertPageBreak,
Link,
ViewSidebar,
ZoomIn,
ZoomOut,
} from '@mui/icons-material';
import { ButtonBase, Divider, Tooltip } from '@mui/material';
import clsx from 'clsx';
import { get } from 'lodash';
import { useTranslation } from 'next-i18next';
import toast from 'react-hot-toast';
import { useMutation } from 'react-query';
import { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch';
import { ServerError } from '@/services/axios';
import { printResumeAsPdf, PrintResumeAsPdfParams } from '@/services/printer';
import { togglePageBreakLine, togglePageOrientation, toggleSidebar } from '@/store/build/buildSlice';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import getResumeUrl from '@/utils/getResumeUrl';
import styles from './ArtboardController.module.scss';
const ArtboardController: React.FC<ReactZoomPanPinchRef> = ({ zoomIn, zoomOut, centerView }) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const resume = useAppSelector((state) => state.resume);
const { left, right } = useAppSelector((state) => state.build.sidebar);
const orientation = useAppSelector((state) => state.build.page.orientation);
const { mutateAsync, isLoading } = useMutation<string, ServerError, PrintResumeAsPdfParams>(printResumeAsPdf);
const handleTogglePageBreakLine = () => dispatch(togglePageBreakLine());
const handleTogglePageOrientation = () => dispatch(togglePageOrientation());
const handleToggleSidebar = () => {
dispatch(toggleSidebar({ sidebar: 'left' }));
dispatch(toggleSidebar({ sidebar: 'right' }));
};
const handleCopyLink = async () => {
const url = getResumeUrl(resume, { withHost: true });
await navigator.clipboard.writeText(url);
toast.success(t('common.toast.success.resume-link-copied'));
};
const handleExportPDF = async () => {
const download = (await import('downloadjs')).default;
const slug = get(resume, 'slug');
const username = get(resume, 'user.username');
const url = await mutateAsync({ username, slug });
download(url);
};
return (
<div
className={clsx({
[styles.container]: true,
[styles.pushLeft]: left.open,
[styles.pushRight]: right.open,
})}
>
<div className={styles.controller}>
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.zoom-in')}>
<ButtonBase onClick={() => zoomIn(0.25)}>
<ZoomIn fontSize="medium" />
</ButtonBase>
</Tooltip>
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.zoom-out')}>
<ButtonBase onClick={() => zoomOut(0.25)}>
<ZoomOut fontSize="medium" />
</ButtonBase>
</Tooltip>
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.center-artboard')}>
<ButtonBase onClick={() => centerView(0.95)}>
<FilterCenterFocus fontSize="medium" />
</ButtonBase>
</Tooltip>
<Divider />
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.toggle-orientation')}>
<ButtonBase onClick={handleTogglePageOrientation}>
{orientation === 'vertical' ? (
<AlignHorizontalCenter fontSize="medium" />
) : (
<AlignVerticalCenter fontSize="medium" />
)}
</ButtonBase>
</Tooltip>
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.toggle-page-break-line')}>
<ButtonBase onClick={handleTogglePageBreakLine}>
<InsertPageBreak fontSize="medium" />
</ButtonBase>
</Tooltip>
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.toggle-sidebars')}>
<ButtonBase onClick={handleToggleSidebar}>
<ViewSidebar fontSize="medium" />
</ButtonBase>
</Tooltip>
<Divider />
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.copy-link')}>
<ButtonBase onClick={handleCopyLink}>
<Link fontSize="medium" />
</ButtonBase>
</Tooltip>
<Tooltip arrow placement="top" title={t('builder.controller.tooltip.export-pdf')}>
<ButtonBase onClick={handleExportPDF} disabled={isLoading}>
<Download fontSize="medium" />
</ButtonBase>
</Tooltip>
</div>
</div>
);
};
export default ArtboardController;

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('builder.header.menu.tooltips.share-link')}>
<div>
<MenuItem>
<ListItemIcon>
<LinkIcon className="scale-90" />
</ListItemIcon>
<ListItemText>{t('builder.header.menu.share-link')}</ListItemText>
</MenuItem>
</div>
</Tooltip>
)}
<Tooltip arrow placement="right" title={t('builder.header.menu.tooltips.delete')}>
<MenuItem onClick={handleDelete}>
<ListItemIcon>
<Delete className="scale-90" />
</ListItemIcon>
<ListItemText>{t('builder.header.menu.delete')}</ListItemText>
</MenuItem>
</Tooltip>
</Menu>
</div>
<IconButton onClick={toggleRightSidebar}>{right.open ? <ChevronRightIcon /> : <ChevronLeftIcon />}</IconButton>
</Toolbar>
</AppBar>
);
};
export default Header;

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> = useMemo(() => get(templateMap, `${template}.component`, null), [template]);
return (
<div data-page={page + 1} className={styles.container}>
<div
className={clsx({
reset: true,
[styles.page]: true,
[styles.break]: breakLine,
[css(themeCSS)]: true,
[css(typographyCSS)]: true,
[css(customCSS.value)]: customCSS.visible,
})}
>
{TemplatePage && <TemplatePage page={page} />}
</div>
{showPageNumbers && (
<h4 className={styles.pageNumber}>
{t('builder.common.glossary.page')} {page + 1}
</h4>
)}
</div>
);
};
export default Page;

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,124 @@
import { Add, Star } from '@mui/icons-material';
import { Button, Divider, IconButton, SwipeableDrawer, Tooltip, useMediaQuery, useTheme } from '@mui/material';
import { Section as SectionRecord } from '@reactive-resume/schema';
import get from 'lodash/get';
import Link from 'next/link';
import { useTranslation } from 'next-i18next';
import { useMemo } from 'react';
import { validate } from 'uuid';
import Logo from '@/components/shared/Logo';
import { getCustomSections, left } from '@/config/sections';
import { setSidebarState } from '@/store/build/buildSlice';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { addSection } from '@/store/resume/resumeSlice';
import styles from './LeftSidebar.module.scss';
import Section from './sections/Section';
const LeftSidebar = () => {
const theme = useTheme();
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isDesktop = useMediaQuery(theme.breakpoints.up('lg'));
const sections = useAppSelector((state) => state.resume.sections);
const { open } = useAppSelector((state) => state.build.sidebar.left);
const customSections = useMemo(() => getCustomSections(sections), [sections]);
const handleOpen = () => dispatch(setSidebarState({ sidebar: 'left', state: { open: true } }));
const handleClose = () => dispatch(setSidebarState({ sidebar: 'left', state: { open: false } }));
const handleClick = (id: string) => {
const elementId = validate(id) ? `#section-${id}` : `#${id}`;
const section = document.querySelector(elementId);
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const handleAddSection = () => {
const newSection: SectionRecord = {
name: 'Custom Section',
type: 'custom',
visible: true,
columns: 2,
items: [],
};
dispatch(addSection({ value: newSection }));
};
return (
<SwipeableDrawer
open={open}
anchor="left"
onOpen={handleOpen}
onClose={handleClose}
PaperProps={{ className: '!shadow-lg' }}
variant={isDesktop ? 'persistent' : 'temporary'}
>
<div className={styles.container}>
<nav>
<div>
<Link href="/dashboard">
<a className="inline-flex">
<Logo size={40} />
</a>
</Link>
<Divider />
</div>
<div className={styles.sections}>
{left.map(({ id, icon }) => (
<Tooltip
arrow
key={id}
placement="right"
title={get(sections, `${id}.name`, t(`builder.leftSidebar.sections.${id}.heading`))}
>
<IconButton onClick={() => handleClick(id)}>{icon}</IconButton>
</Tooltip>
))}
{customSections.map(({ id }) => (
<Tooltip key={id} title={get(sections, `${id}.name`, '')} placement="right" arrow>
<IconButton onClick={() => handleClick(id)}>
<Star />
</IconButton>
</Tooltip>
))}
</div>
<div />
</nav>
<main>
{left.map(({ id, component }) => (
<section key={id} id={id}>
{component}
</section>
))}
{customSections.map(({ id }) => (
<section key={id} id={`section-${id}`}>
<Section path={`sections.${id}`} isEditable isHideable isDeletable />
</section>
))}
<div className="py-6 text-right">
<Button fullWidth variant="outlined" startIcon={<Add />} onClick={handleAddSection}>
{t('builder.common.actions.add', { token: t('builder.leftSidebar.sections.section.heading') })}
</Button>
</div>
</main>
</div>
</SwipeableDrawer>
);
};
export default LeftSidebar;

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) =>
dispatch(setResumeState({ path: 'basics.photo.filters.size', value: size }));
const handleChangeShape = (shape: PhotoShape) =>
dispatch(setResumeState({ path: 'basics.photo.filters.shape', value: shape }));
const handleSetGrayscale = (value: boolean) =>
dispatch(setResumeState({ path: 'basics.photo.filters.grayscale', value }));
const handleSetBorder = (value: boolean) => dispatch(setResumeState({ path: 'basics.photo.filters.border', value }));
return (
<div className="flex flex-col gap-2 p-5 dark:bg-neutral-800">
<div>
<h4 className="font-medium">{t('builder.leftSidebar.sections.basics.photo-filters.size.heading')}</h4>
<div className="mx-2">
<Slider
min={32}
max={512}
step={2}
marks={[
{ value: 32, label: '32' },
{ value: 128, label: '128' },
{ value: 256, label: '256' },
{ value: 512, label: '512' },
]}
value={size}
onChange={(_, value: number) => handleChangeSize(value)}
/>
</div>
</div>
<Divider />
<div>
<h4 className="font-medium">{t('builder.leftSidebar.sections.basics.photo-filters.effects.heading')}</h4>
<div className="flex items-center">
<FormControlLabel
label={t('builder.leftSidebar.sections.basics.photo-filters.effects.grayscale.label') as string}
control={
<Checkbox color="secondary" checked={grayscale} onChange={(_, value) => handleSetGrayscale(value)} />
}
/>
<FormControlLabel
label={t('builder.leftSidebar.sections.basics.photo-filters.effects.border.label') as string}
control={<Checkbox color="secondary" checked={border} onChange={(_, value) => handleSetBorder(value)} />}
/>
</div>
</div>
<Divider />
<div className="flex flex-col gap-2">
<h4 className="font-medium">{t('builder.leftSidebar.sections.basics.photo-filters.shape.heading')}</h4>
<ToggleButtonGroup exclusive value={shape} onChange={(_, value) => handleChangeShape(value)}>
<ToggleButton size="small" value="square" className="w-14">
<Square fontSize="small" />
</ToggleButton>
<ToggleButton size="small" value="rounded-square" className="w-14">
<SquareRounded fontSize="small" />
</ToggleButton>
<ToggleButton size="small" value="circle" className="w-14">
<Circle fontSize="small" />
</ToggleButton>
</ToggleButtonGroup>
</div>
</div>
);
};
export default PhotoFilters;

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('builder.leftSidebar.sections.basics.photo-upload.tooltip.upload')
: t('builder.leftSidebar.sections.basics.photo-upload.tooltip.remove')
}
>
<Avatar sx={{ width: 96, height: 96 }} src={photo.url} />
</Tooltip>
)}
<input hidden type="file" ref={fileInputRef} onChange={handleChange} accept="image/*" />
</IconButton>
);
};
export default PhotoUpload;

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);
const modal: ModalName = validate(id) ? 'builder.sections.custom' : `builder.${path}`;
dispatch(setModalState({ modal, state: { open: true, payload: { path } } }));
};
const handleEdit = (item: ListItem) => {
const id = path.split('.').at(-1);
const modal: ModalName = validate(id) ? 'builder.sections.custom' : `builder.${path}`;
const payload = validate(id) ? { path, item } : { item };
dispatch(setModalState({ modal, state: { open: true, payload } }));
};
const handleDuplicate = (item: ListItem) => dispatch(duplicateItem({ path: `${path}.items`, value: item }));
return (
<>
<Heading path={path} name={name} isEditable={isEditable} isHideable={isHideable} isDeletable={isDeletable} />
<List
path={`${path}.items`}
titleKey={titleKey}
subtitleKey={subtitleKey}
onEdit={handleEdit}
onDuplicate={handleDuplicate}
className={clsx({ 'opacity-50': !visibility })}
/>
<footer className="flex items-center justify-between">
<SectionSettings path={path} />
<Button variant="outlined" startIcon={<Add />} onClick={handleAdd}>
{t('builder.common.actions.add', { token: heading })}
</Button>
</footer>
</>
);
};
export default Section;

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('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,83 @@
import { Divider, IconButton, SwipeableDrawer, Tooltip, useMediaQuery, useTheme } from '@mui/material';
import { capitalize } from 'lodash';
import { useTranslation } from 'next-i18next';
import Avatar from '@/components/shared/Avatar';
import Footer from '@/components/shared/Footer';
import { right } from '@/config/sections';
import { setSidebarState } from '@/store/build/buildSlice';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import styles from './RightSidebar.module.scss';
const RightSidebar = () => {
const theme = useTheme();
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isDesktop = useMediaQuery(theme.breakpoints.up('lg'));
const { open } = useAppSelector((state) => state.build.sidebar.right);
const handleOpen = () => dispatch(setSidebarState({ sidebar: 'right', state: { open: true } }));
const handleClose = () => dispatch(setSidebarState({ sidebar: 'right', state: { open: false } }));
const handleClick = (id: string) => {
const section = document.querySelector(`#${id}`);
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
return (
<SwipeableDrawer
open={open}
anchor="right"
onOpen={handleOpen}
onClose={handleClose}
PaperProps={{ className: '!shadow-lg' }}
variant={isDesktop ? 'persistent' : 'temporary'}
>
<div className={styles.container}>
<nav>
<div>
<Avatar size={40} />
<Divider />
</div>
<div className={styles.sections}>
{right.map(({ id, icon }) => (
<Tooltip
key={id}
arrow
placement="right"
title={t(`builder.rightSidebar.sections.${id}.heading`, { defaultValue: capitalize(id) })}
>
<IconButton onClick={() => handleClick(id)}>{icon}</IconButton>
</Tooltip>
))}
</div>
<div />
</nav>
<main>
{right.map(({ id, component }) => (
<section key={id} id={id}>
{component}
</section>
))}
<footer className={styles.footer}>
<Footer />
<div>v{process.env.NEXT_PUBLIC_APP_VERSION}</div>
</footer>
</main>
</div>
</SwipeableDrawer>
);
};
export default RightSidebar;

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) => {
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,141 @@
import { Add, Close, Restore } from '@mui/icons-material';
import { Button, IconButton, Tooltip } from '@mui/material';
import clsx from 'clsx';
import cloneDeep from 'lodash/cloneDeep';
import get from 'lodash/get';
import { useTranslation } from 'next-i18next';
import { DragDropContext, Draggable, DraggableLocation, Droppable, DropResult } from 'react-beautiful-dnd';
import Heading from '@/components/shared/Heading';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { addPage, deletePage, setResumeState } from '@/store/resume/resumeSlice';
import styles from './Layout.module.scss';
const getIndices = (location: DraggableLocation) => ({
page: +location.droppableId.split('.')[0],
column: +location.droppableId.split('.')[1],
section: +location.index,
});
const Layout = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const layout = useAppSelector((state) => state.resume.metadata.layout);
const resumeSections = useAppSelector((state) => state.resume.sections);
const onDragEnd = (dropResult: DropResult) => {
const { source: srcLoc, destination: destLoc } = dropResult;
if (!destLoc) return;
const newLayout = cloneDeep(layout);
const srcIndex = getIndices(srcLoc);
const destIndex = getIndices(destLoc);
const section = layout[srcIndex.page][srcIndex.column][srcIndex.section];
// Remove item at source
newLayout[srcIndex.page][srcIndex.column].splice(srcIndex.section, 1);
// Insert item at destination
newLayout[destIndex.page][destIndex.column].splice(destIndex.section, 0, section);
dispatch(setResumeState({ path: 'metadata.layout', value: newLayout }));
};
const handleAddPage = () => dispatch(addPage());
const handleDeletePage = (page: number) => dispatch(deletePage({ page }));
const handleResetLayout = () => {
for (let i = layout.length - 1; i > 0; i--) {
handleDeletePage(i);
}
};
return (
<>
<Heading
path="metadata.layout"
name={t('builder.rightSidebar.sections.layout.heading')}
action={
<Tooltip title={t('builder.rightSidebar.sections.layout.tooltip.reset-layout')}>
<IconButton onClick={handleResetLayout}>
<Restore />
</IconButton>
</Tooltip>
}
/>
<DragDropContext onDragEnd={onDragEnd}>
{/* Pages */}
{layout.map((columns, pageIndex) => (
<div key={pageIndex} className={styles.page}>
<div className="flex items-center justify-between pr-3">
<p className={styles.heading}>
{t('builder.common.glossary.page')} {pageIndex + 1}
</p>
<div className={clsx(styles.delete, { hidden: pageIndex === 0 })}>
<Tooltip title={t('builder.common.actions.delete', { token: t('builder.common.glossary.page') })}>
<IconButton size="small" onClick={() => handleDeletePage(pageIndex)}>
<Close fontSize="small" />
</IconButton>
</Tooltip>
</div>
</div>
<div className={styles.container}>
{/* Sections */}
{columns.map((sections, columnIndex) => {
const index = `${pageIndex}.${columnIndex}`;
return (
<Droppable key={index} droppableId={index}>
{(provided) => (
<div ref={provided.innerRef} className={styles.column} {...provided.droppableProps}>
<p className={styles.heading}>{columnIndex ? 'Sidebar' : 'Main'}</p>
<div className={styles.base} />
{/* Sections */}
{sections.map((sectionId, sectionIndex) => (
<Draggable key={sectionId} draggableId={sectionId} index={sectionIndex}>
{(provided) => (
<div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>
<div
className={clsx(styles.section, {
[styles.disabled]: !get(resumeSections, `${sectionId}.visible`, true),
})}
>
{get(resumeSections, `${sectionId}.name`)}
</div>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
);
})}
</div>
</div>
))}
<div className="flex items-center justify-end">
<Button variant="outlined" startIcon={<Add />} onClick={handleAddPage}>
{t('builder.common.actions.add', { token: t('builder.common.glossary.page') })}
</Button>
</div>
</DragDropContext>
</>
);
};
export default Layout;

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,198 @@
import { Anchor, DeleteForever, Palette } from '@mui/icons-material';
import {
Autocomplete,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
ListSubheader,
Switch,
TextField,
} from '@mui/material';
import { DateConfig, Resume } from '@reactive-resume/schema';
import dayjs from 'dayjs';
import get from 'lodash/get';
import { useTranslation } from 'next-i18next';
import { useMemo } from 'react';
import { useMutation } from 'react-query';
import Heading from '@/components/shared/Heading';
import ThemeSwitch from '@/components/shared/ThemeSwitch';
import { Language, languageMap, languages } from '@/config/languages';
import { ServerError } from '@/services/axios';
import queryClient from '@/services/react-query';
import { loadSampleData, LoadSampleDataParams, resetResume, ResetResumeParams } from '@/services/resume';
import { setTheme, togglePageBreakLine, togglePageOrientation } from '@/store/build/buildSlice';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setResumeState } from '@/store/resume/resumeSlice';
import { dateFormatOptions } from '@/utils/date';
const Settings = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const resume = useAppSelector((state) => state.resume);
const theme = useAppSelector((state) => state.build.theme);
const breakLine = useAppSelector((state) => state.build.page.breakLine);
const orientation = useAppSelector((state) => state.build.page.orientation);
const id: number = useMemo(() => get(resume, 'id'), [resume]);
const slug: string = useMemo(() => get(resume, 'slug'), [resume]);
const username: string = useMemo(() => get(resume, 'user.username'), [resume]);
const language: string = useMemo(() => get(resume, 'metadata.language'), [resume]);
const dateConfig: DateConfig = useMemo(() => get(resume, 'metadata.date'), [resume]);
const isDarkMode = useMemo(() => theme === 'dark', [theme]);
const exampleString = useMemo(() => `Eg. ${dayjs().format(dateConfig.format)}`, [dateConfig.format]);
const themeString = useMemo(() => (isDarkMode ? 'Matte Black Everything' : 'As bright as your future'), [isDarkMode]);
const { mutateAsync: loadSampleDataMutation } = useMutation<Resume, ServerError, LoadSampleDataParams>(
loadSampleData
);
const { mutateAsync: resetResumeMutation } = useMutation<Resume, ServerError, ResetResumeParams>(resetResume);
const handleSetTheme = (value: boolean) => dispatch(setTheme({ theme: value ? 'dark' : 'light' }));
const handleChangeDateFormat = (value: string) => dispatch(setResumeState({ path: 'metadata.date.format', value }));
const handleChangeLanguage = (value: Language) =>
dispatch(setResumeState({ path: 'metadata.language', value: value.code }));
const handleLoadSampleData = async () => {
await loadSampleDataMutation({ id });
queryClient.invalidateQueries(`resume/${username}/${slug}`);
};
const handleResetResume = async () => {
await resetResumeMutation({ id });
queryClient.invalidateQueries(`resume/${username}/${slug}`);
};
return (
<>
<Heading path="metadata.settings" name={t('builder.rightSidebar.sections.settings.heading')} />
<List sx={{ padding: 0 }}>
{/* Global Settings */}
<>
<ListSubheader className="rounded">
{t('builder.rightSidebar.sections.settings.global.heading')}
</ListSubheader>
<ListItem>
<ListItemIcon>
<Palette />
</ListItemIcon>
<ListItemText
primary={t('builder.rightSidebar.sections.settings.global.theme.primary')}
secondary={themeString}
/>
<ThemeSwitch checked={isDarkMode} onChange={(_, value: boolean) => handleSetTheme(value)} />
</ListItem>
<ListItem className="flex-col">
<ListItemText
className="w-full"
primary={t('builder.rightSidebar.sections.settings.global.date.primary')}
secondary={t('builder.rightSidebar.sections.settings.global.date.secondary')}
/>
<Autocomplete<string, false, boolean, false>
disableClearable
className="my-2 w-full"
options={dateFormatOptions}
value={dateConfig.format}
onChange={(_, value) => handleChangeDateFormat(value)}
renderInput={(params) => <TextField {...params} helperText={exampleString} />}
/>
</ListItem>
<ListItem className="flex-col">
<ListItemText
className="w-full"
primary={t('builder.rightSidebar.sections.settings.global.language.primary')}
secondary={t('builder.rightSidebar.sections.settings.global.language.secondary')}
/>
<Autocomplete<Language, false, boolean, false>
disableClearable
className="my-2 w-full"
options={languages}
value={languageMap[language]}
getOptionLabel={(language) => {
if (language.localName) {
return `${language.name} (${language.localName})`;
}
return language.name;
}}
isOptionEqualToValue={(a, b) => a.code === b.code}
onChange={(_, value) => handleChangeLanguage(value)}
renderInput={(params) => <TextField {...params} />}
/>
</ListItem>
</>
{/* Page Settings */}
<>
<ListSubheader className="rounded">{t('builder.rightSidebar.sections.settings.page.heading')}</ListSubheader>
<ListItem>
<ListItemText
primary={t('builder.rightSidebar.sections.settings.page.orientation.primary')}
secondary={t('builder.rightSidebar.sections.settings.page.orientation.secondary')}
/>
<Switch
color="secondary"
checked={orientation === 'horizontal'}
onChange={() => dispatch(togglePageOrientation())}
/>
</ListItem>
<ListItem>
<ListItemText
primary={t('builder.rightSidebar.sections.settings.page.break-line.primary')}
secondary={t('builder.rightSidebar.sections.settings.page.break-line.secondary')}
/>
<Switch color="secondary" checked={breakLine} onChange={() => dispatch(togglePageBreakLine())} />
</ListItem>
</>
{/* Resume Settings */}
<>
<ListSubheader className="rounded">
{t('builder.rightSidebar.sections.settings.resume.heading')}
</ListSubheader>
<ListItem>
<ListItemButton onClick={handleLoadSampleData}>
<ListItemIcon>
<Anchor />
</ListItemIcon>
<ListItemText
primary={t('builder.rightSidebar.sections.settings.resume.sample.primary')}
secondary={t('builder.rightSidebar.sections.settings.resume.sample.secondary')}
/>
</ListItemButton>
</ListItem>
<ListItem>
<ListItemButton onClick={handleResetResume}>
<ListItemIcon>
<DeleteForever />
</ListItemIcon>
<ListItemText
primary={t('builder.rightSidebar.sections.settings.resume.reset.primary')}
secondary={t('builder.rightSidebar.sections.settings.resume.reset.secondary')}
/>
</ListItemButton>
</ListItem>
</>
</List>
</>
);
};
export default Settings;

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

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 | Font) => {
if (value === null) return;
dispatch(
setResumeState({
path: `metadata.typography.${property}.${category}`,
value: property === 'family' ? (value as Font).family : value,
})
);
};
if (isEmpty(fonts)) return <TypographySkeleton />;
return (
<>
<h5 className={styles.subheading}>{label}</h5>
<div className={styles.container}>
<div className={styles.slider}>
<Slider
min={12}
max={36}
step={1}
marks={[
{ value: 12, label: '12px' },
{ value: 24, label: t('builder.rightSidebar.sections.typography.form.font-size.label') },
{ value: 36, label: '36px' },
]}
valueLabelDisplay="auto"
value={size[category]}
onChange={(_, size: number) => handleChange('size', size)}
/>
</div>
<Autocomplete<Font, false, boolean, false>
options={fonts}
disableClearable={true}
groupBy={(font) => font.category}
getOptionLabel={(font) => font.family}
isOptionEqualToValue={(a, b) => a.family === b.family}
value={fonts.find((font) => font.family === family[category])}
onChange={(_, font: Font) => handleChange('family', font)}
renderInput={(params) => (
<TextField {...params} label={t('builder.rightSidebar.sections.typography.form.font-family.label')} />
)}
/>
</div>
</>
);
};
const Typography = () => {
const { t } = useTranslation();
return (
<>
<Heading path="metadata.typography" name={t('builder.rightSidebar.sections.typography.heading')} />
<Widgets label={t('builder.rightSidebar.sections.typography.widgets.headings.label')} category="heading" />
<Widgets label={t('builder.rightSidebar.sections.typography.widgets.body.label')} category="body" />
</>
);
};
export default Typography;

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('dashboard.resume.menu.tooltips.share-link')}>
<div>
<MenuItem>
<ListItemIcon>
<LinkIcon className="scale-90" />
</ListItemIcon>
<ListItemText>{t('dashboard.resume.menu.share-link')}</ListItemText>
</MenuItem>
</div>
</Tooltip>
)}
<Tooltip arrow placement="right" title={t('dashboard.resume.menu.tooltips.delete')}>
<MenuItem onClick={handleDelete}>
<ListItemIcon>
<DeleteOutline className="scale-90" />
</ListItemIcon>
<ListItemText>{t('dashboard.resume.menu.delete')}</ListItemText>
</MenuItem>
</Tooltip>
</Menu>
</footer>
</section>
);
};
export default ResumePreview;

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 = {
value: string[];
label: string;
onChange: (event: any) => void;
errors: FieldError | FieldError[];
className?: string;
};
const ArrayInput: React.FC<Props> = ({ value, label, onChange, errors, className }) => {
const [items, setItems] = useState<string[]>(value);
const onAdd = () => setItems([...items, '']);
const onDelete = (index: number) => setItems(items.filter((_, idx) => idx !== index));
const handleChange = (event: React.ChangeEvent<HTMLInputElement>, index: number) => {
const tempItems = [...items];
tempItems[index] = event.target.value;
setItems(tempItems);
};
useEffect(() => {
onChange(items);
}, [onChange, items]);
return (
<div className={className}>
<header className={styles.header}>
<h3 className={styles.label}>
{label} <small>({items.length})</small>
</h3>
<IconButton onClick={onAdd}>
<Add />
</IconButton>
</header>
<div className={styles.inputGrid}>
{items.map((value, index) => (
<TextField
key={index}
value={value}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => handleChange(event, index)}
error={!!get(errors, index, false)}
helperText={get(errors, `${index}.message`, '')}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton edge="end" onClick={() => onDelete(index)} className={styles.delete}>
<Delete />
</IconButton>
</InputAdornment>
),
}}
/>
))}
</div>
</div>
);
};
export default ArrayInput;

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

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({
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,18 @@
import clsx from 'clsx';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
type Props = {
children?: string;
className?: string;
};
const Markdown: React.FC<Props> = ({ className, children }) => {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} className={clsx('markdown', className)}>
{children}
</ReactMarkdown>
);
};
export default Markdown;

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;

View File

@ -0,0 +1,20 @@
export const hexColorPattern = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i;
export const colorOptions: string[] = [
'#f44336',
'#e91e63',
'#9c27b0',
'#673ab7',
'#3f51b5',
'#4896d5',
'#03a9f4',
'#00bcd4',
'#009688',
'#4caf50',
'#8bc34a',
'#cddc39',
'#ffeb3b',
'#ffc107',
'#222222',
'#dddddd',
];

View File

@ -0,0 +1,20 @@
export type Language = {
code: string;
name: string;
localName?: string;
};
export const languages: Language[] = [
{
code: 'en',
name: 'English',
},
];
export const languageMap = languages.reduce(
(acc, lang) => ({
...acc,
[lang.code]: lang,
}),
{}
);

View File

@ -0,0 +1,31 @@
type Screenshot = {
src: string;
alt: string;
};
export const screenshots: Screenshot[] = [
{
src: '/images/screenshots/dashboard.png',
alt: 'Create multiple resumes under one account',
},
{
src: '/images/screenshots/import-external.png',
alt: 'Import your data from LinkedIn, JSON Resume or Reactive Resume',
},
{
src: '/images/screenshots/builder.png',
alt: 'Variety of features to personalize your resume to your liking',
},
{
src: '/images/screenshots/add-section.png',
alt: 'Multiple pre-built sections which can be renamed, or just create your own section',
},
{
src: '/images/screenshots/page-layout.png',
alt: 'Create multiple pages, manage section layouts as easy as dragging them around',
},
{
src: '/images/screenshots/preview.png',
alt: 'Get a unique link to your resume which can be shared with anyone for the latest information',
},
];

View File

@ -0,0 +1,181 @@
import {
Architecture,
CardGiftcard,
Category,
Coffee,
Download,
EmojiEvents,
FontDownload,
Groups,
Language,
Link as LinkIcon,
Map,
Margin,
MenuBook,
Palette,
Person,
Sailing,
School,
Settings as SettingsIcon,
Share,
Style,
Twitter,
VolunteerActivism,
Work,
} from '@mui/icons-material';
import { Section as SectionRecord } from '@reactive-resume/schema';
import isEmpty from 'lodash/isEmpty';
import Basics from '@/components/build/LeftSidebar/sections/Basics';
import Location from '@/components/build/LeftSidebar/sections/Location';
import Profiles from '@/components/build/LeftSidebar/sections/Profiles';
import Section from '@/components/build/LeftSidebar/sections/Section';
import CustomCSS from '@/components/build/RightSidebar/sections/CustomCSS';
import Export from '@/components/build/RightSidebar/sections/Export';
import Layout from '@/components/build/RightSidebar/sections/Layout';
import Links from '@/components/build/RightSidebar/sections/Links';
import Settings from '@/components/build/RightSidebar/sections/Settings';
import Sharing from '@/components/build/RightSidebar/sections/Sharing';
import Templates from '@/components/build/RightSidebar/sections/Templates';
import Theme from '@/components/build/RightSidebar/sections/Theme';
import Typography from '@/components/build/RightSidebar/sections/Typography';
import { SidebarSection } from '@/types/app';
export const left: SidebarSection[] = [
{
id: 'basics',
icon: <Person />,
component: <Basics />,
},
{
id: 'location',
icon: <Map />,
component: <Location />,
},
{
id: 'profiles',
icon: <Twitter />,
component: <Profiles />,
},
{
id: 'work',
icon: <Work />,
component: <Section path="sections.work" titleKey="name" subtitleKey="position" isEditable isHideable />,
},
{
id: 'education',
icon: <School />,
component: <Section path="sections.education" titleKey="institution" subtitleKey="area" isEditable isHideable />,
},
{
id: 'awards',
icon: <EmojiEvents />,
component: <Section path="sections.awards" titleKey="title" subtitleKey="awarder" isEditable isHideable />,
},
{
id: 'certifications',
icon: <CardGiftcard />,
component: <Section path="sections.certifications" titleKey="name" subtitleKey="issuer" isEditable isHideable />,
},
{
id: 'publications',
icon: <MenuBook />,
component: <Section path="sections.publications" titleKey="name" subtitleKey="publisher" isEditable isHideable />,
},
{
id: 'skills',
icon: <Architecture />,
component: <Section path="sections.skills" titleKey="name" subtitleKey="level" isEditable isHideable />,
},
{
id: 'languages',
icon: <Language />,
component: <Section path="sections.languages" titleKey="name" subtitleKey="level" isEditable isHideable />,
},
{
id: 'interests',
icon: <Sailing />,
component: <Section path="sections.interests" titleKey="name" subtitleKey="keywords" isEditable isHideable />,
},
{
id: 'volunteer',
icon: <VolunteerActivism />,
component: (
<Section path="sections.volunteer" titleKey="organization" subtitleKey="position" isEditable isHideable />
),
},
{
id: 'projects',
icon: <Coffee />,
component: <Section path="sections.projects" titleKey="name" subtitleKey="description" isEditable isHideable />,
},
{
id: 'references',
icon: <Groups />,
component: <Section path="sections.references" titleKey="name" subtitleKey="relationship" isEditable isHideable />,
},
];
export const right: SidebarSection[] = [
{
id: 'templates',
icon: <Category />,
component: <Templates />,
},
{
id: 'layout',
icon: <Margin />,
component: <Layout />,
},
{
id: 'typography',
icon: <FontDownload />,
component: <Typography />,
},
{
id: 'theme',
icon: <Palette />,
component: <Theme />,
},
{
id: 'css',
icon: <Style />,
component: <CustomCSS />,
},
{
id: 'sharing',
icon: <Share />,
component: <Sharing />,
},
{
id: 'export',
icon: <Download />,
component: <Export />,
},
{
id: 'settings',
icon: <SettingsIcon />,
component: <Settings />,
},
{
id: 'links',
icon: <LinkIcon />,
component: <Links />,
},
];
export const getCustomSections = (sections: Record<string, SectionRecord>): Array<SectionRecord> => {
if (isEmpty(sections)) return [];
return Object.entries(sections).reduce((acc, [id, section]) => {
if (section.type === 'custom') {
return [...acc, { ...section, id }];
}
return acc;
}, [] as Array<SectionRecord>);
};
const sections = [...left, ...right];
export default sections;

View File

@ -0,0 +1,72 @@
import { createTheme } from '@mui/material';
import { grey } from '@mui/material/colors';
import { teal } from 'tailwindcss/colors';
const theme = createTheme({
typography: {
fontSize: 12,
fontFamily: 'Inter, sans-serif',
},
components: {
MuiButton: {
defaultProps: {
size: 'small',
variant: 'contained',
disableElevation: true,
},
styleOverrides: {
root: {
textTransform: 'none',
padding: '6px 20px',
},
},
},
MuiTextField: {
defaultProps: {
variant: 'outlined',
},
},
MuiAppBar: {
styleOverrides: {
root: {
zIndex: 30,
},
},
},
MuiTooltip: {
styleOverrides: {
tooltip: {
fontSize: 12,
},
},
},
MuiDrawer: {
styleOverrides: {
root: {
zIndex: 40,
},
paper: {
border: 'none',
},
},
},
},
});
export const lightTheme = createTheme({
...theme,
palette: {
mode: 'light',
primary: { main: grey[800] },
secondary: { main: teal[600] },
},
});
export const darkTheme = createTheme({
...theme,
palette: {
mode: 'dark',
primary: { main: grey[100] },
secondary: { main: teal[400] },
},
});

View File

@ -0,0 +1,11 @@
// React Queries
export const FONTS_QUERY = 'fonts';
export const RESUMES_QUERY = 'resumes';
// Date Formats
export const FILENAME_TIMESTAMP = 'DDMMYYYYHHmmss';
// Links
export const DONATION_URL = 'https://www.buymeacoffee.com/AmruthPillai';
export const GITHUB_URL = 'https://github.com/AmruthPillai/Reactive-Resume';
export const GITHUB_ISSUES_URL = 'https://github.com/AmruthPillai/Reactive-Resume/issues/new/choose';

6
apps/client/index.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
declare module '*.svg' {
const content: any;
export const ReactComponent: any;
export default content;
}

View File

@ -0,0 +1,10 @@
module.exports = {
displayName: 'client',
preset: '../../jest.preset.js',
transform: {
'^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nrwl/react/plugins/jest',
'^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nrwl/next/babel'] }],
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/apps/client',
};

View File

@ -0,0 +1,91 @@
import { joiResolver } from '@hookform/resolvers/joi';
import { Password } from '@mui/icons-material';
import { Button, TextField } from '@mui/material';
import Joi from 'joi';
import { useTranslation } from 'next-i18next';
import { Controller, useForm } from 'react-hook-form';
import { useMutation } from 'react-query';
import BaseModal from '@/components/shared/BaseModal';
import { forgotPassword, ForgotPasswordParams } from '@/services/auth';
import { ServerError } from '@/services/axios';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';
type FormData = {
email: string;
};
const defaultState: FormData = {
email: '',
};
const schema = Joi.object({
email: Joi.string()
.email({ tlds: { allow: false } })
.required(),
});
const ForgotPasswordModal: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { open: isOpen } = useAppSelector((state) => state.modal['auth.forgot']);
const { mutate, isLoading } = useMutation<void, ServerError, ForgotPasswordParams>(forgotPassword);
const { reset, control, handleSubmit } = useForm<FormData>({
defaultValues: defaultState,
resolver: joiResolver(schema),
});
const handleClose = () => {
dispatch(setModalState({ modal: 'auth.forgot', state: { open: false } }));
reset();
};
const onSubmit = ({ email }: FormData) => {
mutate({ email }, { onSettled: handleClose });
};
return (
<>
<BaseModal
icon={<Password />}
isOpen={isOpen}
heading={t('modals.auth.forgot-password.heading')}
handleClose={handleClose}
footerChildren={
<Button type="submit" disabled={isLoading} onClick={handleSubmit(onSubmit)}>
{t('modals.auth.forgot-password.actions.send-email')}
</Button>
}
>
<div className="grid gap-4">
<p>{t('modals.auth.forgot-password.body')}</p>
<form className="grid gap-4 xl:w-2/3">
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
autoFocus
label={t('modals.auth.forgot-password.form.email.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
</form>
<p className="text-xs">{t('modals.auth.forgot-password.help-text')}</p>
</div>
</BaseModal>
</>
);
};
export default ForgotPasswordModal;

View File

@ -0,0 +1,182 @@
import { joiResolver } from '@hookform/resolvers/joi';
import { Google, Login, Visibility, VisibilityOff } from '@mui/icons-material';
import { Button, IconButton, InputAdornment, TextField } from '@mui/material';
import Joi from 'joi';
import { Trans, useTranslation } from 'next-i18next';
import { useMemo, useState } from 'react';
import { GoogleLoginResponse, useGoogleLogin } from 'react-google-login';
import { Controller, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
import { useIsMutating, useMutation } from 'react-query';
import BaseModal from '@/components/shared/BaseModal';
import { login, LoginParams, loginWithGoogle, LoginWithGoogleParams } from '@/services/auth';
import { ServerError } from '@/services/axios';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';
type FormData = {
identifier: string;
password: string;
};
const defaultState: FormData = {
identifier: '',
password: '',
};
const schema = Joi.object({
identifier: Joi.string().required(),
password: Joi.string().min(6).required(),
});
const LoginModal: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [showPassword, setShowPassword] = useState(false);
const isMutating = useIsMutating();
const isLoading = useMemo(() => isMutating > 0, [isMutating]);
const { open: isOpen } = useAppSelector((state) => state.modal['auth.login']);
const { reset, control, handleSubmit } = useForm<FormData>({
defaultValues: defaultState,
resolver: joiResolver(schema),
});
const { mutateAsync: loginMutation } = useMutation<void, ServerError, LoginParams>(login);
const { mutateAsync: loginWithGoogleMutation } = useMutation<void, ServerError, LoginWithGoogleParams>(
loginWithGoogle
);
const { signIn } = useGoogleLogin({
clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
onSuccess: async (response: GoogleLoginResponse) => {
await loginWithGoogleMutation({ accessToken: response.accessToken });
handleClose();
},
});
const handleClose = () => {
dispatch(setModalState({ modal: 'auth.login', state: { open: false } }));
reset();
};
const onSubmit = async ({ identifier, password }: FormData) => {
await loginMutation(
{ identifier, password },
{
onError: (error) => {
toast.error(error.message);
},
}
);
handleClose();
};
const handleCreateAccount = () => {
handleClose();
dispatch(setModalState({ modal: 'auth.register', state: { open: true } }));
};
const handleRecoverAccount = () => {
handleClose();
dispatch(setModalState({ modal: 'auth.forgot', state: { open: true } }));
};
const handleLoginWithGoogle = () => {
signIn();
};
const PasswordVisibility = (): React.ReactElement => {
const handleToggle = () => setShowPassword((showPassword) => !showPassword);
return (
<InputAdornment position="end">
<IconButton edge="end" onClick={handleToggle}>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
);
};
return (
<BaseModal
icon={<Login />}
isOpen={isOpen}
heading={t('modals.auth.login.heading')}
handleClose={handleClose}
footerChildren={
<div className="flex gap-4">
<Button
type="submit"
variant="outlined"
disabled={isLoading}
startIcon={<Google />}
onClick={handleLoginWithGoogle}
>
{t('modals.auth.login.actions.login-google')}
</Button>
<Button type="submit" onClick={handleSubmit(onSubmit)} disabled={isLoading}>
{t('modals.auth.login.actions.login')}
</Button>
</div>
}
>
<p>{t('modals.auth.login.body')}</p>
<form className="grid gap-4 xl:w-2/3">
<Controller
name="identifier"
control={control}
render={({ field, fieldState }) => (
<TextField
autoFocus
label={t('modals.auth.login.form.username.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message || t('modals.auth.login.form.username.help-text')}
{...field}
/>
)}
/>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
type={showPassword ? 'text' : 'password'}
label={t('modals.auth.login.form.password.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
InputProps={{ endAdornment: <PasswordVisibility /> }}
{...field}
/>
)}
/>
</form>
<p className="text-xs">
<Trans t={t} i18nKey="modals.auth.login.register-text">
If you don&apos;t have one, you can <a onClick={handleCreateAccount}>create an account</a> here.
</Trans>
</p>
<p className="text-xs">
<Trans t={t} i18nKey="modals.auth.login.recover-text">
In case you have forgotten your password, you can <a onClick={handleRecoverAccount}>recover your account</a>
here.
</Trans>
</p>
</BaseModal>
);
};
export default LoginModal;

View File

@ -0,0 +1,170 @@
import { joiResolver } from '@hookform/resolvers/joi';
import { HowToReg } from '@mui/icons-material';
import { Button, TextField } from '@mui/material';
import Joi from 'joi';
import { Trans, useTranslation } from 'next-i18next';
import { Controller, useForm } from 'react-hook-form';
import { useMutation } from 'react-query';
import BaseModal from '@/components/shared/BaseModal';
import { register as registerUser, RegisterParams } from '@/services/auth';
import { ServerError } from '@/services/axios';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';
type FormData = {
name: string;
username: string;
email: string;
password: string;
confirmPassword: string;
};
const defaultState: FormData = {
name: '',
username: '',
email: '',
password: '',
confirmPassword: '',
};
const schema = Joi.object({
name: Joi.string().required(),
username: Joi.string()
.lowercase()
.min(3)
.regex(/^[a-z0-9-]+$/, 'only lowercase characters, numbers and hyphens')
.required(),
email: Joi.string()
.email({ tlds: { allow: false } })
.required(),
password: Joi.string().min(6).required(),
confirmPassword: Joi.string().min(6).required().valid(Joi.ref('password')),
});
const RegisterModal: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { open: isOpen } = useAppSelector((state) => state.modal['auth.register']);
const { reset, control, handleSubmit } = useForm<FormData>({
defaultValues: defaultState,
resolver: joiResolver(schema),
});
const { mutateAsync, isLoading } = useMutation<void, ServerError, RegisterParams>(registerUser);
const handleClose = () => {
dispatch(setModalState({ modal: 'auth.register', state: { open: false } }));
reset();
};
const onSubmit = async ({ name, username, email, password }: FormData) => {
await mutateAsync({ name, username, email, password });
handleClose();
};
const handleLogin = () => {
handleClose();
dispatch(setModalState({ modal: 'auth.login', state: { open: true } }));
};
return (
<BaseModal
icon={<HowToReg />}
isOpen={isOpen}
heading={t('modals.auth.register.heading')}
handleClose={handleClose}
footerChildren={
<Button type="submit" onClick={handleSubmit(onSubmit)} disabled={isLoading}>
{t('modals.auth.register.actions.register')}
</Button>
}
>
<p>{t('modals.auth.register.body')}</p>
<form className="grid gap-4 md:grid-cols-2">
<Controller
name="name"
control={control}
render={({ field, fieldState }) => (
<TextField
autoFocus
label={t('modals.auth.register.form.name.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="username"
control={control}
render={({ field, fieldState }) => (
<TextField
label={t('modals.auth.register.form.username.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
type="email"
label={t('modals.auth.register.form.email.label')}
className="col-span-2"
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
type="password"
label={t('modals.auth.register.form.password.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="confirmPassword"
control={control}
render={({ field, fieldState }) => (
<TextField
type="password"
label={t('modals.auth.register.form.confirm-password.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
</form>
<p className="text-xs">
<Trans t={t} i18nKey="modals.auth.register.loginText">
If you already have an account, you can <a onClick={handleLogin}>login here</a>.
</Trans>
</p>
</BaseModal>
);
};
export default RegisterModal;

View File

@ -0,0 +1,112 @@
import { joiResolver } from '@hookform/resolvers/joi';
import { LockReset } from '@mui/icons-material';
import { Button, TextField } from '@mui/material';
import Joi from 'joi';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
import { useTranslation } from 'next-i18next';
import { Controller, useForm } from 'react-hook-form';
import { useMutation } from 'react-query';
import BaseModal from '@/components/shared/BaseModal';
import { resetPassword, ResetPasswordParams } from '@/services/auth';
import { ServerError } from '@/services/axios';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { ModalState, setModalState } from '@/store/modal/modalSlice';
type Payload = {
resetToken?: string;
};
type FormData = {
password: string;
confirmPassword: string;
};
const defaultState: FormData = {
password: '',
confirmPassword: '',
};
const schema = Joi.object({
password: Joi.string().min(6).required(),
confirmPassword: Joi.string().min(6).required().valid(Joi.ref('password')),
});
const ResetPasswordModal: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { open: isOpen, payload } = useAppSelector((state) => state.modal['auth.reset']) as ModalState;
const resetData = get(payload, 'item', {}) as Payload;
const { mutateAsync, isLoading } = useMutation<void, ServerError, ResetPasswordParams>(resetPassword);
const { reset, control, handleSubmit } = useForm<FormData>({
defaultValues: defaultState,
resolver: joiResolver(schema),
});
const handleClose = () => {
dispatch(setModalState({ modal: 'auth.reset', state: { open: false } }));
reset();
};
const onSubmit = async ({ password }: FormData) => {
if (isEmpty(resetData.resetToken)) return;
await mutateAsync({ resetToken: resetData.resetToken, password });
handleClose();
};
return (
<BaseModal
icon={<LockReset />}
isOpen={isOpen}
heading={t('modals.auth.reset-password.heading')}
handleClose={handleClose}
footerChildren={
<Button type="submit" disabled={isLoading} onClick={handleSubmit(onSubmit)}>
{t('modals.auth.reset-password.actions.set-password')}
</Button>
}
>
<p>{t('modals.auth.reset-password.body')}</p>
<form className="grid gap-4 md:grid-cols-2">
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
autoFocus
type="password"
label={t('modals.auth.reset-password.form.password.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="confirmPassword"
control={control}
render={({ field, fieldState }) => (
<TextField
type="password"
label={t('modals.auth.reset-password.form.confirm-password.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
</form>
</BaseModal>
);
};
export default ResetPasswordModal;

View File

@ -0,0 +1,185 @@
import { joiResolver } from '@hookform/resolvers/joi';
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
import DatePicker from '@mui/lab/DatePicker';
import { Button, TextField } from '@mui/material';
import { Award, SectionPath } from '@reactive-resume/schema';
import dayjs from 'dayjs';
import Joi from 'joi';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
import { useTranslation } from 'next-i18next';
import { useEffect, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import BaseModal from '@/components/shared/BaseModal';
import MarkdownSupported from '@/components/shared/MarkdownSupported';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';
import { addItem, editItem } from '@/store/resume/resumeSlice';
type FormData = Award;
const path: SectionPath = 'sections.awards';
const defaultState: FormData = {
title: '',
awarder: '',
date: '',
url: '',
summary: '',
};
const schema = Joi.object<FormData>().keys({
id: Joi.string(),
title: Joi.string().required(),
awarder: Joi.string().required(),
date: Joi.string().allow(''),
url: Joi.string()
.pattern(/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/, { name: 'valid URL' })
.allow(''),
summary: Joi.string().allow(''),
});
const AwardModal: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
const item: FormData = get(payload, 'item', null);
const isEditMode = useMemo(() => !!item, [item]);
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
const { reset, control, handleSubmit } = useForm<FormData>({
defaultValues: defaultState,
resolver: joiResolver(schema),
});
const onSubmit = (formData: FormData) => {
if (isEditMode) {
dispatch(editItem({ path: `${path}.items`, value: formData }));
} else {
dispatch(addItem({ path: `${path}.items`, value: formData }));
}
handleClose();
};
const handleClose = () => {
dispatch(
setModalState({
modal: `builder.${path}`,
state: { open: false },
})
);
reset(defaultState);
};
useEffect(() => {
if (!isEmpty(item)) {
reset(item);
}
}, [item, reset]);
return (
<BaseModal
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
isOpen={isOpen}
handleClose={handleClose}
heading={isEditMode ? editText : addText}
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
>
<form className="my-2 grid grid-cols-2 gap-4">
<Controller
name="title"
control={control}
render={({ field, fieldState }) => (
<TextField
required
autoFocus
label={t('builder.common.form.title.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="awarder"
control={control}
render={({ field, fieldState }) => (
<TextField
required
label={t('builder.leftSidebar.sections.awards.form.awarder.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="date"
control={control}
render={({ field, fieldState }) => (
<DatePicker
{...field}
openTo="year"
label={t('builder.common.form.date.label')}
views={['year', 'month', 'day']}
onChange={(date: Date, keyboardInputValue: string) => {
isEmpty(keyboardInputValue) && field.onChange('');
dayjs(date).isValid() && field.onChange(date.toISOString());
}}
renderInput={(params) => (
<TextField
{...params}
error={!!fieldState.error}
helperText={fieldState.error?.message || params.inputProps?.placeholder}
/>
)}
/>
)}
/>
<Controller
name="url"
control={control}
render={({ field, fieldState }) => (
<TextField
label={t('builder.common.form.url.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="summary"
control={control}
render={({ field, fieldState }) => (
<TextField
multiline
minRows={3}
maxRows={6}
label={t('builder.common.form.summary.label')}
className="col-span-2"
error={!!fieldState.error}
helperText={fieldState.error?.message || <MarkdownSupported />}
{...field}
/>
)}
/>
</form>
</BaseModal>
);
};
export default AwardModal;

View File

@ -0,0 +1,185 @@
import { joiResolver } from '@hookform/resolvers/joi';
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
import DatePicker from '@mui/lab/DatePicker';
import { Button, TextField } from '@mui/material';
import { Certificate, SectionPath } from '@reactive-resume/schema';
import dayjs from 'dayjs';
import Joi from 'joi';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
import { useTranslation } from 'next-i18next';
import { useEffect, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import BaseModal from '@/components/shared/BaseModal';
import MarkdownSupported from '@/components/shared/MarkdownSupported';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';
import { addItem, editItem } from '@/store/resume/resumeSlice';
type FormData = Certificate;
const path: SectionPath = 'sections.certifications';
const defaultState: FormData = {
name: '',
issuer: '',
date: '',
url: '',
summary: '',
};
const schema = Joi.object<FormData>().keys({
id: Joi.string(),
name: Joi.string().required(),
issuer: Joi.string().required(),
date: Joi.string().allow(''),
url: Joi.string()
.pattern(/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/, { name: 'valid URL' })
.allow(''),
summary: Joi.string().allow(''),
});
const CertificateModal: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
const item: FormData = get(payload, 'item', null);
const isEditMode = useMemo(() => !!item, [item]);
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
const { reset, control, handleSubmit } = useForm<FormData>({
defaultValues: defaultState,
resolver: joiResolver(schema),
});
const onSubmit = (formData: FormData) => {
if (isEditMode) {
dispatch(editItem({ path: `${path}.items`, value: formData }));
} else {
dispatch(addItem({ path: `${path}.items`, value: formData }));
}
handleClose();
};
const handleClose = () => {
dispatch(
setModalState({
modal: `builder.${path}`,
state: { open: false },
})
);
reset(defaultState);
};
useEffect(() => {
if (!isEmpty(item)) {
reset(item);
}
}, [item, reset]);
return (
<BaseModal
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
isOpen={isOpen}
handleClose={handleClose}
heading={isEditMode ? editText : addText}
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
>
<form className="my-2 grid grid-cols-2 gap-4">
<Controller
name="name"
control={control}
render={({ field, fieldState }) => (
<TextField
required
autoFocus
label={t('builder.common.form.name.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="issuer"
control={control}
render={({ field, fieldState }) => (
<TextField
required
label={t('builder.leftSidebar.sections.certifications.form.issuer.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="date"
control={control}
render={({ field, fieldState }) => (
<DatePicker
{...field}
openTo="year"
label={t('builder.common.form.date.label')}
views={['year', 'month', 'day']}
onChange={(date: Date, keyboardInputValue: string) => {
isEmpty(keyboardInputValue) && field.onChange('');
dayjs(date).isValid() && field.onChange(date.toISOString());
}}
renderInput={(params) => (
<TextField
{...params}
error={!!fieldState.error}
helperText={fieldState.error?.message || params.inputProps?.placeholder}
/>
)}
/>
)}
/>
<Controller
name="url"
control={control}
render={({ field, fieldState }) => (
<TextField
label={t('builder.common.form.url.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="summary"
control={control}
render={({ field, fieldState }) => (
<TextField
multiline
minRows={3}
maxRows={6}
label={t('builder.common.form.summary.label')}
className="col-span-2"
error={!!fieldState.error}
helperText={fieldState.error?.message || <MarkdownSupported />}
{...field}
/>
)}
/>
</form>
</BaseModal>
);
};
export default CertificateModal;

View File

@ -0,0 +1,290 @@
import { joiResolver } from '@hookform/resolvers/joi';
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
import DatePicker from '@mui/lab/DatePicker';
import { Button, Slider, TextField } from '@mui/material';
import { Custom } from '@reactive-resume/schema';
import dayjs from 'dayjs';
import Joi from 'joi';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
import { useTranslation } from 'next-i18next';
import { useEffect, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import ArrayInput from '@/components/shared/ArrayInput';
import BaseModal from '@/components/shared/BaseModal';
import MarkdownSupported from '@/components/shared/MarkdownSupported';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';
import { addItem, editItem } from '@/store/resume/resumeSlice';
type FormData = Custom;
export type CustomModalPayload = {
path: string;
item?: Custom;
};
const defaultState: FormData = {
title: '',
subtitle: '',
date: {
start: '',
end: '',
},
url: '',
level: '',
levelNum: 0,
summary: '',
keywords: [],
};
const schema = Joi.object<FormData>().keys({
id: Joi.string(),
title: Joi.string().required(),
subtitle: Joi.string().allow(''),
date: Joi.object().keys({
start: Joi.string().allow(''),
end: Joi.string().allow(''),
}),
url: Joi.string()
.pattern(/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/, { name: 'valid URL' })
.allow(''),
level: Joi.string().allow(''),
levelNum: Joi.number().min(0).max(10),
summary: Joi.string().allow(''),
keywords: Joi.array().items(Joi.string().optional()),
});
const CustomModal: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
const { open: isOpen, payload } = useAppSelector((state) => state.modal['builder.sections.custom']);
const path: string = get(payload, 'path', '');
const item: FormData = get(payload, 'item', null);
const isEditMode = useMemo(() => !!item, [item]);
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
const { reset, control, handleSubmit } = useForm<FormData>({
defaultValues: defaultState,
resolver: joiResolver(schema),
});
const onSubmit = (formData: FormData) => {
if (isEditMode) {
dispatch(editItem({ path: `${path}.items`, value: formData }));
} else {
dispatch(addItem({ path: `${path}.items`, value: formData }));
}
handleClose();
};
const handleClose = () => {
dispatch(
setModalState({
modal: 'builder.sections.custom',
state: { open: false },
})
);
reset(defaultState);
};
useEffect(() => {
if (!isEmpty(item)) {
reset(item);
}
}, [item, reset]);
return (
<BaseModal
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
isOpen={isOpen}
handleClose={handleClose}
heading={isEditMode ? editText : addText}
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
>
<form className="my-2 grid grid-cols-2 gap-4">
<Controller
name="title"
control={control}
render={({ field, fieldState }) => (
<TextField
required
autoFocus
label={t('builder.common.form.title.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="subtitle"
control={control}
render={({ field, fieldState }) => (
<TextField
label={t('builder.common.form.subtitle.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="date.start"
control={control}
render={({ field, fieldState }) => (
<DatePicker
{...field}
openTo="year"
label={t('builder.common.form.start-date.label')}
views={['year', 'month', 'day']}
onChange={(date: Date, keyboardInputValue: string) => {
isEmpty(keyboardInputValue) && field.onChange('');
dayjs(date).isValid() && field.onChange(date.toISOString());
}}
renderInput={(params) => (
<TextField
{...params}
error={!!fieldState.error}
helperText={fieldState.error?.message || params.inputProps?.placeholder}
/>
)}
/>
)}
/>
<Controller
name="date.end"
control={control}
render={({ field, fieldState }) => (
<DatePicker
{...field}
openTo="year"
label={t('builder.common.form.end-date.label')}
views={['year', 'month', 'day']}
onChange={(date: Date, keyboardInputValue: string) => {
isEmpty(keyboardInputValue) && field.onChange('');
dayjs(date).isValid() && field.onChange(date.toISOString());
}}
renderInput={(params) => (
<TextField
{...params}
error={!!fieldState.error}
helperText={fieldState.error?.message || 'Leave this field blank, if still present'}
/>
)}
/>
)}
/>
<Controller
name="url"
control={control}
render={({ field, fieldState }) => (
<TextField
label={t('builder.common.form.url.label')}
className="col-span-2"
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="level"
control={control}
render={({ field, fieldState }) => (
<TextField
label={t('builder.common.form.level.label')}
className="col-span-2"
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="levelNum"
control={control}
render={({ field }) => (
<div className="col-span-2">
<h4 className="mb-3 font-semibold">{t('builder.common.form.levelNum.label')}</h4>
<div className="px-10">
<Slider
{...field}
marks={[
{
value: 0,
label: 'Disable',
},
{
value: 1,
label: 'Beginner',
},
{
value: 10,
label: 'Expert',
},
]}
min={0}
max={10}
defaultValue={0}
color="secondary"
valueLabelDisplay="auto"
aria-label={t('builder.common.form.levelNum.label')}
/>
</div>
</div>
)}
/>
<Controller
name="summary"
control={control}
render={({ field, fieldState }) => (
<TextField
multiline
minRows={3}
maxRows={6}
label={t('builder.common.form.summary.label')}
className="col-span-2"
error={!!fieldState.error}
helperText={fieldState.error?.message || <MarkdownSupported />}
{...field}
/>
)}
/>
<Controller
name="keywords"
control={control}
render={({ field, fieldState }) => (
<ArrayInput
label={t('builder.common.form.keywords.label')}
value={field.value}
onChange={field.onChange}
errors={fieldState.error}
className="col-span-2"
/>
)}
/>
</form>
</BaseModal>
);
};
export default CustomModal;

View File

@ -0,0 +1,263 @@
import { joiResolver } from '@hookform/resolvers/joi';
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
import DatePicker from '@mui/lab/DatePicker';
import { Button, TextField } from '@mui/material';
import { Education, SectionPath } from '@reactive-resume/schema';
import dayjs from 'dayjs';
import Joi from 'joi';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
import { useTranslation } from 'next-i18next';
import { useEffect, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import ArrayInput from '@/components/shared/ArrayInput';
import BaseModal from '@/components/shared/BaseModal';
import MarkdownSupported from '@/components/shared/MarkdownSupported';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';
import { addItem, editItem } from '@/store/resume/resumeSlice';
type FormData = Education;
const path: SectionPath = 'sections.education';
const defaultState: FormData = {
institution: '',
degree: '',
area: '',
score: '',
date: {
start: '',
end: '',
},
url: '',
summary: '',
courses: [],
};
const schema = Joi.object<FormData>().keys({
id: Joi.string(),
institution: Joi.string().required(),
degree: Joi.string().required(),
area: Joi.string().allow(''),
score: Joi.string().allow(''),
date: Joi.object().keys({
start: Joi.string().allow(''),
end: Joi.string().allow(''),
}),
url: Joi.string()
.pattern(/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/, { name: 'valid URL' })
.allow(''),
summary: Joi.string().allow(''),
courses: Joi.array().items(Joi.string().optional()),
});
const EducationModal: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
const item: FormData = get(payload, 'item', null);
const isEditMode = useMemo(() => !!item, [item]);
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
const { reset, control, handleSubmit } = useForm<FormData>({
defaultValues: defaultState,
resolver: joiResolver(schema),
});
const onSubmit = (formData: FormData) => {
if (isEditMode) {
dispatch(editItem({ path: `${path}.items`, value: formData }));
} else {
dispatch(addItem({ path: `${path}.items`, value: formData }));
}
handleClose();
};
const handleClose = () => {
dispatch(
setModalState({
modal: `builder.${path}`,
state: { open: false },
})
);
reset(defaultState);
};
useEffect(() => {
if (!isEmpty(item)) {
reset(item);
}
}, [item, reset]);
return (
<BaseModal
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
isOpen={isOpen}
handleClose={handleClose}
heading={isEditMode ? editText : addText}
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
>
<form className="my-2 grid grid-cols-2 gap-4">
<Controller
name="institution"
control={control}
render={({ field, fieldState }) => (
<TextField
required
autoFocus
label={t('builder.leftSidebar.sections.education.form.institution.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="degree"
control={control}
render={({ field, fieldState }) => (
<TextField
required
label={t('builder.leftSidebar.sections.education.form.degree.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="area"
control={control}
render={({ field, fieldState }) => (
<TextField
label={t('builder.leftSidebar.sections.education.form.area-study.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="score"
control={control}
render={({ field, fieldState }) => (
<TextField
label={t('builder.leftSidebar.sections.education.form.grade.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="date.start"
control={control}
render={({ field, fieldState }) => (
<DatePicker
{...field}
openTo="year"
label={t('builder.common.form.start-date.label')}
views={['year', 'month', 'day']}
onChange={(date: Date, keyboardInputValue: string) => {
isEmpty(keyboardInputValue) && field.onChange('');
dayjs(date).isValid() && field.onChange(date.toISOString());
}}
renderInput={(params) => (
<TextField
{...params}
error={!!fieldState.error}
helperText={fieldState.error?.message || params.inputProps?.placeholder}
/>
)}
/>
)}
/>
<Controller
name="date.end"
control={control}
render={({ field, fieldState }) => (
<DatePicker
{...field}
openTo="year"
label={t('builder.common.form.end-date.label')}
views={['year', 'month', 'day']}
onChange={(date: Date, keyboardInputValue: string) => {
isEmpty(keyboardInputValue) && field.onChange('');
dayjs(date).isValid() && field.onChange(date.toISOString());
}}
renderInput={(params) => (
<TextField
{...params}
error={!!fieldState.error}
helperText={fieldState.error?.message || t('builder.common.form.end-date.help-text')}
/>
)}
/>
)}
/>
<Controller
name="url"
control={control}
render={({ field, fieldState }) => (
<TextField
label={t('builder.common.form.url.label')}
className="col-span-2"
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="summary"
control={control}
render={({ field, fieldState }) => (
<TextField
multiline
minRows={3}
maxRows={6}
label={t('builder.common.form.summary.label')}
className="col-span-2"
error={!!fieldState.error}
helperText={fieldState.error?.message || <MarkdownSupported />}
{...field}
/>
)}
/>
<Controller
name="courses"
control={control}
render={({ field, fieldState }) => (
<ArrayInput
label={t('builder.leftSidebar.sections.education.form.courses.label')}
value={field.value}
onChange={field.onChange}
errors={fieldState.error}
className="col-span-2"
/>
)}
/>
</form>
</BaseModal>
);
};
export default EducationModal;

View File

@ -0,0 +1,122 @@
import { joiResolver } from '@hookform/resolvers/joi';
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
import { Button, TextField } from '@mui/material';
import { Interest, SectionPath } from '@reactive-resume/schema';
import Joi from 'joi';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
import { useTranslation } from 'next-i18next';
import { useEffect, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import ArrayInput from '@/components/shared/ArrayInput';
import BaseModal from '@/components/shared/BaseModal';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';
import { addItem, editItem } from '@/store/resume/resumeSlice';
type FormData = Interest;
const path: SectionPath = 'sections.interests';
const defaultState: FormData = {
name: '',
keywords: [],
};
const schema = Joi.object<FormData>().keys({
id: Joi.string(),
name: Joi.string().required(),
keywords: Joi.array().items(Joi.string().optional()),
});
const InterestModal: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
const item: FormData = get(payload, 'item', null);
const isEditMode = useMemo(() => !!item, [item]);
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
const { reset, control, handleSubmit } = useForm<FormData>({
defaultValues: defaultState,
resolver: joiResolver(schema),
});
const onSubmit = (formData: FormData) => {
if (isEditMode) {
dispatch(editItem({ path: `${path}.items`, value: formData }));
} else {
dispatch(addItem({ path: `${path}.items`, value: formData }));
}
handleClose();
};
const handleClose = () => {
dispatch(
setModalState({
modal: `builder.${path}`,
state: { open: false },
})
);
reset(defaultState);
};
useEffect(() => {
if (!isEmpty(item)) {
reset(item);
}
}, [item, reset]);
return (
<BaseModal
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
isOpen={isOpen}
handleClose={handleClose}
heading={isEditMode ? editText : addText}
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
>
<form className="my-2 grid grid-cols-2 gap-4">
<Controller
name="name"
control={control}
render={({ field, fieldState }) => (
<TextField
required
autoFocus
label={t('builder.common.form.name.label')}
className="col-span-2"
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="keywords"
control={control}
render={({ field, fieldState }) => (
<ArrayInput
label={t('builder.common.form.keywords.label')}
value={field.value}
onChange={field.onChange}
errors={fieldState.error}
className="col-span-2"
/>
)}
/>
</form>
</BaseModal>
);
};
export default InterestModal;

View File

@ -0,0 +1,158 @@
import { joiResolver } from '@hookform/resolvers/joi';
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
import { Button, Slider, TextField } from '@mui/material';
import { Language, SectionPath } from '@reactive-resume/schema';
import Joi from 'joi';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
import { useTranslation } from 'next-i18next';
import { useEffect, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import BaseModal from '@/components/shared/BaseModal';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';
import { addItem, editItem } from '@/store/resume/resumeSlice';
type FormData = Language;
const path: SectionPath = 'sections.languages';
const defaultState: FormData = {
name: '',
level: '',
levelNum: 0,
};
const schema = Joi.object<FormData>().keys({
id: Joi.string(),
name: Joi.string().required(),
level: Joi.string().required(),
levelNum: Joi.number().min(0).max(10).required(),
});
const LanguageModal: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
const item: FormData = get(payload, 'item', null);
const isEditMode = useMemo(() => !!item, [item]);
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
const { reset, control, handleSubmit } = useForm<FormData>({
defaultValues: defaultState,
resolver: joiResolver(schema),
});
const onSubmit = (formData: FormData) => {
if (isEditMode) {
dispatch(editItem({ path: `${path}.items`, value: formData }));
} else {
dispatch(addItem({ path: `${path}.items`, value: formData }));
}
handleClose();
};
const handleClose = () => {
dispatch(
setModalState({
modal: `builder.${path}`,
state: { open: false },
})
);
reset(defaultState);
};
useEffect(() => {
if (!isEmpty(item)) {
reset(item);
}
}, [item, reset]);
return (
<BaseModal
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
isOpen={isOpen}
handleClose={handleClose}
heading={isEditMode ? editText : addText}
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
>
<form className="my-2 grid grid-cols-2 gap-4">
<Controller
name="name"
control={control}
render={({ field, fieldState }) => (
<TextField
required
autoFocus
label={t('builder.common.form.name.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="level"
control={control}
render={({ field, fieldState }) => (
<TextField
required
label={t('builder.common.form.level.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="levelNum"
control={control}
render={({ field }) => (
<div className="col-span-2">
<h4 className="mb-3 font-semibold">{t('builder.common.form.levelNum.label')}</h4>
<div className="px-10">
<Slider
{...field}
marks={[
{
value: 0,
label: 'Disable',
},
{
value: 1,
label: 'Beginner',
},
{
value: 10,
label: 'Expert',
},
]}
min={0}
max={10}
defaultValue={0}
color="secondary"
valueLabelDisplay="auto"
aria-label={t('builder.common.form.levelNum.label')}
/>
</div>
</div>
)}
/>
</form>
</BaseModal>
);
};
export default LanguageModal;

View File

@ -0,0 +1,145 @@
import { joiResolver } from '@hookform/resolvers/joi';
import { Add, AlternateEmail, DriveFileRenameOutline } from '@mui/icons-material';
import { Button, TextField } from '@mui/material';
import { Profile } from '@reactive-resume/schema';
import Joi from 'joi';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
import { useTranslation } from 'next-i18next';
import { useEffect, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import BaseModal from '@/components/shared/BaseModal';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';
import { addItem, editItem } from '@/store/resume/resumeSlice';
type FormData = Profile;
const path = 'sections.profile';
const defaultState: FormData = {
network: '',
username: '',
url: 'https://',
};
const schema = Joi.object<FormData>({
id: Joi.string(),
network: Joi.string().required(),
username: Joi.string().required(),
url: Joi.string()
.pattern(/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/, { name: 'valid URL' })
.default('https://')
.allow(''),
});
const ProfileModal: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
const item: FormData = get(payload, 'item', null);
const isEditMode = useMemo(() => !!item, [item]);
const addText = t('builder.common.actions.add', {
section: t('builder.leftSidebar.sections.profiles.heading', { count: 1 }),
});
const editText = t('builder.common.actions.edit', {
section: t('builder.leftSidebar.sections.profiles.heading', { count: 1 }),
});
const { reset, control, handleSubmit } = useForm<FormData>({
defaultValues: defaultState,
resolver: joiResolver(schema),
});
const onSubmit = (formData: FormData) => {
if (isEditMode) {
dispatch(editItem({ path: 'basics.profiles', value: formData }));
} else {
dispatch(addItem({ path: 'basics.profiles', value: formData }));
}
handleClose();
};
const handleClose = () => {
dispatch(
setModalState({
modal: `builder.${path}`,
state: { open: false },
})
);
reset(defaultState);
};
useEffect(() => {
if (!isEmpty(item)) {
reset(item);
}
}, [item, reset]);
return (
<BaseModal
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
isOpen={isOpen}
heading={isEditMode ? editText : addText}
handleClose={handleClose}
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
>
<form className="my-2 grid grid-cols-2 gap-4">
<Controller
name="network"
control={control}
render={({ field, fieldState }) => (
<TextField
required
autoFocus
label={t('builder.leftSidebar.sections.profiles.form.network.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="username"
control={control}
render={({ field, fieldState }) => (
<TextField
required
label={t('builder.leftSidebar.sections.profiles.form.username.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
InputProps={{
startAdornment: <AlternateEmail className="mr-2" />,
}}
{...field}
/>
)}
/>
<Controller
name="url"
control={control}
render={({ field, fieldState }) => (
<TextField
label={t('builder.common.form.url.label')}
className="col-span-2"
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
</form>
</BaseModal>
);
};
export default ProfileModal;

View File

@ -0,0 +1,233 @@
import { joiResolver } from '@hookform/resolvers/joi';
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
import DatePicker from '@mui/lab/DatePicker';
import { Button, TextField } from '@mui/material';
import { Project, SectionPath } from '@reactive-resume/schema';
import dayjs from 'dayjs';
import Joi from 'joi';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
import { useTranslation } from 'next-i18next';
import { useEffect, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import ArrayInput from '@/components/shared/ArrayInput';
import BaseModal from '@/components/shared/BaseModal';
import MarkdownSupported from '@/components/shared/MarkdownSupported';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';
import { addItem, editItem } from '@/store/resume/resumeSlice';
type FormData = Project;
const path: SectionPath = 'sections.projects';
const defaultState: FormData = {
name: '',
description: '',
date: {
start: '',
end: '',
},
url: '',
summary: '',
keywords: [],
};
const schema = Joi.object<FormData>().keys({
id: Joi.string(),
name: Joi.string().required(),
description: Joi.string().required(),
date: Joi.object().keys({
start: Joi.string().allow(''),
end: Joi.string().allow(''),
}),
url: Joi.string()
.pattern(/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/, { name: 'valid URL' })
.allow(''),
summary: Joi.string().allow(''),
keywords: Joi.array().items(Joi.string().optional()),
});
const ProjectModal: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
const item: FormData = get(payload, 'item', null);
const isEditMode = useMemo(() => !!item, [item]);
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
const { reset, control, handleSubmit } = useForm<FormData>({
defaultValues: defaultState,
resolver: joiResolver(schema),
});
const onSubmit = (formData: FormData) => {
if (isEditMode) {
dispatch(editItem({ path: `${path}.items`, value: formData }));
} else {
dispatch(addItem({ path: `${path}.items`, value: formData }));
}
handleClose();
};
const handleClose = () => {
dispatch(
setModalState({
modal: `builder.${path}`,
state: { open: false },
})
);
reset(defaultState);
};
useEffect(() => {
if (!isEmpty(item)) {
reset(item);
}
}, [item, reset]);
return (
<BaseModal
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
isOpen={isOpen}
handleClose={handleClose}
heading={isEditMode ? editText : addText}
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
>
<form className="my-2 grid grid-cols-2 gap-4">
<Controller
name="name"
control={control}
render={({ field, fieldState }) => (
<TextField
required
autoFocus
label={t('builder.common.form.name.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="description"
control={control}
render={({ field, fieldState }) => (
<TextField
required
label={t('builder.common.form.description.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="date.start"
control={control}
render={({ field, fieldState }) => (
<DatePicker
{...field}
openTo="year"
label={t('builder.common.form.start-date.label')}
views={['year', 'month', 'day']}
onChange={(date: Date, keyboardInputValue: string) => {
isEmpty(keyboardInputValue) && field.onChange('');
dayjs(date).isValid() && field.onChange(date.toISOString());
}}
renderInput={(params) => (
<TextField
{...params}
error={!!fieldState.error}
helperText={fieldState.error?.message || params.inputProps?.placeholder}
/>
)}
/>
)}
/>
<Controller
name="date.end"
control={control}
render={({ field, fieldState }) => (
<DatePicker
{...field}
openTo="year"
label={t('builder.common.form.end-date.label')}
views={['year', 'month', 'day']}
onChange={(date: Date, keyboardInputValue: string) => {
isEmpty(keyboardInputValue) && field.onChange('');
dayjs(date).isValid() && field.onChange(date.toISOString());
}}
renderInput={(params) => (
<TextField
{...params}
error={!!fieldState.error}
helperText={fieldState.error?.message || 'Leave this field blank, if still present'}
/>
)}
/>
)}
/>
<Controller
name="url"
control={control}
render={({ field, fieldState }) => (
<TextField
label={t('builder.common.form.url.label')}
className="col-span-2"
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="summary"
control={control}
render={({ field, fieldState }) => (
<TextField
multiline
minRows={3}
maxRows={6}
label={t('builder.common.form.summary.label')}
className="col-span-2"
error={!!fieldState.error}
helperText={fieldState.error?.message || <MarkdownSupported />}
{...field}
/>
)}
/>
<Controller
name="keywords"
control={control}
render={({ field, fieldState }) => (
<ArrayInput
label={t('builder.common.form.keywords.label')}
value={field.value}
onChange={field.onChange}
errors={fieldState.error}
className="col-span-2"
/>
)}
/>
</form>
</BaseModal>
);
};
export default ProjectModal;

View File

@ -0,0 +1,185 @@
import { joiResolver } from '@hookform/resolvers/joi';
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
import DatePicker from '@mui/lab/DatePicker';
import { Button, TextField } from '@mui/material';
import { Publication, SectionPath } from '@reactive-resume/schema';
import dayjs from 'dayjs';
import Joi from 'joi';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
import { useTranslation } from 'next-i18next';
import { useEffect, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import BaseModal from '@/components/shared/BaseModal';
import MarkdownSupported from '@/components/shared/MarkdownSupported';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';
import { addItem, editItem } from '@/store/resume/resumeSlice';
type FormData = Publication;
const path: SectionPath = 'sections.publications';
const defaultState: FormData = {
name: '',
publisher: '',
date: '',
url: '',
summary: '',
};
const schema = Joi.object<FormData>().keys({
id: Joi.string(),
name: Joi.string().required(),
publisher: Joi.string().required(),
date: Joi.string().allow(''),
url: Joi.string()
.pattern(/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/, { name: 'valid URL' })
.allow(''),
summary: Joi.string().allow(''),
});
const PublicationModal: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
const item: FormData = get(payload, 'item', null);
const isEditMode = useMemo(() => !!item, [item]);
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
const { reset, control, handleSubmit } = useForm<FormData>({
defaultValues: defaultState,
resolver: joiResolver(schema),
});
const onSubmit = (formData: FormData) => {
if (isEditMode) {
dispatch(editItem({ path: `${path}.items`, value: formData }));
} else {
dispatch(addItem({ path: `${path}.items`, value: formData }));
}
handleClose();
};
const handleClose = () => {
dispatch(
setModalState({
modal: `builder.${path}`,
state: { open: false },
})
);
reset(defaultState);
};
useEffect(() => {
if (!isEmpty(item)) {
reset(item);
}
}, [item, reset]);
return (
<BaseModal
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
isOpen={isOpen}
handleClose={handleClose}
heading={isEditMode ? editText : addText}
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
>
<form className="my-2 grid grid-cols-2 gap-4">
<Controller
name="name"
control={control}
render={({ field, fieldState }) => (
<TextField
required
autoFocus
label={t('builder.common.form.name.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="publisher"
control={control}
render={({ field, fieldState }) => (
<TextField
required
label="{t('builder.leftSidebar.sections.publications.form.publisher.label')}"
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="date"
control={control}
render={({ field, fieldState }) => (
<DatePicker
{...field}
openTo="year"
label={t('builder.common.form.date.label')}
views={['year', 'month', 'day']}
onChange={(date: Date, keyboardInputValue: string) => {
isEmpty(keyboardInputValue) && field.onChange('');
dayjs(date).isValid() && field.onChange(date.toISOString());
}}
renderInput={(params) => (
<TextField
{...params}
error={!!fieldState.error}
helperText={fieldState.error?.message || params.inputProps?.placeholder}
/>
)}
/>
)}
/>
<Controller
name="url"
control={control}
render={({ field, fieldState }) => (
<TextField
label={t('builder.common.form.url.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="summary"
control={control}
render={({ field, fieldState }) => (
<TextField
multiline
minRows={3}
maxRows={6}
label={t('builder.common.form.summary.label')}
className="col-span-2"
error={!!fieldState.error}
helperText={fieldState.error?.message || <MarkdownSupported />}
{...field}
/>
)}
/>
</form>
</BaseModal>
);
};
export default PublicationModal;

Some files were not shown because too many files have changed in this diff Show More