🚀 release: v3.0.0

This commit is contained in:
Amruth Pillai
2022-03-02 17:44:11 +01:00
parent 2175256310
commit 295172687b
352 changed files with 30932 additions and 0 deletions
@@ -0,0 +1,91 @@
import { joiResolver } from '@hookform/resolvers/joi';
import { Password } from '@mui/icons-material';
import { Button, TextField } from '@mui/material';
import Joi from 'joi';
import { useTranslation } from 'next-i18next';
import { Controller, useForm } from 'react-hook-form';
import { useMutation } from 'react-query';
import BaseModal from '@/components/shared/BaseModal';
import { forgotPassword, ForgotPasswordParams } from '@/services/auth';
import { ServerError } from '@/services/axios';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';
type FormData = {
email: string;
};
const defaultState: FormData = {
email: '',
};
const schema = Joi.object({
email: Joi.string()
.email({ tlds: { allow: false } })
.required(),
});
const ForgotPasswordModal: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { open: isOpen } = useAppSelector((state) => state.modal['auth.forgot']);
const { mutate, isLoading } = useMutation<void, ServerError, ForgotPasswordParams>(forgotPassword);
const { reset, control, handleSubmit } = useForm<FormData>({
defaultValues: defaultState,
resolver: joiResolver(schema),
});
const handleClose = () => {
dispatch(setModalState({ modal: 'auth.forgot', state: { open: false } }));
reset();
};
const onSubmit = ({ email }: FormData) => {
mutate({ email }, { onSettled: handleClose });
};
return (
<>
<BaseModal
icon={<Password />}
isOpen={isOpen}
heading={t('modals.auth.forgot-password.heading')}
handleClose={handleClose}
footerChildren={
<Button type="submit" disabled={isLoading} onClick={handleSubmit(onSubmit)}>
{t('modals.auth.forgot-password.actions.send-email')}
</Button>
}
>
<div className="grid gap-4">
<p>{t('modals.auth.forgot-password.body')}</p>
<form className="grid gap-4 xl:w-2/3">
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
autoFocus
label={t('modals.auth.forgot-password.form.email.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
</form>
<p className="text-xs">{t('modals.auth.forgot-password.help-text')}</p>
</div>
</BaseModal>
</>
);
};
export default ForgotPasswordModal;
+182
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;
+170
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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -0,0 +1,170 @@
import { joiResolver } from '@hookform/resolvers/joi';
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
import { Button, TextField } from '@mui/material';
import { Reference, SectionPath } from '@reactive-resume/schema';
import Joi from 'joi';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
import { useTranslation } from 'next-i18next';
import { useEffect, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import BaseModal from '@/components/shared/BaseModal';
import MarkdownSupported from '@/components/shared/MarkdownSupported';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';
import { addItem, editItem } from '@/store/resume/resumeSlice';
type FormData = Reference;
const path: SectionPath = 'sections.references';
const defaultState: FormData = {
name: '',
relationship: '',
phone: '',
email: '',
summary: '',
};
const schema = Joi.object<FormData>().keys({
id: Joi.string(),
name: Joi.string().required(),
relationship: Joi.string().required(),
phone: Joi.string().allow(''),
email: Joi.string().allow(''),
summary: Joi.string().allow(''),
});
const ReferenceModal: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
const item: FormData = get(payload, 'item', null);
const isEditMode = useMemo(() => !!item, [item]);
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
const { reset, control, handleSubmit } = useForm<FormData>({
defaultValues: defaultState,
resolver: joiResolver(schema),
});
const onSubmit = (formData: FormData) => {
if (isEditMode) {
dispatch(editItem({ path: `${path}.items`, value: formData }));
} else {
dispatch(addItem({ path: `${path}.items`, value: formData }));
}
handleClose();
};
const handleClose = () => {
dispatch(
setModalState({
modal: `builder.${path}`,
state: { open: false },
})
);
reset(defaultState);
};
useEffect(() => {
if (!isEmpty(item)) {
reset(item);
}
}, [item, reset]);
return (
<BaseModal
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
isOpen={isOpen}
handleClose={handleClose}
heading={isEditMode ? editText : addText}
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
>
<form className="my-2 grid grid-cols-2 gap-4">
<Controller
name="name"
control={control}
render={({ field, fieldState }) => (
<TextField
required
autoFocus
label={t('builder.common.form.name.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="relationship"
control={control}
render={({ field, fieldState }) => (
<TextField
required
label={t('builder.leftSidebar.sections.references.form.relationship.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="phone"
control={control}
render={({ field, fieldState }) => (
<TextField
label={t('builder.common.form.phone.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
label={t('builder.common.form.email.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="summary"
control={control}
render={({ field, fieldState }) => (
<TextField
multiline
minRows={3}
maxRows={6}
label={t('builder.common.form.summary.label')}
className="col-span-2"
error={!!fieldState.error}
helperText={fieldState.error?.message || <MarkdownSupported />}
{...field}
/>
)}
/>
</form>
</BaseModal>
);
};
export default ReferenceModal;
@@ -0,0 +1,175 @@
import { joiResolver } from '@hookform/resolvers/joi';
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
import { Button, Slider, TextField } from '@mui/material';
import { SectionPath, Skill } from '@reactive-resume/schema';
import Joi from 'joi';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
import { useTranslation } from 'next-i18next';
import { useEffect, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import ArrayInput from '@/components/shared/ArrayInput';
import BaseModal from '@/components/shared/BaseModal';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';
import { addItem, editItem } from '@/store/resume/resumeSlice';
type FormData = Skill;
const path: SectionPath = 'sections.skills';
const defaultState: FormData = {
name: '',
level: '',
levelNum: 0,
keywords: [],
};
const schema = Joi.object<FormData>().keys({
id: Joi.string(),
name: Joi.string().required(),
level: Joi.string().required(),
levelNum: Joi.number().min(0).max(10).required(),
keywords: Joi.array().items(Joi.string().optional()),
});
const SkillModal: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
const item: FormData = get(payload, 'item', null);
const isEditMode = useMemo(() => !!item, [item]);
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
const { reset, control, handleSubmit } = useForm<FormData>({
defaultValues: defaultState,
resolver: joiResolver(schema),
});
const onSubmit = (formData: FormData) => {
if (isEditMode) {
dispatch(editItem({ path: `${path}.items`, value: formData }));
} else {
dispatch(addItem({ path: `${path}.items`, value: formData }));
}
handleClose();
};
const handleClose = () => {
dispatch(
setModalState({
modal: `builder.${path}`,
state: { open: false },
})
);
reset(defaultState);
};
useEffect(() => {
if (!isEmpty(item)) {
reset(item);
}
}, [item, reset]);
return (
<BaseModal
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
isOpen={isOpen}
handleClose={handleClose}
heading={isEditMode ? editText : addText}
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
>
<form className="my-2 grid grid-cols-2 gap-4">
<Controller
name="name"
control={control}
render={({ field, fieldState }) => (
<TextField
required
autoFocus
label={t('builder.common.form.name.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="level"
control={control}
render={({ field, fieldState }) => (
<TextField
required
label={t('builder.common.form.level.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="levelNum"
control={control}
render={({ field }) => (
<div className="col-span-2">
<h4 className="mb-3 font-semibold">{t('builder.common.form.levelNum.label')}</h4>
<div className="px-10">
<Slider
{...field}
marks={[
{
value: 0,
label: 'Disable',
},
{
value: 1,
label: 'Beginner',
},
{
value: 10,
label: 'Expert',
},
]}
min={0}
max={10}
defaultValue={0}
color="secondary"
valueLabelDisplay="auto"
aria-label={t('builder.common.form.levelNum.label')}
/>
</div>
</div>
)}
/>
<Controller
name="keywords"
control={control}
render={({ field, fieldState }) => (
<ArrayInput
label={t('builder.common.form.keywords.label')}
value={field.value}
onChange={field.onChange}
errors={fieldState.error}
className="col-span-2"
/>
)}
/>
</form>
</BaseModal>
);
};
export default SkillModal;
@@ -0,0 +1,216 @@
import { joiResolver } from '@hookform/resolvers/joi';
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
import DatePicker from '@mui/lab/DatePicker';
import { Button, TextField } from '@mui/material';
import { SectionPath, Volunteer } from '@reactive-resume/schema';
import dayjs from 'dayjs';
import Joi from 'joi';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
import { useTranslation } from 'next-i18next';
import { useEffect, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import BaseModal from '@/components/shared/BaseModal';
import MarkdownSupported from '@/components/shared/MarkdownSupported';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';
import { addItem, editItem } from '@/store/resume/resumeSlice';
type FormData = Volunteer;
const path: SectionPath = 'sections.volunteer';
const defaultState: FormData = {
organization: '',
position: '',
date: {
start: '',
end: '',
},
url: '',
summary: '',
};
const schema = Joi.object<FormData>().keys({
id: Joi.string(),
organization: Joi.string().required(),
position: Joi.string().required(),
date: Joi.object().keys({
start: Joi.string().allow(''),
end: Joi.string().allow(''),
}),
url: Joi.string()
.pattern(/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/, { name: 'valid URL' })
.allow(''),
summary: Joi.string().allow(''),
});
const VolunteerModal: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
const item: FormData = get(payload, 'item', null);
const isEditMode = useMemo(() => !!item, [item]);
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
const { reset, control, handleSubmit } = useForm<FormData>({
defaultValues: defaultState,
resolver: joiResolver(schema),
});
const onSubmit = (formData: FormData) => {
if (isEditMode) {
dispatch(editItem({ path: `${path}.items`, value: formData }));
} else {
dispatch(addItem({ path: `${path}.items`, value: formData }));
}
handleClose();
};
const handleClose = () => {
dispatch(
setModalState({
modal: `builder.${path}`,
state: { open: false },
})
);
reset(defaultState);
};
useEffect(() => {
if (!isEmpty(item)) {
reset(item);
}
}, [item, reset]);
return (
<BaseModal
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
isOpen={isOpen}
handleClose={handleClose}
heading={isEditMode ? editText : addText}
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
>
<form className="my-2 grid grid-cols-2 gap-4">
<Controller
name="organization"
control={control}
render={({ field, fieldState }) => (
<TextField
required
autoFocus
label={t('builder.leftSidebar.sections.volunteer.form.organization.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="position"
control={control}
render={({ field, fieldState }) => (
<TextField
required
label={t('builder.common.form.position.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="date.start"
control={control}
render={({ field, fieldState }) => (
<DatePicker
{...field}
openTo="year"
label={t('builder.common.form.start-date.label')}
views={['year', 'month', 'day']}
onChange={(date: Date, keyboardInputValue: string) => {
isEmpty(keyboardInputValue) && field.onChange('');
dayjs(date).isValid() && field.onChange(date.toISOString());
}}
renderInput={(params) => (
<TextField
{...params}
error={!!fieldState.error}
helperText={fieldState.error?.message || params.inputProps?.placeholder}
/>
)}
/>
)}
/>
<Controller
name="date.end"
control={control}
render={({ field, fieldState }) => (
<DatePicker
{...field}
openTo="year"
label={t('builder.common.form.end-date.label')}
views={['year', 'month', 'day']}
onChange={(date: Date, keyboardInputValue: string) => {
isEmpty(keyboardInputValue) && field.onChange('');
dayjs(date).isValid() && field.onChange(date.toISOString());
}}
renderInput={(params) => (
<TextField
{...params}
error={!!fieldState.error}
helperText={fieldState.error?.message || 'Leave this field blank, if still present'}
/>
)}
/>
)}
/>
<Controller
name="url"
control={control}
render={({ field, fieldState }) => (
<TextField
label={t('builder.common.form.url.label')}
className="col-span-2"
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="summary"
control={control}
render={({ field, fieldState }) => (
<TextField
multiline
minRows={3}
maxRows={6}
label={t('builder.common.form.summary.label')}
className="col-span-2"
error={!!fieldState.error}
helperText={fieldState.error?.message || <MarkdownSupported />}
{...field}
/>
)}
/>
</form>
</BaseModal>
);
};
export default VolunteerModal;
@@ -0,0 +1,216 @@
import { joiResolver } from '@hookform/resolvers/joi';
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
import DatePicker from '@mui/lab/DatePicker';
import { Button, TextField } from '@mui/material';
import { SectionPath, WorkExperience } from '@reactive-resume/schema';
import dayjs from 'dayjs';
import Joi from 'joi';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
import { useTranslation } from 'next-i18next';
import { useEffect, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import BaseModal from '@/components/shared/BaseModal';
import MarkdownSupported from '@/components/shared/MarkdownSupported';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';
import { addItem, editItem } from '@/store/resume/resumeSlice';
type FormData = WorkExperience;
const path: SectionPath = 'sections.work';
const defaultState: FormData = {
name: '',
position: '',
date: {
start: '',
end: '',
},
url: '',
summary: '',
};
const schema = Joi.object<FormData>().keys({
id: Joi.string(),
name: Joi.string().required(),
position: Joi.string().required(),
date: Joi.object().keys({
start: Joi.string().allow(''),
end: Joi.string().allow(''),
}),
url: Joi.string()
.pattern(/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/, { name: 'valid URL' })
.allow(''),
summary: Joi.string().allow(''),
});
const WorkModal: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const heading = useAppSelector((state) => get(state.resume, `${path}.name`));
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
const item: FormData = get(payload, 'item', null);
const isEditMode = useMemo(() => !!item, [item]);
const addText = useMemo(() => t('builder.common.actions.add', { token: heading }), [t, heading]);
const editText = useMemo(() => t('builder.common.actions.edit', { token: heading }), [t, heading]);
const { reset, control, handleSubmit } = useForm<FormData>({
defaultValues: defaultState,
resolver: joiResolver(schema),
});
const onSubmit = (formData: FormData) => {
if (isEditMode) {
dispatch(editItem({ path: `${path}.items`, value: formData }));
} else {
dispatch(addItem({ path: `${path}.items`, value: formData }));
}
handleClose();
};
const handleClose = () => {
dispatch(
setModalState({
modal: `builder.${path}`,
state: { open: false },
})
);
reset(defaultState);
};
useEffect(() => {
if (!isEmpty(item)) {
reset(item);
}
}, [item, reset]);
return (
<BaseModal
icon={isEditMode ? <DriveFileRenameOutline /> : <Add />}
isOpen={isOpen}
handleClose={handleClose}
heading={isEditMode ? editText : addText}
footerChildren={<Button onClick={handleSubmit(onSubmit)}>{isEditMode ? editText : addText}</Button>}
>
<form className="my-2 grid grid-cols-2 gap-4">
<Controller
name="name"
control={control}
render={({ field, fieldState }) => (
<TextField
required
autoFocus
label={t('builder.common.form.name.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="position"
control={control}
render={({ field, fieldState }) => (
<TextField
required
label={t('builder.common.form.position.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="date.start"
control={control}
render={({ field, fieldState }) => (
<DatePicker
{...field}
openTo="year"
label={t('builder.common.form.start-date.label')}
views={['year', 'month', 'day']}
onChange={(date: Date, keyboardInputValue: string) => {
isEmpty(keyboardInputValue) && field.onChange('');
dayjs(date).isValid() && field.onChange(date.toISOString());
}}
renderInput={(params) => (
<TextField
{...params}
error={!!fieldState.error}
helperText={fieldState.error?.message || params.inputProps?.placeholder}
/>
)}
/>
)}
/>
<Controller
name="date.end"
control={control}
render={({ field, fieldState }) => (
<DatePicker
{...field}
openTo="year"
label={t('builder.common.form.end-date.label')}
views={['year', 'month', 'day']}
onChange={(date: Date, keyboardInputValue: string) => {
isEmpty(keyboardInputValue) && field.onChange('');
dayjs(date).isValid() && field.onChange(date.toISOString());
}}
renderInput={(params) => (
<TextField
{...params}
error={!!fieldState.error}
helperText={fieldState.error?.message || t('builder.common.form.end-date.help-text')}
/>
)}
/>
)}
/>
<Controller
name="url"
control={control}
render={({ field, fieldState }) => (
<TextField
label={t('builder.common.form.url.label')}
className="col-span-2"
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="summary"
control={control}
render={({ field, fieldState }) => (
<TextField
multiline
minRows={3}
maxRows={6}
label={t('builder.common.form.summary.label')}
className="col-span-2"
error={!!fieldState.error}
helperText={fieldState.error?.message || <MarkdownSupported />}
{...field}
/>
)}
/>
</form>
</BaseModal>
);
};
export default WorkModal;
@@ -0,0 +1,143 @@
import { joiResolver } from '@hookform/resolvers/joi';
import { Add } from '@mui/icons-material';
import { Button, FormControlLabel, FormGroup, Switch, TextField } from '@mui/material';
import { Resume } from '@reactive-resume/schema';
import Joi from 'joi';
import { useTranslation } from 'next-i18next';
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
import { useMutation } from 'react-query';
import BaseModal from '@/components/shared/BaseModal';
import { RESUMES_QUERY } from '@/constants/index';
import { ServerError } from '@/services/axios';
import queryClient from '@/services/react-query';
import { createResume, CreateResumeParams } from '@/services/resume';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';
type FormData = {
name: string;
slug: string;
isPublic: boolean;
};
const defaultState: FormData = {
name: '',
slug: '',
isPublic: true,
};
const schema = Joi.object({
name: Joi.string().required(),
slug: Joi.string()
.lowercase()
.min(3)
.regex(/^[a-z0-9-]+$/, 'only lowercase characters, numbers and hyphens')
.required(),
isPublic: Joi.boolean().default(true).required(),
});
const CreateResumeModal: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { open: isOpen } = useAppSelector((state) => state.modal['dashboard.create-resume']);
const { mutateAsync, isLoading } = useMutation<Resume, ServerError, CreateResumeParams>(createResume);
const { reset, watch, control, setValue, handleSubmit } = useForm<FormData>({
defaultValues: defaultState,
resolver: joiResolver(schema),
});
const name = watch('name');
useEffect(() => {
const slug = name
? name
.toLowerCase()
.replace(/[`~!@#$%^&*()_|+=?;:'",.<>{}[]\\\/]/gi, '')
.replace(/[ ]/gi, '-')
: '';
setValue('slug', slug);
}, [name, setValue]);
const onSubmit = async ({ name, slug, isPublic }: FormData) => {
try {
await mutateAsync({ name, slug, public: isPublic });
queryClient.invalidateQueries(RESUMES_QUERY);
handleClose();
} catch (error) {
toast.error(error.message);
}
};
const handleClose = () => {
dispatch(setModalState({ modal: 'dashboard.create-resume', state: { open: false } }));
reset();
};
return (
<BaseModal
isOpen={isOpen}
icon={<Add />}
heading={t('modals.dashboard.create-resume.heading')}
handleClose={handleClose}
footerChildren={
<Button type="submit" disabled={isLoading} onClick={handleSubmit(onSubmit)}>
{t('modals.dashboard.create-resume.actions.create-resume')}
</Button>
}
>
<p>{t('modals.dashboard.create-resume.body')}</p>
<form className="grid gap-4">
<Controller
name="name"
control={control}
render={({ field, fieldState }) => (
<TextField
autoFocus
label={t('modals.dashboard.create-resume.form.name.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="slug"
control={control}
render={({ field, fieldState }) => (
<TextField
label={t('modals.dashboard.create-resume.form.slug.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<FormGroup>
<FormControlLabel
label={t('modals.dashboard.create-resume.form.public.label') as string}
control={
<Controller
name="isPublic"
control={control}
render={({ field }) => <Switch defaultChecked color="secondary" {...field} />}
/>
}
/>
</FormGroup>
</form>
</BaseModal>
);
};
export default CreateResumeModal;
@@ -0,0 +1,197 @@
import { Code, ImportExport, LinkedIn, TrackChanges, UploadFile } from '@mui/icons-material';
import { Button, Divider } from '@mui/material';
import { Integration, Resume } from '@reactive-resume/schema';
import { Trans, useTranslation } from 'next-i18next';
import { useRef } from 'react';
import toast from 'react-hot-toast';
import { useMutation } from 'react-query';
import BaseModal from '@/components/shared/BaseModal';
import { RESUMES_QUERY } from '@/constants/index';
import { ServerError } from '@/services/axios';
import { importFromExternal, ImportFromExternalParams } from '@/services/integrations';
import queryClient from '@/services/react-query';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';
const FILE_UPLOAD_MAX_SIZE = 2000000; // 2 MB
const ImportExternalModal: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const linkedinInputRef = useRef<HTMLInputElement>(null);
const jsonResumeInputRef = useRef<HTMLInputElement>(null);
const reactiveResumeInputRef = useRef<HTMLInputElement>(null);
const { open: isOpen } = useAppSelector((state) => state.modal['dashboard.import-external']);
const { mutateAsync, isLoading } = useMutation<Resume, ServerError, ImportFromExternalParams>(importFromExternal);
const handleClose = () => {
dispatch(setModalState({ modal: 'dashboard.import-external', state: { open: false } }));
};
const handleClick = (integration: Integration) => {
if (integration === 'linkedin') {
if (linkedinInputRef.current) {
linkedinInputRef.current.click();
linkedinInputRef.current.value = '';
}
} else if (integration === 'json-resume') {
if (jsonResumeInputRef.current) {
jsonResumeInputRef.current.click();
jsonResumeInputRef.current.value = '';
}
} else if (integration === 'reactive-resume') {
if (reactiveResumeInputRef.current) {
reactiveResumeInputRef.current.click();
reactiveResumeInputRef.current.value = '';
}
}
};
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>, integration: Integration) => {
if (event.target.files && event.target.files[0]) {
const file = event.target.files[0];
if (file.size > FILE_UPLOAD_MAX_SIZE) {
toast.error(t('common.toast.error.upload-file-size'));
return;
}
await mutateAsync({ integration, file });
queryClient.invalidateQueries(RESUMES_QUERY);
handleClose();
}
};
return (
<BaseModal
isOpen={isOpen}
icon={<ImportExport />}
heading={t('modals.dashboard.import-external.heading')}
handleClose={handleClose}
>
<div className="grid gap-5">
<h2 className="inline-flex items-center gap-2 text-lg font-medium">
<LinkedIn />
{t('modals.dashboard.import-external.linkedin.heading')}
</h2>
<p className="mb-2">
<Trans t={t} i18nKey="modals.dashboard.import-external.linkedin.body">
You can save time by exporting your data from LinkedIn and using it to auto-fill fields on Reactive Resume.
Head on over to the
<a
href="https://www.linkedin.com/psettings/member-data"
className="underline"
rel="noreferrer"
target="_blank"
>
Data Privacy
</a>
section on LinkedIn and request an archive of your data. Once it is available, upload the ZIP archive below.
</Trans>
</p>
<div>
<Button
variant="contained"
disabled={isLoading}
startIcon={<UploadFile />}
onClick={() => handleClick('linkedin')}
>
{t('modals.dashboard.import-external.linkedin.actions.upload-archive')}
</Button>
<input
hidden
type="file"
ref={linkedinInputRef}
onChange={(event) => handleChange(event, 'linkedin')}
accept="application/zip"
/>
</div>
</div>
<Divider className="py-2" />
<div className="grid gap-5">
<h2 className="inline-flex items-center gap-2 text-lg font-medium">
<Code />
{t('modals.dashboard.import-external.json-resume.heading')}
</h2>
<p className="mb-2">
<Trans t={t} i18nKey="modals.dashboard.import-external.json-resume.body">
If you have a
<a
href="https://github.com/jsonresume/resume-schema"
className="underline"
rel="noreferrer"
target="_blank"
>
validated JSON Resume
</a>
ready to go, you can use it to fast-track your development on Reactive Resume. Click the button below and
upload a valid JSON file to begin.
</Trans>
</p>
<div>
<Button
variant="contained"
disabled={isLoading}
startIcon={<UploadFile />}
onClick={() => handleClick('json-resume')}
>
{t('modals.dashboard.import-external.json-resume.actions.upload-json')}
</Button>
<input
hidden
type="file"
ref={jsonResumeInputRef}
onChange={(event) => handleChange(event, 'json-resume')}
accept="application/json"
/>
</div>
</div>
<Divider className="py-2" />
<div className="grid gap-5">
<h2 className="inline-flex items-center gap-2 text-lg font-medium">
<TrackChanges />
{t('modals.dashboard.import-external.reactive-resume.heading')}
</h2>
<p className="mb-2">{t('modals.dashboard.import-external.reactive-resume.body')}</p>
<div>
<Button
variant="contained"
disabled={isLoading}
startIcon={<UploadFile />}
onClick={() => handleClick('reactive-resume')}
>
{t('modals.dashboard.import-external.reactive-resume.actions.upload-json')}
</Button>
<input
hidden
type="file"
ref={reactiveResumeInputRef}
onChange={(event) => handleChange(event, 'reactive-resume')}
accept="application/json"
/>
</div>
</div>
</BaseModal>
);
};
export default ImportExternalModal;
@@ -0,0 +1,135 @@
import { joiResolver } from '@hookform/resolvers/joi';
import { DriveFileRenameOutline } from '@mui/icons-material';
import { Button, TextField } from '@mui/material';
import { Resume } from '@reactive-resume/schema';
import Joi from 'joi';
import get from 'lodash/get';
import noop from 'lodash/noop';
import { useTranslation } from 'next-i18next';
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useMutation } from 'react-query';
import BaseModal from '@/components/shared/BaseModal';
import { RESUMES_QUERY } from '@/constants/index';
import { ServerError } from '@/services/axios';
import queryClient from '@/services/react-query';
import { renameResume, RenameResumeParams } from '@/services/resume';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { ModalState, setModalState } from '@/store/modal/modalSlice';
type FormData = {
name: string;
slug: string;
};
const schema = Joi.object({
name: Joi.string().required(),
slug: Joi.string()
.lowercase()
.min(3)
.regex(/^[a-z0-9-]+$/, 'only lowercase characters, numbers and hyphens')
.required(),
});
const RenameResumeModal: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { open: isOpen, payload } = useAppSelector((state) => state.modal['dashboard.rename-resume']) as ModalState;
const resume: Resume = get(payload, 'item') as Resume;
const onComplete = get(payload, 'onComplete', noop);
const { mutateAsync, isLoading } = useMutation<Resume, ServerError, RenameResumeParams>(renameResume);
const { reset, watch, control, setValue, handleSubmit } = useForm<FormData>({
defaultValues: {
name: resume?.name,
slug: resume?.slug,
},
resolver: joiResolver(schema),
});
const name = watch('name');
useEffect(() => {
const slug = name
? name
.toLowerCase()
.replace(/[`~!@#$%^&*()_|+=?;:'",.<>{}[]\\\/]/gi, '')
.replace(/[ ]/gi, '-')
: '';
setValue('slug', slug);
}, [name, setValue]);
useEffect(() => {
if (!resume) return;
const { name, slug }: FormData = resume;
reset({ name, slug });
}, [resume, reset]);
const onSubmit = async ({ name, slug }: FormData) => {
if (!resume) return;
const newResume = await mutateAsync({ id: resume.id, name, slug });
onComplete && onComplete(newResume);
queryClient.invalidateQueries(RESUMES_QUERY);
handleClose();
};
const handleClose = () => {
dispatch(setModalState({ modal: 'dashboard.rename-resume', state: { open: false } }));
reset();
};
return (
<BaseModal
icon={<DriveFileRenameOutline />}
isOpen={isOpen}
heading={t('modals.dashboard.rename-resume.heading')}
handleClose={handleClose}
footerChildren={
<Button type="submit" disabled={isLoading} onClick={handleSubmit(onSubmit)}>
{t('modals.dashboard.rename-resume.actions.rename-resume')}
</Button>
}
>
<form className="grid gap-4">
<Controller
name="name"
control={control}
render={({ field, fieldState }) => (
<TextField
autoFocus
label={t('modals.dashboard.rename-resume.form.name.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<Controller
name="slug"
control={control}
render={({ field, fieldState }) => (
<TextField
label={t('modals.dashboard.rename-resume.form.slug.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
</form>
</BaseModal>
);
};
export default RenameResumeModal;
+79
View File
@@ -0,0 +1,79 @@
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useAppDispatch } from '@/store/hooks';
import { ModalName, setModalState } from '@/store/modal/modalSlice';
import ForgotPasswordModal from './auth/ForgotPasswordModal';
import LoginModal from './auth/LoginModal';
import RegisterModal from './auth/RegisterModal';
import ResetPasswordModal from './auth/ResetPasswordModal';
import AwardModal from './builder/sections/AwardModal';
import CertificateModal from './builder/sections/CertificateModal';
import CustomModal from './builder/sections/CustomModal';
import EducationModal from './builder/sections/EducationModal';
import InterestModal from './builder/sections/InterestModal';
import LanguageModal from './builder/sections/LanguageModal';
import ProfileModal from './builder/sections/ProfileModal';
import ProjectModal from './builder/sections/ProjectModal';
import PublicationModal from './builder/sections/PublicationModal';
import ReferenceModal from './builder/sections/ReferenceModal';
import SkillModal from './builder/sections/SkillModal';
import VolunteerModal from './builder/sections/VolunteerModal';
import WorkModal from './builder/sections/WorkModal';
import CreateResumeModal from './dashboard/CreateResumeModal';
import ImportExternalModal from './dashboard/ImportExternalModal';
import RenameResumeModal from './dashboard/RenameResumeModal';
type QueryParams = {
modal?: ModalName;
};
const ModalWrapper: React.FC = () => {
const router = useRouter();
const dispatch = useAppDispatch();
useEffect(() => {
const { modal, ...rest } = router.query as QueryParams;
if (!modal) return;
dispatch(setModalState({ modal, state: { open: true, payload: rest } }));
}, [router.query, dispatch]);
return (
<>
{/* Authentication */}
<LoginModal />
<RegisterModal />
<ForgotPasswordModal />
<ResetPasswordModal />
{/* Dashboard */}
<CreateResumeModal />
<ImportExternalModal />
<RenameResumeModal />
{/* Builder */}
{/* Sections */}
<ProfileModal />
<WorkModal />
<EducationModal />
<AwardModal />
<CertificateModal />
<PublicationModal />
<SkillModal />
<LanguageModal />
<InterestModal />
<VolunteerModal />
<ProjectModal />
<ReferenceModal />
{/* Custom Sections */}
<CustomModal />
</>
);
};
export default ModalWrapper;