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