├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── components │ ├── Button.tsx │ ├── ExpanderWithHeightTransition.tsx │ ├── FlexboxSpacer.tsx │ ├── Resume │ │ ├── ResumeControlBar.tsx │ │ ├── ResumeIFrame.tsx │ │ ├── ResumePDF │ │ │ ├── ResumePDFCustom.tsx │ │ │ ├── ResumePDFEducation.tsx │ │ │ ├── ResumePDFProfile.tsx │ │ │ ├── ResumePDFProject.tsx │ │ │ ├── ResumePDFSkills.tsx │ │ │ ├── ResumePDFWorkExperience.tsx │ │ │ ├── common │ │ │ │ ├── ResumePDFIcon.tsx │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── hooks.tsx │ │ └── index.tsx │ ├── ResumeDropzone.tsx │ ├── ResumeForm │ │ ├── CustomForm.tsx │ │ ├── EducationsForm.tsx │ │ ├── Form │ │ │ ├── FeaturedSkillInput.tsx │ │ │ ├── IconButton.tsx │ │ │ ├── InputGroup.tsx │ │ │ └── index.tsx │ │ ├── ProfileForm.tsx │ │ ├── ProjectsForm.tsx │ │ ├── SkillsForm.tsx │ │ ├── ThemeForm │ │ │ ├── InlineInput.tsx │ │ │ ├── Selection.tsx │ │ │ ├── constants.ts │ │ │ └── index.tsx │ │ ├── WorkExperiencesForm.tsx │ │ ├── index.tsx │ │ └── types.ts │ ├── Tooltip.tsx │ ├── TopNavBar.tsx │ ├── documentation │ │ ├── Heading.tsx │ │ └── Paragraph.tsx │ └── fonts │ │ ├── constants.ts │ │ └── hooks.tsx ├── favicon.ico ├── globals.css ├── home │ ├── AutoTypingResume.tsx │ ├── Hero.tsx │ ├── Steps.tsx │ └── constants.ts ├── layout.tsx ├── lib │ ├── constants.ts │ ├── cx.ts │ ├── deep-merge.ts │ ├── make-object-char-iterator.ts │ ├── parse-resume-from-pdf │ │ ├── deep-clone.ts │ │ ├── extract-resume-from-sections │ │ │ ├── extract-education.ts │ │ │ ├── extract-profile.ts │ │ │ ├── extract-project.ts │ │ │ ├── extract-skill.ts │ │ │ ├── extract-work-experience.ts │ │ │ ├── index.ts │ │ │ └── lib │ │ │ │ ├── bullet-points.ts │ │ │ │ ├── common-features.ts │ │ │ │ ├── feature-scoring-system.ts │ │ │ │ ├── get-section-lines.ts │ │ │ │ └── subsections.ts │ │ ├── group-lines-into-sections.ts │ │ ├── group-text-items-into-lines.ts │ │ ├── index.ts │ │ ├── read-pdf.ts │ │ └── types.ts │ └── redux │ │ ├── hooks.tsx │ │ ├── local-storage.ts │ │ ├── resumeSlice.ts │ │ ├── settingsSlice.ts │ │ ├── store.ts │ │ └── types.ts ├── page.tsx ├── resume-builder │ └── page.tsx ├── resume-import │ └── page.tsx └── resume-parser │ ├── ResumeParserAlgorithmArticle.tsx │ ├── ResumeTable.tsx │ └── page.tsx ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── assets │ ├── add-pdf.svg │ ├── dots.svg │ └── heart.svg ├── fonts │ ├── Caladea-Bold.ttf │ ├── Caladea-Regular.ttf │ ├── Lato-Bold.ttf │ ├── Lato-Regular.ttf │ ├── Lora-Bold.ttf │ ├── Lora-Regular.ttf │ ├── Merriweather-Bold.ttf │ ├── Merriweather-Regular.ttf │ ├── Montserrat-Bold.ttf │ ├── Montserrat-Regular.ttf │ ├── OFL.txt │ ├── OpenSans-Bold.ttf │ ├── OpenSans-Regular.ttf │ ├── PlayfairDisplay-Bold.ttf │ ├── PlayfairDisplay-Regular.ttf │ ├── Raleway-Bold.ttf │ ├── Raleway-Regular.ttf │ ├── Roboto-Bold.ttf │ ├── Roboto-Regular.ttf │ ├── RobotoSlab-Bold.ttf │ ├── RobotoSlab-Regular.ttf │ └── fonts.css ├── next.svg ├── resume-example │ ├── inhouse-resume.pdf │ └── public-resume.pdf └── vercel.svg ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /app/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { cx } from "../lib/cx"; 2 | import { ToolTip } from "./Tooltip"; 3 | 4 | type ReactButtonProps = React.ComponentProps<"button">; 5 | type ReactAnchorProps = React.ComponentProps<"a">; 6 | 7 | type ButtonProps = ReactAnchorProps | ReactButtonProps; 8 | 9 | const isAnchor = (props: ButtonProps): props is ReactAnchorProps => { 10 | return "href" in props; 11 | }; 12 | 13 | export const Button = (props: ButtonProps) => { 14 | if (isAnchor(props)) { 15 | return ; 16 | } else { 17 | return 151 | 152 | )} 153 |
154 | {!hasFile ? ( 155 | <> 156 | 170 | {hasNonPdfFile && ( 171 |

Only pdf file is supported

172 | )} 173 | 174 | ) : ( 175 | <> 176 | {!playgroundView && ( 177 | 184 | )} 185 |

186 | Note: {!playgroundView ? "Import" : "Parser"} works best on 187 | single column resume 188 |

189 | 190 | )} 191 |
192 | 193 | 194 | ); 195 | }; 196 | 197 | const getFileSizeString = (filesizeB: number) => { 198 | const fileSizeKB = filesizeB / 1024; 199 | const fileSizeMB = fileSizeKB / 1024; 200 | if (fileSizeKB < 1000) { 201 | return fileSizeKB.toPrecision(3) + " KB"; 202 | } else { 203 | return fileSizeMB.toPrecision(3) + " MB"; 204 | } 205 | }; 206 | 207 | export default ResumeDropzone; 208 | -------------------------------------------------------------------------------- /app/components/ResumeForm/CustomForm.tsx: -------------------------------------------------------------------------------- 1 | import { useAppDispatch, useAppSelector } from "@/app/lib/redux/hooks"; 2 | import { 3 | changeCustom, 4 | changeSkills, 5 | selectCustom, 6 | selectSkills, 7 | } from "@/app/lib/redux/resumeSlice"; 8 | import { 9 | changeShowBulletPoints, 10 | selectShowBulletPoints, 11 | selectThemeColor, 12 | } from "@/app/lib/redux/settingsSlice"; 13 | import { Form } from "./Form"; 14 | import { BulletListTextArea, InputGroupWrapper } from "./Form/InputGroup"; 15 | import { BulletListIconButton } from "./Form/IconButton"; 16 | import { FeaturedSkillInput } from "./Form/FeaturedSkillInput"; 17 | 18 | export const CustomForm = () => { 19 | const custom = useAppSelector(selectCustom); 20 | const dispatch = useAppDispatch(); 21 | const { descriptions } = custom; 22 | const form = "custom"; 23 | const showBulletPoints = useAppSelector(selectShowBulletPoints(form)); 24 | 25 | const handleCustomChange = (field: "descriptions", value: string[]) => { 26 | dispatch(changeCustom({ field, value })); 27 | }; 28 | 29 | const handleShowBulletPoints = (value: boolean) => { 30 | dispatch(changeShowBulletPoints({ field: form, value })); 31 | }; 32 | 33 | return ( 34 |
35 |
36 |
37 | 46 |
47 | 51 |
52 |
53 |
54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /app/components/ResumeForm/EducationsForm.tsx: -------------------------------------------------------------------------------- 1 | import { useAppDispatch, useAppSelector } from "@/app/lib/redux/hooks"; 2 | import { 3 | changeEducations, 4 | selectEducations, 5 | } from "@/app/lib/redux/resumeSlice"; 6 | import { 7 | changeShowBulletPoints, 8 | selectShowBulletPoints, 9 | } from "@/app/lib/redux/settingsSlice"; 10 | import { Form, FormSection } from "./Form"; 11 | import { CreateHandleChangeArgsWithDescriptions } from "./types"; 12 | import { ResumeEducation } from "@/app/lib/redux/types"; 13 | import { BulletListTextArea, Input } from "./Form/InputGroup"; 14 | import { BulletListIconButton } from "./Form/IconButton"; 15 | 16 | export const EducationsForm = () => { 17 | const educations = useAppSelector(selectEducations); 18 | const dispatch = useAppDispatch(); 19 | const showDelete = educations.length > 1; 20 | const form = "educations"; 21 | const showBulletPoints = useAppSelector(selectShowBulletPoints(form)); 22 | 23 | return ( 24 |
25 | {educations.map(({ school, degree, gpa, date, descriptions }, idx) => { 26 | const handleWorkExperienceChange = ( 27 | ...[ 28 | field, 29 | value, 30 | ]: CreateHandleChangeArgsWithDescriptions 31 | ) => { 32 | dispatch(changeEducations({ idx, field, value } as any)); 33 | }; 34 | 35 | const handleShowBulletPoints = (value: boolean) => { 36 | dispatch(changeShowBulletPoints({ field: form, value })); 37 | }; 38 | 39 | const showMoveUp = idx !== 0; 40 | const showMoveDown = idx !== educations.length - 1; 41 | 42 | return ( 43 | 52 | 60 | 68 | 76 | 84 |
85 | 94 |
95 | 99 |
100 |
101 |
102 | ); 103 | })} 104 | 105 | ); 106 | }; 107 | -------------------------------------------------------------------------------- /app/components/ResumeForm/Form/FeaturedSkillInput.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { INPUT_CLASS_NAME } from "./InputGroup"; 3 | 4 | export const FeaturedSkillInput = ({ 5 | skill, 6 | rating, 7 | setSkillRating, 8 | placeholder, 9 | className, 10 | circleColor, 11 | }: { 12 | skill: string; 13 | rating: number; 14 | setSkillRating: (skill: string, rating: number) => void; 15 | placeholder: string; 16 | className?: string; 17 | circleColor?: string; 18 | }) => { 19 | return ( 20 |
21 | setSkillRating(e.target.value, rating)} 26 | className={INPUT_CLASS_NAME} 27 | /> 28 | setSkillRating(skill, newRating)} 31 | circleColor={circleColor} 32 | /> 33 |
34 | ); 35 | }; 36 | 37 | const CircleRating = ({ 38 | rating, 39 | setRating, 40 | circleColor = "#38bdf8", 41 | }: { 42 | rating: number; 43 | setRating: (rating: number) => void; 44 | circleColor?: string; 45 | }) => { 46 | const numCircles = 5; 47 | const [hoverRating, setHoverRating] = useState(null); 48 | 49 | return ( 50 |
51 | {[...Array(numCircles)].map((_, idx) => ( 52 |
setRating(idx)} 56 | onMouseEnter={() => setHoverRating(idx)} 57 | onMouseLeave={() => setHoverRating(null)} 58 | > 59 |
= idx) || 64 | (hoverRating === null && rating >= idx) 65 | ? circleColor 66 | : "#d1d5db", 67 | }} 68 | /> 69 |
70 | ))} 71 |
72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /app/components/ResumeForm/Form/IconButton.tsx: -------------------------------------------------------------------------------- 1 | // import { ArrowSmallUpIcon } from "@heroicons/react/20/solid"; 2 | import { 3 | ArrowSmallDownIcon, 4 | ArrowSmallUpIcon, 5 | EyeIcon, 6 | EyeSlashIcon, 7 | ListBulletIcon, 8 | TrashIcon, 9 | } from "@heroicons/react/24/outline"; 10 | import { IconButton } from "../../Button"; 11 | 12 | type MoveIconButtonType = "up" | "down"; 13 | 14 | export const ShowIconButton = ({ 15 | show, 16 | setShow, 17 | }: { 18 | show: boolean; 19 | setShow: (show: boolean) => void; 20 | }) => { 21 | const tooltipText = show ? "Hide section" : "Show section"; 22 | const onClick = () => { 23 | setShow(!show); 24 | }; 25 | const Icon = show ? EyeIcon : EyeSlashIcon; 26 | 27 | return ( 28 | 29 | 32 | ); 33 | }; 34 | 35 | export const DeletIconButton = ({ 36 | onClick, 37 | tooltipText, 38 | }: { 39 | onClick: () => void; 40 | tooltipText: string; 41 | }) => { 42 | return ( 43 | 44 | 47 | ); 48 | }; 49 | 50 | export const MoveIconButton = ({ 51 | type, 52 | size = "medium", 53 | onClick, 54 | }: { 55 | type: MoveIconButtonType; 56 | size?: "small" | "medium"; 57 | onClick: (type: MoveIconButtonType) => void; 58 | }) => { 59 | const tooltipText = type === "up" ? "Move up" : "Move down"; 60 | const sizeClassName = size === "medium" ? "h-6 w-6" : "h-4 w-4"; 61 | const Icon = type === "up" ? ArrowSmallUpIcon : ArrowSmallDownIcon; 62 | 63 | return ( 64 | onClick(type)} 66 | tooltipText={tooltipText} 67 | size={size} 68 | > 69 | 72 | ); 73 | }; 74 | 75 | export const BulletListIconButton = ({ 76 | onClick, 77 | showBulletPoints, 78 | }: { 79 | onClick: (newShowBulletPoints: boolean) => void; 80 | showBulletPoints: boolean; 81 | }) => { 82 | const tooltipText = showBulletPoints 83 | ? "Hide bullet points" 84 | : "Show bullet points"; 85 | 86 | return ( 87 | onClick(!showBulletPoints)} 89 | tooltipText={tooltipText} 90 | size="small" 91 | className={showBulletPoints ? "!bg-sky-100" : ""} 92 | > 93 | 101 | ); 102 | }; 103 | -------------------------------------------------------------------------------- /app/components/ResumeForm/Form/InputGroup.tsx: -------------------------------------------------------------------------------- 1 | import ContentEditable from "react-contenteditable"; 2 | 3 | interface InputProps { 4 | label: string; 5 | labelClassName?: string; 6 | 7 | name: K; 8 | value?: V; 9 | placeholder: string; 10 | onChange: (name: K, value: V) => void; 11 | } 12 | 13 | export const InputGroupWrapper = ({ 14 | label, 15 | className, 16 | children, 17 | }: { 18 | label: string; 19 | className?: string; 20 | children?: React.ReactNode; 21 | }) => ( 22 | 26 | ); 27 | 28 | export const INPUT_CLASS_NAME = 29 | "mt-1 px-3 py-2 block w-full rounded-md border border-gray-300 text-gray-300 shadow-sm outline-none font-normal text-base"; 30 | 31 | export const Input = ({ 32 | name, 33 | value = "", 34 | placeholder, 35 | onChange, 36 | label, 37 | labelClassName, 38 | }: InputProps) => ( 39 | 40 | onChange(name, e.target.value)} 46 | className={INPUT_CLASS_NAME} 47 | /> 48 | 49 | ); 50 | 51 | export const BulletListTextArea = ({ 52 | label, 53 | labelClassName: wrapperClassName, 54 | name, 55 | value: bulletListStrings = [], 56 | placeholder, 57 | onChange, 58 | showBulletPoints = true, 59 | }: InputProps & { 60 | showBulletPoints?: boolean; 61 | }) => { 62 | const html = getHTMLFromBulletListStrings(bulletListStrings); 63 | 64 | return ( 65 | 66 | div]:list-item ${ 69 | showBulletPoints ? "pl-7" : "[&>div]:list-['']" 70 | }`} 71 | // placeholder={placeholder} 72 | onChange={(e) => { 73 | if (e.type === "input") { 74 | const { innerText } = e.currentTarget as HTMLDivElement; 75 | const newBulletListStrings = 76 | getBulletListStringsFromInnerText(innerText); 77 | onChange(name, newBulletListStrings); 78 | } 79 | }} 80 | html={html} 81 | /> 82 | 83 | ); 84 | }; 85 | 86 | const NORMALIZED_LINE_BREAK = "\n"; 87 | const normalizeLineBreak = (str: string) => 88 | str.replace(/\r?\n/g, NORMALIZED_LINE_BREAK); 89 | const dedupeLineBreak = (str: string) => 90 | str.replace(/\n\n/g, NORMALIZED_LINE_BREAK); 91 | const getStringsByLineBreak = (str: string) => str.split(NORMALIZED_LINE_BREAK); 92 | 93 | const getBulletListStringsFromInnerText = (innerText: string) => { 94 | const innerTextWithNormalizedLineBreak = normalizeLineBreak(innerText); 95 | 96 | let newInnerText = dedupeLineBreak(innerTextWithNormalizedLineBreak); 97 | 98 | if (newInnerText === NORMALIZED_LINE_BREAK) { 99 | newInnerText = ""; 100 | } 101 | return getStringsByLineBreak(newInnerText); 102 | }; 103 | 104 | const getHTMLFromBulletListStrings = (bulletListStrings: string[]) => { 105 | if (bulletListStrings.length === 0) { 106 | return "
"; 107 | } 108 | 109 | return bulletListStrings.map((text) => `
${text}
`).join(""); 110 | }; 111 | -------------------------------------------------------------------------------- /app/components/ResumeForm/Form/index.tsx: -------------------------------------------------------------------------------- 1 | import { useAppDispatch, useAppSelector } from "@/app/lib/redux/hooks"; 2 | import { 3 | ShowForm, 4 | changeFormHeading, 5 | changeFormOrder, 6 | changeShowForm, 7 | selectHeadingByForm, 8 | selectIsFirstForm, 9 | selectIsLastForm, 10 | selectShowByForm, 11 | } from "@/app/lib/redux/settingsSlice"; 12 | import { 13 | AcademicCapIcon, 14 | BuildingOfficeIcon, 15 | LightBulbIcon, 16 | WrenchIcon, 17 | } from "@heroicons/react/24/outline"; 18 | import { Input } from "./InputGroup"; 19 | import { DeletIconButton, MoveIconButton, ShowIconButton } from "./IconButton"; 20 | import { ExpanderWithHeightTransition } from "../../ExpanderWithHeightTransition"; 21 | import { 22 | addSectionInForm, 23 | deleteSectionInFormByIdx, 24 | moveSectionInForm, 25 | } from "@/app/lib/redux/resumeSlice"; 26 | import { PlusSmallIcon } from "@heroicons/react/24/outline"; 27 | 28 | const FORM_TO_ICON: { [section in ShowForm]: typeof BuildingOfficeIcon } = { 29 | workExperiences: BuildingOfficeIcon, 30 | educations: AcademicCapIcon, 31 | projects: LightBulbIcon, 32 | skills: WrenchIcon, 33 | custom: WrenchIcon, 34 | }; 35 | 36 | export const BaseForm = ({ 37 | children, 38 | className, 39 | }: { 40 | children: React.ReactNode; 41 | className?: string; 42 | }) => ( 43 |
46 | {children} 47 |
48 | ); 49 | 50 | export const Form = ({ 51 | form, 52 | addButtonText, 53 | children, 54 | }: { 55 | form: ShowForm; 56 | addButtonText?: string; 57 | children: React.ReactNode; 58 | }) => { 59 | const showForm = useAppSelector(selectShowByForm(form)); 60 | const heading = useAppSelector(selectHeadingByForm(form)); 61 | 62 | const dispatch = useAppDispatch(); 63 | 64 | const setShowForm = (showForm: boolean) => { 65 | dispatch(changeShowForm({ field: form, value: showForm })); 66 | }; 67 | 68 | const setHeading = (heading: string) => { 69 | dispatch(changeFormHeading({ field: form, value: heading })); 70 | }; 71 | 72 | const isFirstForm = useAppSelector(selectIsFirstForm(form)); 73 | const isLastForm = useAppSelector(selectIsLastForm(form)); 74 | 75 | const handleMoveclick = (type: "up" | "down") => { 76 | dispatch(changeFormOrder({ form, type })); 77 | }; 78 | 79 | const Icon = FORM_TO_ICON[form]; 80 | 81 | return ( 82 | 87 |
88 |
89 |
97 |
98 | {!isFirstForm && ( 99 | 100 | )} 101 | {!isLastForm && ( 102 | 103 | )} 104 | 105 |
106 |
107 | 108 | {children} 109 | 110 | {showForm && addButtonText && ( 111 |
112 | 122 |
123 | )} 124 |
125 | ); 126 | }; 127 | 128 | export const FormSection = ({ 129 | form, 130 | idx, 131 | showMoveUp, 132 | showMoveDown, 133 | showDelete, 134 | deleteButtonTooltipText, 135 | children, 136 | }: { 137 | form: ShowForm; 138 | idx: number; 139 | showMoveUp: boolean; 140 | showMoveDown: boolean; 141 | showDelete: boolean; 142 | deleteButtonTooltipText: string; 143 | children: React.ReactNode; 144 | }) => { 145 | const dispatch = useAppDispatch(); 146 | 147 | const handleDeleteClick = () => { 148 | dispatch(deleteSectionInFormByIdx({ form, idx })); 149 | }; 150 | 151 | const handleMoveClick = (direction: "up" | "down") => { 152 | dispatch(moveSectionInForm({ form, direction, idx })); 153 | }; 154 | 155 | return ( 156 | <> 157 | {idx !== 0 && ( 158 |
159 | )} 160 |
161 | {children} 162 |
163 |
168 | handleMoveClick("up")} 172 | /> 173 |
174 |
179 | handleMoveClick("down")} 183 | /> 184 |
185 |
190 | 194 |
195 |
196 |
197 | 198 | ); 199 | }; 200 | -------------------------------------------------------------------------------- /app/components/ResumeForm/ProfileForm.tsx: -------------------------------------------------------------------------------- 1 | import { useAppDispatch, useAppSelector } from "@/app/lib/redux/hooks"; 2 | import { changeProfile, selectProfile } from "@/app/lib/redux/resumeSlice"; 3 | import { BaseForm } from "./Form"; 4 | import { Input } from "./Form/InputGroup"; 5 | import { ResumeProfile } from "@/app/lib/redux/types"; 6 | 7 | export const ProfileForm = () => { 8 | const profile = useAppSelector(selectProfile); 9 | const dispatch = useAppDispatch(); 10 | 11 | const { name, email, phone, url, summary, location } = profile; 12 | 13 | const handleProfileChange = (field: keyof ResumeProfile, value: string) => { 14 | dispatch(changeProfile({ field, value })); 15 | }; 16 | 17 | return ( 18 | 19 |
20 | 28 | 36 | 44 | 52 | 60 | 68 |
69 |
70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /app/components/ResumeForm/ProjectsForm.tsx: -------------------------------------------------------------------------------- 1 | import { useAppDispatch, useAppSelector } from "@/app/lib/redux/hooks"; 2 | import { 3 | changeEducations, 4 | selectEducations, 5 | selectProjects, 6 | changeProjects, 7 | } from "@/app/lib/redux/resumeSlice"; 8 | import { 9 | changeShowBulletPoints, 10 | selectShowBulletPoints, 11 | } from "@/app/lib/redux/settingsSlice"; 12 | import { Form, FormSection } from "./Form"; 13 | import { CreateHandleChangeArgsWithDescriptions } from "./types"; 14 | import { ResumeEducation, ResumeProject } from "@/app/lib/redux/types"; 15 | import { BulletListTextArea, Input } from "./Form/InputGroup"; 16 | import { BulletListIconButton } from "./Form/IconButton"; 17 | 18 | export const ProjectsForm = () => { 19 | const projects = useAppSelector(selectProjects); 20 | const dispatch = useAppDispatch(); 21 | const showDelete = projects.length > 1; 22 | 23 | return ( 24 |
25 | {projects.map(({ project, date, descriptions }, idx) => { 26 | const handleProjectChange = ( 27 | ...[ 28 | field, 29 | value, 30 | ]: CreateHandleChangeArgsWithDescriptions 31 | ) => { 32 | dispatch(changeProjects({ idx, field, value } as any)); 33 | }; 34 | 35 | const showMoveUp = idx !== 0; 36 | const showMoveDown = idx !== projects.length - 1; 37 | 38 | return ( 39 | 48 | 56 | 64 | 72 | 73 | ); 74 | })} 75 | 76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /app/components/ResumeForm/SkillsForm.tsx: -------------------------------------------------------------------------------- 1 | import { useAppDispatch, useAppSelector } from "@/app/lib/redux/hooks"; 2 | import { changeSkills, selectSkills } from "@/app/lib/redux/resumeSlice"; 3 | import { 4 | changeShowBulletPoints, 5 | selectShowBulletPoints, 6 | selectThemeColor, 7 | } from "@/app/lib/redux/settingsSlice"; 8 | import { Form } from "./Form"; 9 | import { BulletListTextArea, InputGroupWrapper } from "./Form/InputGroup"; 10 | import { BulletListIconButton } from "./Form/IconButton"; 11 | import { FeaturedSkillInput } from "./Form/FeaturedSkillInput"; 12 | 13 | export const SkillsForm = () => { 14 | const skills = useAppSelector(selectSkills); 15 | const dispatch = useAppDispatch(); 16 | const { featuredSkills, descriptions } = skills; 17 | const form = "skills"; 18 | const showBulletPoints = useAppSelector(selectShowBulletPoints(form)); 19 | const themeColor = useAppSelector(selectThemeColor) || "#38bdf8"; 20 | 21 | const handleSkillsChange = (field: "descriptions", value: string[]) => { 22 | dispatch(changeSkills({ field, value })); 23 | }; 24 | 25 | const handleFeaturedSkillsChange = ( 26 | idx: number, 27 | skill: string, 28 | rating: number 29 | ) => { 30 | dispatch(changeSkills({ field: "featuredSkills", idx, skill, rating })); 31 | }; 32 | 33 | const handleShowBulletPoints = (value: boolean) => { 34 | dispatch(changeShowBulletPoints({ field: form, value })); 35 | }; 36 | 37 | return ( 38 |
39 |
40 |
41 | 50 |
51 | 55 |
56 |
57 |
58 | 62 |

63 | Featured skills is optional to highlight top skills, with more 64 | circles mean higher proficiency 65 |

66 |
67 | 68 | {featuredSkills.map(({ skill, rating }, idx) => ( 69 | { 75 | handleFeaturedSkillsChange(idx, newSkill, newRating); 76 | }} 77 | placeholder={`Featured Skill ${idx + 1}`} 78 | circleColor={themeColor} 79 | /> 80 | ))} 81 |
82 | 83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /app/components/ResumeForm/ThemeForm/InlineInput.tsx: -------------------------------------------------------------------------------- 1 | interface InputProps { 2 | label: string; 3 | labelClassName?: string; 4 | name: K; 5 | value?: V; 6 | placeholder: string; 7 | inputStyle?: React.CSSProperties; 8 | onChange: (name: K, value: V) => void; 9 | } 10 | 11 | export const InlineInput = ({ 12 | label, 13 | labelClassName, 14 | name, 15 | value = "", 16 | placeholder, 17 | inputStyle = {}, 18 | onChange, 19 | }: InputProps) => { 20 | return ( 21 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /app/components/ResumeForm/ThemeForm/Selection.tsx: -------------------------------------------------------------------------------- 1 | import { GeneralSetting } from "@/app/lib/redux/settingsSlice"; 2 | import { 3 | FONT_FAMILY_TO_DISPLAY_NAME, 4 | FONT_FAMILY_TO_STANDARD_SIZE_IN_PT, 5 | getAllFontFamiliesToLoad, 6 | } from "../../fonts/constants"; 7 | import { PX_PER_PT } from "@/app/lib/constants"; 8 | import dynamic from "next/dynamic"; 9 | 10 | const SelectionComponent = ({ 11 | selectedColor, 12 | isSelected, 13 | style = {}, 14 | onClick, 15 | children, 16 | }: { 17 | selectedColor: string; 18 | isSelected: boolean; 19 | style?: React.CSSProperties; 20 | onClick: () => void; 21 | children: React.ReactNode; 22 | }) => { 23 | const selectedStyle = { 24 | color: "white", 25 | backgroundColor: selectedColor, 26 | borderColor: selectedColor, 27 | ...style, 28 | }; 29 | 30 | return ( 31 |
{ 36 | if (["Enter", " "].includes(e.key)) onClick(); 37 | }} 38 | tabIndex={0} 39 | > 40 | {children} 41 |
42 | ); 43 | }; 44 | 45 | const SelectionsWrapper = ({ children }: { children: React.ReactNode }) => { 46 | return
{children}
; 47 | }; 48 | 49 | const FontFamilySelections = ({ 50 | selectedFontFamily, 51 | themeColor, 52 | handleSettingsChange, 53 | }: { 54 | selectedFontFamily: string; 55 | themeColor: string; 56 | handleSettingsChange: (field: GeneralSetting, value: string) => void; 57 | }) => { 58 | const allFontFamilies = getAllFontFamiliesToLoad(); 59 | return ( 60 | 61 | {allFontFamilies.map((fontFamily, idx) => { 62 | const isSelected = selectedFontFamily === fontFamily; 63 | const standardSizePt = FONT_FAMILY_TO_STANDARD_SIZE_IN_PT[fontFamily]; 64 | 65 | return ( 66 | handleSettingsChange("fontFamily", fontFamily)} 75 | > 76 | {FONT_FAMILY_TO_DISPLAY_NAME[fontFamily]} 77 | 78 | ); 79 | })} 80 | 81 | ); 82 | }; 83 | 84 | export const FontFamilySelectionCSR = dynamic( 85 | () => Promise.resolve(FontFamilySelections), 86 | { 87 | ssr: false, 88 | } 89 | ); 90 | 91 | export const FontSizeSelections = ({ 92 | fontFamily, 93 | themeColor, 94 | selectedFontSize, 95 | handleSettingsChange, 96 | }: { 97 | fontFamily: string; 98 | themeColor: string; 99 | selectedFontSize: string; 100 | handleSettingsChange: (field: GeneralSetting, value: string) => void; 101 | }) => { 102 | const standardSizePt = FONT_FAMILY_TO_STANDARD_SIZE_IN_PT[fontFamily]; 103 | const compactSizePt = standardSizePt - 1; 104 | 105 | return ( 106 | 107 | {["Compact", "Standard", "Large"].map((type, idx) => { 108 | const fontSizePt = String(compactSizePt + idx); 109 | const isSelected = fontSizePt === selectedFontSize; 110 | 111 | return ( 112 | handleSettingsChange("fontSize", fontSizePt)} 121 | > 122 | {type} 123 | 124 | ); 125 | })} 126 | 127 | ); 128 | }; 129 | 130 | export const DocumentSizeSelections = ({ 131 | themeColor, 132 | selectedDocumentSize, 133 | handleSettingsChange, 134 | }: { 135 | themeColor: string; 136 | selectedDocumentSize: string; 137 | handleSettingsChange: (field: GeneralSetting, value: string) => void; 138 | }) => { 139 | return ( 140 | 141 | {["Letter", "A4"].map((type, idx) => { 142 | return ( 143 | handleSettingsChange("documentSize", type)} 148 | > 149 |
150 |
{type}
151 |
152 | {type === "Letter" ? "(US, Canada)" : "(India,Other Countries)"} 153 |
154 |
155 |
156 | ); 157 | })} 158 |
159 | ); 160 | }; 161 | -------------------------------------------------------------------------------- /app/components/ResumeForm/ThemeForm/constants.ts: -------------------------------------------------------------------------------- 1 | export const THEME_COLORS = [ 2 | "#f87171", 3 | "#ef4444", 4 | "#fb923c", 5 | "#f97316", 6 | "#fbbf24", 7 | "#f59e0b", 8 | "#22c55e", 9 | "#15803d", 10 | "#38bdf8", 11 | "#0ea5e9", 12 | "#818cf8", 13 | "#6366f1", 14 | ]; 15 | -------------------------------------------------------------------------------- /app/components/ResumeForm/ThemeForm/index.tsx: -------------------------------------------------------------------------------- 1 | import { useAppDispatch, useAppSelector } from "@/app/lib/redux/hooks"; 2 | import { 3 | DEFAULT_THEME_COLOR, 4 | GeneralSetting, 5 | changeSettings, 6 | selectSettings, 7 | } from "@/app/lib/redux/settingsSlice"; 8 | import { BaseForm } from "../Form"; 9 | import { Cog6ToothIcon } from "@heroicons/react/24/outline"; 10 | import { InlineInput } from "./InlineInput"; 11 | import { THEME_COLORS } from "./constants"; 12 | import { InputGroupWrapper } from "../Form/InputGroup"; 13 | import { 14 | DocumentSizeSelections, 15 | FontFamilySelectionCSR, 16 | FontSizeSelections, 17 | } from "./Selection"; 18 | import { FontFamily } from "../../fonts/constants"; 19 | 20 | export const ThemeForm = () => { 21 | const settings = useAppSelector(selectSettings); 22 | const { fontSize, fontFamily, documentSize } = settings; 23 | const themeColor = settings.themeColor || DEFAULT_THEME_COLOR; 24 | const dispatch = useAppDispatch(); 25 | 26 | const handleSettingsChange = (field: GeneralSetting, value: string) => { 27 | dispatch(changeSettings({ field, value })); 28 | }; 29 | 30 | return ( 31 | 32 |
33 |
34 |
39 |
40 | 48 |
49 | {THEME_COLORS.map((color, idx) => ( 50 |
handleSettingsChange("themeColor", color)} 55 | onKeyDown={(e) => { 56 | if (["Enter", " "].includes(e.key)) { 57 | handleSettingsChange("themeColor", color); 58 | } 59 | }} 60 | tabIndex={0} 61 | > 62 | {settings.themeColor === color ? "$" : ""} 63 |
64 | ))} 65 |
66 |
67 |
68 | 69 | 74 |
75 |
76 | 83 | 89 |
90 |
91 | 92 | 97 |
98 |
99 |
100 | ); 101 | }; 102 | -------------------------------------------------------------------------------- /app/components/ResumeForm/WorkExperiencesForm.tsx: -------------------------------------------------------------------------------- 1 | import { useAppDispatch, useAppSelector } from "@/app/lib/redux/hooks"; 2 | import { 3 | changeWorkExperience, 4 | selectWorkExperiences, 5 | } from "@/app/lib/redux/resumeSlice"; 6 | import { Form, FormSection } from "./Form"; 7 | import { CreateHandleChangeArgsWithDescriptions } from "./types"; 8 | import { ResumeWorkExperience } from "@/app/lib/redux/types"; 9 | import { BulletListTextArea, Input } from "./Form/InputGroup"; 10 | 11 | export const WorkExperiencesForm = () => { 12 | const workExperiences = useAppSelector(selectWorkExperiences); 13 | const dispatch = useAppDispatch(); 14 | 15 | const showDelete = workExperiences.length > 1; 16 | 17 | return ( 18 |
19 | {workExperiences.map(({ company, jobTitle, date, descriptions }, idx) => { 20 | const handleWorkExperienceChange = ( 21 | ...[ 22 | field, 23 | value, 24 | ]: CreateHandleChangeArgsWithDescriptions 25 | ) => { 26 | dispatch(changeWorkExperience({ idx, field, value } as any)); 27 | }; 28 | 29 | const showMoveUp = idx !== 0; 30 | const showMoveDown = idx !== workExperiences.length - 1; 31 | 32 | return ( 33 | 42 | 50 | 58 | 66 | 74 | 75 | ); 76 | })} 77 | 78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /app/components/ResumeForm/index.tsx: -------------------------------------------------------------------------------- 1 | import { cx } from "@/app/lib/cx"; 2 | import { 3 | useAppSelector, 4 | useSaveStateToLocalStorageOnChange, 5 | useSetInitialStore, 6 | } from "@/app/lib/redux/hooks"; 7 | import { useState } from "react"; 8 | import { ProfileForm } from "./ProfileForm"; 9 | import { ShowForm, selectFormsOrder } from "@/app/lib/redux/settingsSlice"; 10 | import { WorkExperiencesForm } from "./WorkExperiencesForm"; 11 | import { EducationsForm } from "./EducationsForm"; 12 | import { ProjectsForm } from "./ProjectsForm"; 13 | import { SkillsForm } from "./SkillsForm"; 14 | import { CustomForm } from "./CustomForm"; 15 | import { ThemeForm } from "./ThemeForm"; 16 | 17 | const formTypeToComponent: { [type in ShowForm]: () => JSX.Element } = { 18 | workExperiences: WorkExperiencesForm, 19 | educations: EducationsForm, 20 | projects: ProjectsForm, 21 | skills: SkillsForm, 22 | custom: CustomForm, 23 | }; 24 | 25 | export const ResumeForm = () => { 26 | useSetInitialStore(); 27 | useSaveStateToLocalStorageOnChange(); 28 | 29 | const [isHover, setIsHover] = useState(false); 30 | 31 | const formsOrder = useAppSelector(selectFormsOrder); 32 | 33 | return ( 34 |
setIsHover(true)} 40 | onMouseLeave={() => setIsHover(false)} 41 | > 42 |
43 | 44 | {formsOrder.map((form) => { 45 | const Component = formTypeToComponent[form]; 46 | return ; 47 | })} 48 | 49 |
50 |
51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /app/components/ResumeForm/types.ts: -------------------------------------------------------------------------------- 1 | export type CreateHandleChangeArgsWithDescriptions = 2 | | [field: Exclude, value: string] 3 | | [field: "descriptions", value: string[]]; 4 | -------------------------------------------------------------------------------- /app/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useRef, useState } from "react"; 4 | import { createPortal } from "react-dom"; 5 | 6 | export const ToolTip = ({ 7 | text, 8 | children, 9 | }: { 10 | text: string; 11 | children: React.ReactNode; 12 | }) => { 13 | const spanRef = useRef(null); 14 | const tooltipRef = useRef(null); 15 | 16 | const [tooltipPos, setTooltipPos] = useState({ top: 0, left: 0 }); 17 | 18 | const [show, setShow] = useState(false); 19 | 20 | const showTooltip = () => setShow(true); 21 | const hideTooltip = () => setShow(false); 22 | 23 | useEffect(() => { 24 | const span = spanRef.current; 25 | const tooltip = tooltipRef.current; 26 | if (span && tooltip) { 27 | const rect = span.getBoundingClientRect(); 28 | const TOP_OFFSET = 6; 29 | const newTop = rect.top + rect.height + TOP_OFFSET; 30 | const newLeft = rect.left - tooltip.offsetWidth / 2 + rect.width / 2; 31 | setTooltipPos({ 32 | top: newTop, 33 | left: newLeft, 34 | }); 35 | } 36 | }, [show]); 37 | 38 | return ( 39 | 47 | {children} 48 | {show && 49 | createPortal( 50 |
59 | {text} 60 |
, 61 | document.body 62 | )} 63 |
64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /app/components/TopNavBar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { usePathname } from "next/navigation"; 4 | import { cx } from "../lib/cx"; 5 | import Link from "next/link"; 6 | import Image from "next/image"; 7 | 8 | export const TopNavBar = () => { 9 | const pathname = usePathname(); 10 | const isHomePage = pathname === "/"; 11 | 12 | return ( 13 |
20 |
21 | 22 |
23 | logo 31 |

32 | Resume Builder & Parser 33 |

34 |
35 | 36 | 53 |
54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /app/components/documentation/Heading.tsx: -------------------------------------------------------------------------------- 1 | import { cx } from "@/app/lib/cx"; 2 | 3 | const HEADING_CLASSNAMES = { 4 | 1: "text-2xl font-bold", 5 | 2: "text-xl font-bold", 6 | 3: "text-lg font-semibold", 7 | }; 8 | 9 | export const Heading = ({ 10 | level = 1, 11 | children, 12 | className = "", 13 | }: { 14 | level?: 1 | 2 | 3; 15 | smallMarginTop?: boolean; 16 | children: React.ReactNode; 17 | className?: string; 18 | }) => { 19 | const Component = `h${level}` as const; 20 | 21 | return ( 22 | 29 | {children} 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /app/components/documentation/Paragraph.tsx: -------------------------------------------------------------------------------- 1 | import { cx } from "@/app/lib/cx"; 2 | 3 | export const Paragraph = ({ 4 | smallMarginTop = false, 5 | children, 6 | className = "", 7 | }: { 8 | smallMarginTop?: boolean; 9 | children: React.ReactNode; 10 | className?: string; 11 | }) => { 12 | return ( 13 |

20 | {children} 21 |

22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /app/components/fonts/constants.ts: -------------------------------------------------------------------------------- 1 | const SANS_SERIF_FONT_FAMILIES = [ 2 | "Roboto", 3 | "Lato", 4 | "OpenSans", 5 | "Montserrat", 6 | "Raleway", 7 | ]; 8 | 9 | const SERIF_FONT_FAMILIES = [ 10 | "Lora", 11 | "RobotoSlab", 12 | "Merriweather", 13 | "Caladea", 14 | "PlayfairDisplay", 15 | ]; 16 | 17 | export const FONT_FAMILIES = [ 18 | ...SANS_SERIF_FONT_FAMILIES, 19 | ...SERIF_FONT_FAMILIES, 20 | ]; 21 | 22 | export type FontFamily = (typeof FONT_FAMILIES)[number]; 23 | 24 | export const FONT_FAMILY_TO_STANDARD_SIZE_IN_PT: Record = { 25 | Roboto: 11, 26 | Lato: 11, 27 | Montserrat: 10, 28 | OpenSans: 10, 29 | Raleway: 10, 30 | 31 | Caladea: 11, 32 | Lora: 11, 33 | RobotoSlab: 10, 34 | PlayfairDisplay: 10, 35 | Merriweather: 10, 36 | }; 37 | 38 | export const FONT_FAMILY_TO_DISPLAY_NAME: Record = { 39 | Roboto: "Roboto", 40 | Lato: "Lato", 41 | Montserrat: "Montserrat", 42 | OpenSans: "Open Sans", 43 | Raleway: "Raleway", 44 | 45 | Caladea: "Caladea", 46 | Lora: "Lora", 47 | RobotoSlab: "Roboto Slab", 48 | PlayfairDisplay: "Playfair Display", 49 | Merriweather: "Merriweather", 50 | }; 51 | 52 | export const getAllFontFamiliesToLoad = () => { 53 | return [...FONT_FAMILIES]; 54 | }; 55 | -------------------------------------------------------------------------------- /app/components/fonts/hooks.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { FONT_FAMILIES, getAllFontFamiliesToLoad } from "./constants"; 3 | import { Font } from "@react-pdf/renderer"; 4 | 5 | export const useRegisterReactPDFFont = () => { 6 | useEffect(() => { 7 | const allFontFamilies = getAllFontFamiliesToLoad(); 8 | allFontFamilies.forEach((fontFamily) => { 9 | Font.register({ 10 | family: fontFamily, 11 | fonts: [ 12 | { 13 | src: `fonts/${fontFamily}-Regular.ttf`, 14 | }, 15 | { 16 | src: `fonts/${fontFamily}-Bold.ttf`, 17 | fontWeight: "bold", 18 | }, 19 | ], 20 | }); 21 | }); 22 | }, []); 23 | }; 24 | 25 | export const useRegisterReactPDFHypenationCallback = (fontFamily: string) => { 26 | useEffect(() => { 27 | if (FONT_FAMILIES.includes(fontFamily as any)) { 28 | Font.registerHyphenationCallback((word) => [word]); 29 | } else { 30 | Font.registerHyphenationCallback((word) => 31 | word 32 | .split("") 33 | .map((char) => [char, ""]) 34 | .flat() 35 | ); 36 | } 37 | }, [fontFamily]); 38 | }; 39 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import url("/fonts/fonts.css"); 2 | @tailwind base; 3 | @tailwind components; 4 | @tailwind utilities; 5 | 6 | @layer components { 7 | .btn-primary { 8 | @apply bg-primary outline-theme-purple inline-block rounded-full px-6 py-2 font-semibold shadow-sm; 9 | } 10 | .text-primary { 11 | @apply bg-gradient-to-r from-[color:var(--theme-purple)] to-[color:var(--theme-blue)] bg-clip-text text-transparent !important; 12 | } 13 | .bg-primary { 14 | @apply bg-gradient-to-r from-[color:var(--theme-purple)] to-[color:var(--theme-blue)] text-white; 15 | } 16 | .outline-theme-purple { 17 | @apply hover:opacity-80 hover:outline-[color:var(--theme-purple)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--theme-purple)]; 18 | } 19 | .outline-theme-blue { 20 | @apply hover:opacity-80 hover:outline-[color:var(--theme-blue)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--theme-blue)]; 21 | } 22 | .within-outline-theme-purple { 23 | @apply focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[color:var(--theme-purple)] hover:opacity-80 hover:outline-[color:var(--theme-purple)]; 24 | } 25 | } 26 | 27 | :root { 28 | --theme-purple: #5d52d9; 29 | --theme-blue: #4fc5eb; 30 | 31 | --top-nav-bar-height: 3.5rem; 32 | --resume-control-bar-height: 3rem; 33 | --resume-padding: 1.5rem; 34 | } 35 | -------------------------------------------------------------------------------- /app/home/AutoTypingResume.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useRef, useState } from "react"; 4 | import { deepClone } from "../lib/parse-resume-from-pdf/deep-clone"; 5 | import { initialResumeState } from "../lib/redux/resumeSlice"; 6 | import { makeObjectCharIterator } from "../lib/make-object-char-iterator"; 7 | import { END_HOME_RESUME, START_HOME_RESUME } from "./constants"; 8 | import { ResumeIFrameCSR } from "../components/Resume/ResumeIFrame"; 9 | import { ResumePDF } from "../components/Resume/ResumePDF"; 10 | import { initialSettings } from "../lib/redux/settingsSlice"; 11 | 12 | export const AutoTypingResume = () => { 13 | const [resume, setResume] = useState(deepClone(initialResumeState)); 14 | const resumeCharIterator = useRef( 15 | makeObjectCharIterator(START_HOME_RESUME, END_HOME_RESUME) 16 | ); 17 | 18 | const hasSetEndResume = useRef(false); 19 | 20 | useEffect(() => { 21 | const intervalId = setInterval(() => { 22 | let next = resumeCharIterator.current.next(); 23 | for (let i = 0; i < 9; i++) { 24 | next = resumeCharIterator.current.next(); 25 | } 26 | if (!next.done) { 27 | setResume(next.value); 28 | } else { 29 | if (!hasSetEndResume.current) { 30 | setResume(END_HOME_RESUME); 31 | hasSetEndResume.current = true; 32 | } 33 | } 34 | }, 50); 35 | 36 | return () => clearInterval(intervalId); 37 | }); 38 | 39 | useEffect(() => { 40 | const intervalId = setInterval(() => { 41 | resumeCharIterator.current = makeObjectCharIterator( 42 | START_HOME_RESUME, 43 | END_HOME_RESUME 44 | ); 45 | hasSetEndResume.current = false; 46 | }, 60 * 1000); 47 | return () => clearInterval(intervalId); 48 | }, []); 49 | 50 | return ( 51 | <> 52 | 53 | 69 | 70 | 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /app/home/Hero.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { FlexboxSpacer } from "../components/FlexboxSpacer"; 3 | import { AutoTypingResume } from "./AutoTypingResume"; 4 | 5 | export const Hero = () => { 6 | return ( 7 |
8 | 9 |
10 |

11 | Create a professional 12 |
13 | resume easily 14 |

15 |

16 | With this powerful resume builder 17 |

18 | 19 | Create Resume 20 | 21 |

No sign up required

22 |

23 | Already have a resume? Test its ATS readability with the{" "} 24 | 25 | resume parser 26 | 27 |

28 |
29 | {" "} 30 |
31 | 32 |
33 |
34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /app/home/Steps.tsx: -------------------------------------------------------------------------------- 1 | const STEPS = [ 2 | { title: "Add a resume pdf", text: "or create from scratch" }, 3 | { title: "Preview design", text: "and make edits" }, 4 | { title: "Download new resume", text: "and apply with confidence" }, 5 | ]; 6 | 7 | export const Steps = () => { 8 | return ( 9 |
10 |

3 Simple Steps

11 |
12 |
13 | {STEPS.map(({ title, text }, idx) => ( 14 |
15 |
16 |
17 |
18 |
19 | {idx + 1} 20 |
21 |
22 |
23 | {title} 24 |
25 |
{text}
26 |
27 | ))} 28 |
29 |
30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /app/home/constants.ts: -------------------------------------------------------------------------------- 1 | import { deepClone } from "../lib/parse-resume-from-pdf/deep-clone"; 2 | import { 3 | initialEducation, 4 | initialProfile, 5 | initialProject, 6 | initialWorkExperience, 7 | } from "../lib/redux/resumeSlice"; 8 | import { Resume } from "../lib/redux/types"; 9 | 10 | export const END_HOME_RESUME: Resume = { 11 | profile: { 12 | name: "Kuluru Vineeth", 13 | summary: 14 | "Software engineer obsessed with building exceptional products that people love", 15 | email: "test@gmail.com", 16 | phone: "123-456-7890", 17 | location: "HYD,IND", 18 | url: "linkedin.com/in/yourusername", 19 | }, 20 | workExperiences: [ 21 | { 22 | company: "ABC Company", 23 | jobTitle: "Software Engineer", 24 | date: "May 2023 - Present", 25 | descriptions: [ 26 | "Contributed and Collaborated with cross functional teams to build the scalable product consumned by larger audiences", 27 | "Contributed and Collaborated with cross functional teams to build the scalable product consumned by larger audiences", 28 | "Contributed and Collaborated with cross functional teams to build the scalable product consumned by larger audiences", 29 | ], 30 | }, 31 | { 32 | company: "DEF Organization", 33 | jobTitle: "Software Engineer", 34 | date: "May 2022 - May 2023", 35 | descriptions: [ 36 | "Contributed and Collaborated with cross functional teams to build the scalable product consumned by larger audiences", 37 | "Contributed and Collaborated with cross functional teams to build the scalable product consumned by larger audiences", 38 | "Contributed and Collaborated with cross functional teams to build the scalable product consumned by larger audiences", 39 | ], 40 | }, 41 | { 42 | company: "XYZ Company", 43 | jobTitle: "Software Engineer", 44 | date: "May 2021 - May 2022", 45 | descriptions: [ 46 | "Contributed and Collaborated with cross functional teams to build the scalable product consumned by larger audiences", 47 | ], 48 | }, 49 | ], 50 | educations: [ 51 | { 52 | school: "XYZ University", 53 | degree: "Bachelor of Science in Computer Science", 54 | date: "Sep 2018 - Aug 2022", 55 | gpa: "8.55", 56 | descriptions: [ 57 | "Contributed and Collaborated with cross functional teams to build the scalable product consumned by larger audiences", 58 | ], 59 | }, 60 | ], 61 | projects: [ 62 | { 63 | project: "Project1", 64 | date: "Fall 2021", 65 | descriptions: [ 66 | "Contributed and Collaborated with cross functional teams to build the scalable product consumned by larger audiences", 67 | ], 68 | }, 69 | ], 70 | skills: { 71 | featuredSkills: [ 72 | { skill: "Python", rating: 3 }, 73 | { skill: "TypeScript", rating: 3 }, 74 | { skill: "React", rating: 3 }, 75 | ], 76 | descriptions: [ 77 | "Tech: React Hooks, GraphQL, Node.js, SQL, Postgres, NoSql, Redis, REST API, Git", 78 | "Soft: Teamwork, Creative Problem Solving, Communication, Learning Mindset, Agile", 79 | ], 80 | }, 81 | custom: { 82 | descriptions: [], 83 | }, 84 | }; 85 | 86 | export const START_HOME_RESUME: Resume = { 87 | profile: deepClone(initialProfile), 88 | educations: [deepClone(initialEducation)], 89 | projects: [deepClone(initialProject)], 90 | custom: { 91 | descriptions: [], 92 | }, 93 | workExperiences: END_HOME_RESUME.workExperiences.map(() => 94 | deepClone(initialWorkExperience) 95 | ), 96 | skills: { 97 | featuredSkills: END_HOME_RESUME.skills.featuredSkills.map((item) => ({ 98 | skill: "", 99 | rating: item.rating, 100 | })), 101 | descriptions: [], 102 | }, 103 | }; 104 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | import { TopNavBar } from "./components/TopNavBar"; 5 | 6 | const inter = Inter({ subsets: ["latin"] }); 7 | 8 | export const metadata: Metadata = { 9 | title: "Create Next App", 10 | description: "Generated by create next app", 11 | }; 12 | 13 | export default function RootLayout({ 14 | children, 15 | }: { 16 | children: React.ReactNode; 17 | }) { 18 | return ( 19 | 20 | 21 | 22 | {children} 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /app/lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const PX_PER_PT = 4 / 3; 2 | 3 | export const A4_WIDTH_PT = 595; 4 | const A4_HEIGHT_PT = 842; 5 | export const A4_WIDTH_PX = A4_WIDTH_PT * PX_PER_PT; 6 | export const A4_HEIGHT_PX = A4_HEIGHT_PT * PX_PER_PT; 7 | 8 | export const LETTER_WIDTH_PT = 612; 9 | export const LETTER_HEIGHT_PT = 792; 10 | 11 | export const LETTER_WIDTH_PX = LETTER_WIDTH_PT * PX_PER_PT; 12 | export const LETTER_HEIGHT_PX = LETTER_HEIGHT_PT * PX_PER_PT; 13 | -------------------------------------------------------------------------------- /app/lib/cx.ts: -------------------------------------------------------------------------------- 1 | export const cx = (...classes: Array) => { 2 | const newClasses = []; 3 | for (const c of classes) { 4 | if (typeof c === "string") { 5 | newClasses.push(c.trim()); 6 | } 7 | } 8 | 9 | return newClasses.join(" "); 10 | }; 11 | -------------------------------------------------------------------------------- /app/lib/deep-merge.ts: -------------------------------------------------------------------------------- 1 | type Object = { [key: string]: any }; 2 | 3 | const isObject = (item: any): item is Object => { 4 | return item && typeof item === "object" && !Array.isArray(item); 5 | }; 6 | 7 | export const deepMerge = (target: Object, source: Object, level = 0) => { 8 | const copyTarget = level === 0 ? structuredClone(target) : target; 9 | 10 | for (const key in source) { 11 | const sourceValue = source[key]; 12 | 13 | if (!isObject(sourceValue)) { 14 | copyTarget[key] = sourceValue; 15 | } else { 16 | if (!isObject(copyTarget[key])) { 17 | copyTarget[key] = {}; 18 | } 19 | deepMerge(copyTarget[key], sourceValue, level + 1); 20 | } 21 | } 22 | return copyTarget; 23 | }; 24 | -------------------------------------------------------------------------------- /app/lib/make-object-char-iterator.ts: -------------------------------------------------------------------------------- 1 | import { deepClone } from "./parse-resume-from-pdf/deep-clone"; 2 | 3 | type Object = { [key: string]: any }; 4 | 5 | /** 6 | * 7 | * start = {a: ""} 8 | * end = {a: "abc"} 9 | * const iterator = func(start,end) 10 | * iterator.next().value = {a: "a"} 11 | * {a: "ab"} 12 | * {a: "abc"} 13 | * @param start 14 | * @param end 15 | * @param level 16 | */ 17 | export function* makeObjectCharIterator( 18 | start: T, 19 | end: T, 20 | level = 0 21 | ) { 22 | const object: Object = level === 0 ? deepClone(start) : start; 23 | 24 | for (const [key, endValue] of Object.entries(end)) { 25 | if (typeof endValue === "object") { 26 | const recursiveIterator = makeObjectCharIterator( 27 | object[key], 28 | endValue, 29 | level + 1 30 | ); 31 | while (true) { 32 | const next = recursiveIterator.next(); 33 | if (next.done) { 34 | break; 35 | } 36 | 37 | yield deepClone(object) as T; 38 | } 39 | } else { 40 | for (let i = 0; i <= endValue.length; i++) { 41 | object[key] = endValue.slice(0, i); 42 | yield deepClone(object) as T; 43 | } 44 | } 45 | } 46 | } 47 | 48 | export const countObjectChar = (object: Object) => { 49 | let count = 0; 50 | for (const value of Object.values(object)) { 51 | if (typeof value === "object") { 52 | count += countObjectChar(value); 53 | } else if (typeof value === "string") { 54 | count += value.length; 55 | } 56 | } 57 | return count; 58 | }; 59 | -------------------------------------------------------------------------------- /app/lib/parse-resume-from-pdf/deep-clone.ts: -------------------------------------------------------------------------------- 1 | export const deepClone = (object: T) => 2 | JSON.parse(JSON.stringify(object)) as T; 3 | -------------------------------------------------------------------------------- /app/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-education.ts: -------------------------------------------------------------------------------- 1 | import { ResumeEducation } from "../../redux/types"; 2 | import { hasLetter } from "../group-lines-into-sections"; 3 | import { FeatureSet, ResumeSectionToLines, TextItem } from "../types"; 4 | import { hasComma, hasNumber } from "./extract-profile"; 5 | import { 6 | getBulletPointsFromLines, 7 | getDescriptionsLineIdx, 8 | } from "./lib/bullet-points"; 9 | import { DATE_FEATURE_SETS } from "./lib/common-features"; 10 | import { getTextWithHighestFeatureScore } from "./lib/feature-scoring-system"; 11 | import { getSectionLinesByKeywords } from "./lib/get-section-lines"; 12 | import { divideSectionIntoSubsections } from "./lib/subsections"; 13 | 14 | const SCHOOLS = ["College", "University", "Institute", "School", "Academy"]; 15 | const hasSchool = (item: TextItem) => 16 | SCHOOLS.some((school) => item.text.includes(school)); 17 | 18 | const DEGREES = ["Bachelor", "Master", "PhD", "Ph."]; 19 | const hasDegree = (item: TextItem) => 20 | DEGREES.some((degree) => item.text.includes(degree)) || 21 | /[ABM][A-Z\.]/.test(item.text); 22 | 23 | const matchGPA = (item: TextItem) => item.text.match(/[0-4]\.\d{1,2}/); 24 | 25 | const matchGrade = (item: TextItem) => { 26 | const grade = parseFloat(item.text); 27 | if (Number.isFinite(grade) && grade <= 110) { 28 | return [String(grade)] as RegExpMatchArray; 29 | } 30 | 31 | return null; 32 | }; 33 | 34 | const SCHOOL_FEATURE_SETS: FeatureSet[] = [ 35 | [hasSchool, 4], 36 | [hasDegree, -4], 37 | [hasNumber, -4], 38 | ]; 39 | 40 | const DEGREE_FEATURE_SETS: FeatureSet[] = [ 41 | [hasDegree, 4], 42 | [hasSchool, -4], 43 | [hasNumber, -3], 44 | ]; 45 | 46 | const GPA_FEATURE_SETS: FeatureSet[] = [ 47 | [matchGPA, 4, true], 48 | [matchGrade, 3, true], 49 | [hasComma, -3], 50 | [hasLetter, -4], 51 | ]; 52 | 53 | export const extractEducation = (sections: ResumeSectionToLines) => { 54 | const educations: ResumeEducation[] = []; 55 | const educationScores = []; 56 | const lines = getSectionLinesByKeywords(sections, ["education"]); 57 | const subsections = divideSectionIntoSubsections(lines); 58 | for (const subsectionsLines of subsections) { 59 | const textItems = subsectionsLines.flat(); 60 | const [school, schoolScores] = getTextWithHighestFeatureScore( 61 | textItems, 62 | SCHOOL_FEATURE_SETS 63 | ); 64 | const [degree, degreeScore] = getTextWithHighestFeatureScore( 65 | textItems, 66 | DEGREE_FEATURE_SETS 67 | ); 68 | const [gpa, gpaScores] = getTextWithHighestFeatureScore( 69 | textItems, 70 | GPA_FEATURE_SETS 71 | ); 72 | const [date, dateScores] = getTextWithHighestFeatureScore( 73 | textItems, 74 | DATE_FEATURE_SETS 75 | ); 76 | 77 | let descriptions: string[] = []; 78 | const descriptionsLineIdx = getDescriptionsLineIdx(subsectionsLines); 79 | if (descriptionsLineIdx !== undefined) { 80 | const descriptionLines = subsectionsLines.slice(descriptionsLineIdx); 81 | descriptions = getBulletPointsFromLines(descriptionLines); 82 | } 83 | 84 | educations.push({ school, degree, gpa, date, descriptions }); 85 | educationScores.push({ 86 | schoolScores, 87 | degreeScore, 88 | gpaScores, 89 | dateScores, 90 | }); 91 | } 92 | 93 | if (educations.length !== 0) { 94 | const courseLines = getSectionLinesByKeywords(sections, ["course"]); 95 | if (courseLines.length !== 0) { 96 | educations[0].descriptions.push( 97 | "Courses: " + 98 | courseLines 99 | .flat() 100 | .map((item) => item.text) 101 | .join(" ") 102 | ); 103 | } 104 | } 105 | 106 | return { 107 | educations, 108 | educationScores, 109 | }; 110 | }; 111 | -------------------------------------------------------------------------------- /app/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-profile.ts: -------------------------------------------------------------------------------- 1 | import { 2 | hasLetter, 3 | hasLetterAndIsAllUpperCase, 4 | isBold, 5 | } from "../group-lines-into-sections"; 6 | import { FeatureSet, ResumeSectionToLines, TextItem } from "../types"; 7 | import { getTextWithHighestFeatureScore } from "./lib/feature-scoring-system"; 8 | import { getSectionLinesByKeywords } from "./lib/get-section-lines"; 9 | 10 | //Name 11 | export const matchOnlyLetterSpaceOrPeriod = (item: TextItem) => 12 | item.text.match(/^[A-Za-z\s\.]+$/); 13 | 14 | //Email 15 | export const matchEmail = (item: TextItem) => item.text.match(/\S+@\S+\.\S+/); 16 | const hasAt = (item: TextItem) => item.text.includes("@"); 17 | 18 | //Phone 19 | export const matchPhone = (item: TextItem) => 20 | item.text.match(/\(?\d{3}\)?[\s-]?\d{3}[\s-]?\d{4}/); 21 | const hasParenthesis = (item: TextItem) => /\([0-9]+\)/.test(item.text); 22 | export const hasNumber = (item: TextItem) => /[0-9]/.test(item.text); 23 | 24 | //Location 25 | export const matchCityAndState = (item: TextItem) => 26 | item.text.match(/[A-Z][a-zA-Z\s]+,[A-Z]{2}/); 27 | 28 | export const hasComma = (item: TextItem) => item.text.includes(","); 29 | 30 | //Url 31 | export const matchUrl = (item: TextItem) => item.text.match(/\S+\.[a-z]+\/S+/); 32 | 33 | const matchUrlHttpFallback = (item: TextItem) => 34 | item.text.match(/https?:\/\/S+\.\S+/); 35 | 36 | const matchUrlWwwFallback = (item: TextItem) => 37 | item.text.match(/www\.\S+\.\S+/); 38 | const hasSlash = (item: TextItem) => item.text.includes("/"); 39 | 40 | //Summary 41 | const has4OrMoreWords = (item: TextItem) => item.text.split(" ").length >= 4; 42 | 43 | const NAME_FEATURE_SETS: FeatureSet[] = [ 44 | [matchOnlyLetterSpaceOrPeriod, 3, true], 45 | [isBold, 2], 46 | [hasLetterAndIsAllUpperCase, 2], 47 | 48 | [hasAt, -4], 49 | [hasNumber, -4], 50 | [hasParenthesis, -4], 51 | [hasSlash, -4], 52 | [hasComma, -4], 53 | [has4OrMoreWords, -2], 54 | ]; 55 | 56 | const EMAIL_FEATURE_SETS: FeatureSet[] = [ 57 | [matchEmail, 4, true], 58 | [isBold, -1], 59 | [hasLetterAndIsAllUpperCase, -1], 60 | [hasParenthesis, -4], 61 | [hasComma, -4], 62 | [hasSlash, -4], 63 | [has4OrMoreWords, -4], 64 | ]; 65 | 66 | const PHONE_FEATURE_SETS: FeatureSet[] = [ 67 | [matchPhone, 4, true], 68 | [hasLetter, -4], 69 | ]; 70 | 71 | const LOCATION_FEATURE_SETS: FeatureSet[] = [ 72 | [matchCityAndState, 4, true], 73 | [isBold, -1], 74 | [hasAt, -4], 75 | [hasParenthesis, -3], 76 | [hasSlash, -4], 77 | ]; 78 | 79 | const URL_FEATURE_SETS: FeatureSet[] = [ 80 | [matchUrl, 4, true], 81 | [matchUrlHttpFallback, 3, true], 82 | [matchUrlWwwFallback, 3, true], 83 | [isBold, -1], 84 | [hasAt, -4], 85 | [hasParenthesis, -3], 86 | [hasComma, -4], 87 | [has4OrMoreWords, -4], 88 | ]; 89 | 90 | const SUMMARY_FEATURE_SETS: FeatureSet[] = [ 91 | [has4OrMoreWords, 4], 92 | [isBold, -1], 93 | [hasAt, -4], 94 | [hasParenthesis, -3], 95 | [matchCityAndState, -4, false], 96 | ]; 97 | 98 | export const extractProfile = (sections: ResumeSectionToLines) => { 99 | const lines = sections.profile || []; 100 | const textItems = lines.flat(); 101 | 102 | const [name, nameScores] = getTextWithHighestFeatureScore( 103 | textItems, 104 | NAME_FEATURE_SETS 105 | ); 106 | 107 | const [email, emailScores] = getTextWithHighestFeatureScore( 108 | textItems, 109 | EMAIL_FEATURE_SETS 110 | ); 111 | const [phone, phoneScores] = getTextWithHighestFeatureScore( 112 | textItems, 113 | PHONE_FEATURE_SETS 114 | ); 115 | const [location, locationScores] = getTextWithHighestFeatureScore( 116 | textItems, 117 | LOCATION_FEATURE_SETS 118 | ); 119 | const [url, urlScores] = getTextWithHighestFeatureScore( 120 | textItems, 121 | URL_FEATURE_SETS 122 | ); 123 | const [summary, summaryScores] = getTextWithHighestFeatureScore( 124 | textItems, 125 | SUMMARY_FEATURE_SETS, 126 | undefined, 127 | true 128 | ); 129 | 130 | const summaryLines = getSectionLinesByKeywords(sections, ["summary"]); 131 | const summarySection = summaryLines 132 | .flat() 133 | .map((textItem) => textItem.text) 134 | .join(" "); 135 | 136 | const objectiveLines = getSectionLinesByKeywords(sections, ["objective"]); 137 | const objectiveSection = objectiveLines 138 | .flat() 139 | .map((textItem) => textItem.text) 140 | .join(" "); 141 | 142 | return { 143 | profile: { 144 | name, 145 | email, 146 | phone, 147 | location, 148 | url, 149 | summary: summarySection || objectiveSection || summary, 150 | }, 151 | profileScores: { 152 | name: nameScores, 153 | email: emailScores, 154 | phone: phoneScores, 155 | location: locationScores, 156 | url: urlScores, 157 | summary: summaryScores, 158 | }, 159 | }; 160 | }; 161 | -------------------------------------------------------------------------------- /app/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-project.ts: -------------------------------------------------------------------------------- 1 | import { ResumeProject } from "../../redux/types"; 2 | import { isBold } from "../group-lines-into-sections"; 3 | import { FeatureSet, ResumeSectionToLines } from "../types"; 4 | import { 5 | getBulletPointsFromLines, 6 | getDescriptionsLineIdx, 7 | } from "./lib/bullet-points"; 8 | import { DATE_FEATURE_SETS, getHasText } from "./lib/common-features"; 9 | import { getTextWithHighestFeatureScore } from "./lib/feature-scoring-system"; 10 | import { getSectionLinesByKeywords } from "./lib/get-section-lines"; 11 | import { divideSectionIntoSubsections } from "./lib/subsections"; 12 | 13 | export const extractProject = (sections: ResumeSectionToLines) => { 14 | const projects: ResumeProject[] = []; 15 | const projectScores = []; 16 | const lines = getSectionLinesByKeywords(sections, ["project"]); 17 | const subsections = divideSectionIntoSubsections(lines); 18 | 19 | for (const subsectionLines of subsections) { 20 | const descriptionLineIdx = getDescriptionsLineIdx(subsectionLines) ?? 1; 21 | 22 | const subsectionInfoTextItems = subsectionLines 23 | .slice(0, descriptionLineIdx) 24 | .flat(); 25 | 26 | const [date, dateScores] = getTextWithHighestFeatureScore( 27 | subsectionInfoTextItems, 28 | DATE_FEATURE_SETS 29 | ); 30 | 31 | const PROJECT_FEATURE_SET: FeatureSet[] = [ 32 | [isBold, 2], 33 | [getHasText(date), -4], 34 | ]; 35 | 36 | const [project, projectScore] = getTextWithHighestFeatureScore( 37 | subsectionInfoTextItems, 38 | PROJECT_FEATURE_SET, 39 | false 40 | ); 41 | 42 | const descriptionsLines = subsectionLines.slice(descriptionLineIdx); 43 | const descriptions = getBulletPointsFromLines(descriptionsLines); 44 | 45 | projects.push({ project, date, descriptions }); 46 | projectScores.push({ 47 | projectScore, 48 | dateScores, 49 | }); 50 | } 51 | 52 | return { projects, projectScores }; 53 | }; 54 | -------------------------------------------------------------------------------- /app/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-skill.ts: -------------------------------------------------------------------------------- 1 | import { initialFeaturedSkills } from "../../redux/resumeSlice"; 2 | import { ResumeSkills } from "../../redux/types"; 3 | import { deepClone } from "../deep-clone"; 4 | import { ResumeSectionToLines } from "../types"; 5 | import { 6 | getBulletPointsFromLines, 7 | getDescriptionsLineIdx, 8 | } from "./lib/bullet-points"; 9 | import { getSectionLinesByKeywords } from "./lib/get-section-lines"; 10 | 11 | export const extractSkills = (sections: ResumeSectionToLines) => { 12 | const lines = getSectionLinesByKeywords(sections, ["skill"]); 13 | const descriptionLineIdx = getDescriptionsLineIdx(lines) ?? 0; 14 | const descriptionLines = lines.slice(descriptionLineIdx); 15 | const descriptions = getBulletPointsFromLines(descriptionLines); 16 | 17 | const featuredSkills = deepClone(initialFeaturedSkills); 18 | if (descriptionLineIdx !== 0) { 19 | const featuredSkillLines = lines.slice(0, descriptionLineIdx); 20 | const featuredSkillsTextItems = featuredSkillLines 21 | .flat() 22 | .filter((item) => item.text.trim()) 23 | .slice(0, 6); 24 | 25 | for (let i = 0; i < featuredSkillsTextItems.length; i++) { 26 | featuredSkills[i].skill = featuredSkillsTextItems[i].text; 27 | } 28 | } 29 | 30 | const skills: ResumeSkills = { 31 | featuredSkills, 32 | descriptions, 33 | }; 34 | 35 | return { skills }; 36 | }; 37 | -------------------------------------------------------------------------------- /app/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-work-experience.ts: -------------------------------------------------------------------------------- 1 | import { ResumeWorkExperience } from "../../redux/types"; 2 | import { isBold } from "../group-lines-into-sections"; 3 | import { FeatureSet, ResumeSectionToLines, TextItem } from "../types"; 4 | import { hasNumber } from "./extract-profile"; 5 | import { 6 | getBulletPointsFromLines, 7 | getDescriptionsLineIdx, 8 | } from "./lib/bullet-points"; 9 | import { DATE_FEATURE_SETS, getHasText } from "./lib/common-features"; 10 | import { getTextWithHighestFeatureScore } from "./lib/feature-scoring-system"; 11 | import { getSectionLinesByKeywords } from "./lib/get-section-lines"; 12 | import { divideSectionIntoSubsections } from "./lib/subsections"; 13 | 14 | const WORK_EXPERIENCE_KEYWORDS_LOWERCASE = [ 15 | "work", 16 | "experience", 17 | "employment", 18 | "history", 19 | "job", 20 | ]; 21 | 22 | const JOB_TITLES = [ 23 | "Analyst", 24 | "Agent", 25 | "Administrator", 26 | "Architect", 27 | "Assistant", 28 | "Associate", 29 | "CTO", 30 | ]; 31 | 32 | const hasJobTitle = (item: TextItem) => 33 | JOB_TITLES.some((jobTitle) => 34 | item.text.split(/\s/).some((word) => word === jobTitle) 35 | ); 36 | 37 | const hasMoreThan5Words = (item: TextItem) => item.text.split(/\s/).length > 5; 38 | 39 | const JOB_TITLE_FEATURE_LIST: FeatureSet[] = [ 40 | [hasJobTitle, 4], 41 | [hasNumber, -4], 42 | [hasMoreThan5Words, -2], 43 | ]; 44 | 45 | export const extractWorkExperience = (sections: ResumeSectionToLines) => { 46 | const workExperiences: ResumeWorkExperience[] = []; 47 | const workExperiencesScores = []; 48 | const lines = getSectionLinesByKeywords( 49 | sections, 50 | WORK_EXPERIENCE_KEYWORDS_LOWERCASE 51 | ); 52 | const subsections = divideSectionIntoSubsections(lines); 53 | 54 | for (const subsectionLines of subsections) { 55 | const descriptionsLineIdx = getDescriptionsLineIdx(subsectionLines) ?? 2; 56 | 57 | const subsectionInfoTextItems = subsectionLines 58 | .slice(0, descriptionsLineIdx) 59 | .flat(); 60 | 61 | const [date, dateScores] = getTextWithHighestFeatureScore( 62 | subsectionInfoTextItems, 63 | DATE_FEATURE_SETS 64 | ); 65 | 66 | const [jobTitle, jonTitleScores] = getTextWithHighestFeatureScore( 67 | subsectionInfoTextItems, 68 | JOB_TITLE_FEATURE_LIST 69 | ); 70 | 71 | const COMPANY_FEATURE_SET: FeatureSet[] = [ 72 | [isBold, 2], 73 | [getHasText(date), -4], 74 | [getHasText(jobTitle), -4], 75 | ]; 76 | 77 | const [company, companyScores] = getTextWithHighestFeatureScore( 78 | subsectionInfoTextItems, 79 | COMPANY_FEATURE_SET, 80 | false 81 | ); 82 | 83 | const subsectionDescriptionLines = 84 | subsectionLines.slice(descriptionsLineIdx); 85 | const descriptions = getBulletPointsFromLines(subsectionDescriptionLines); 86 | 87 | workExperiences.push({ company, jobTitle, date, descriptions }); 88 | workExperiencesScores.push({ 89 | companyScores, 90 | jonTitleScores, 91 | dateScores, 92 | }); 93 | } 94 | 95 | return { workExperiences, workExperiencesScores }; 96 | }; 97 | -------------------------------------------------------------------------------- /app/lib/parse-resume-from-pdf/extract-resume-from-sections/index.ts: -------------------------------------------------------------------------------- 1 | import { Resume } from "../../redux/types"; 2 | import { ResumeSectionToLines } from "../types"; 3 | import { extractEducation } from "./extract-education"; 4 | import { extractProfile } from "./extract-profile"; 5 | import { extractProject } from "./extract-project"; 6 | import { extractSkills } from "./extract-skill"; 7 | import { extractWorkExperience } from "./extract-work-experience"; 8 | 9 | export const extractResumeFromSections = ( 10 | sections: ResumeSectionToLines 11 | ): Resume => { 12 | const { profile } = extractProfile(sections); 13 | const { educations } = extractEducation(sections); 14 | const { workExperiences } = extractWorkExperience(sections); 15 | const { projects } = extractProject(sections); 16 | const { skills } = extractSkills(sections); 17 | 18 | return { 19 | profile, 20 | educations, 21 | workExperiences, 22 | projects, 23 | skills, 24 | custom: { 25 | descriptions: [], 26 | }, 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /app/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/bullet-points.ts: -------------------------------------------------------------------------------- 1 | import { Lines, TextItem } from "../../types"; 2 | 3 | export const BULLET_POINTS = [ 4 | "⋅", 5 | "∙", 6 | "🞄", 7 | "•", 8 | "⦁", 9 | "⚫︎", 10 | "●", 11 | "⬤", 12 | "⚬", 13 | "○", 14 | ]; 15 | 16 | const getFirstBulletPointLineIdx = (lines: Lines): number | undefined => { 17 | for (let i = 0; i < lines.length; i++) { 18 | for (let item of lines[i]) { 19 | if (BULLET_POINTS.some((bullet) => item.text.includes(bullet))) { 20 | return i; 21 | } 22 | } 23 | } 24 | 25 | return undefined; 26 | }; 27 | 28 | const isWord = (str: string) => /^[^0-9]+$/.test(str); 29 | 30 | const hasAtLeast8Words = (item: TextItem) => 31 | item.text.split(/\s/).filter(isWord).length >= 8; 32 | 33 | export const getDescriptionsLineIdx = (lines: Lines): number | undefined => { 34 | let idx = getFirstBulletPointLineIdx(lines); 35 | 36 | if (idx === undefined) { 37 | for (let i = 0; i < lines.length; i++) { 38 | const line = lines[i]; 39 | if (line.length === 1 && hasAtLeast8Words(line[0])) { 40 | idx = i; 41 | break; 42 | } 43 | } 44 | } 45 | 46 | return idx; 47 | }; 48 | 49 | export const getBulletPointsFromLines = (lines: Lines): string[] => { 50 | const firstBulletPointLineIndex = getFirstBulletPointLineIdx(lines); 51 | if (firstBulletPointLineIndex === undefined) { 52 | return lines.map((line) => line.map((item) => item.text).join(" ")); 53 | } 54 | 55 | let lineStr = ""; 56 | for (let item of lines.flat()) { 57 | const text = item.text; 58 | 59 | if (!lineStr.endsWith(" ") && !text.startsWith(" ")) { 60 | lineStr += " "; 61 | } 62 | lineStr += text; 63 | } 64 | 65 | const commonBulletPoint = getMostCommonBulletPoint(lineStr); 66 | 67 | const firstBulletPointIndex = lineStr.indexOf(commonBulletPoint); 68 | if (firstBulletPointIndex !== -1) { 69 | lineStr = lineStr.slice(firstBulletPointIndex); 70 | } 71 | 72 | return lineStr 73 | .split(commonBulletPoint) 74 | .map((text) => text.trim()) 75 | .filter((text) => !!text); 76 | }; 77 | 78 | const getMostCommonBulletPoint = (str: string): string => { 79 | const bulletToCount: { [bullet: string]: number } = BULLET_POINTS.reduce( 80 | (acc: { [bullet: string]: number }, curr) => { 81 | acc[curr] = 0; 82 | return acc; 83 | }, 84 | {} 85 | ); 86 | 87 | let bulletWithMostCount = BULLET_POINTS[0]; 88 | let bulletMaxCount = 0; 89 | for (let char of str) { 90 | if (bulletToCount.hasOwnProperty(char)) { 91 | bulletToCount[char]++; 92 | if (bulletToCount[char] > bulletMaxCount) { 93 | bulletWithMostCount = char; 94 | } 95 | } 96 | } 97 | 98 | return bulletWithMostCount; 99 | }; 100 | -------------------------------------------------------------------------------- /app/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/common-features.ts: -------------------------------------------------------------------------------- 1 | export const getHasText = (text: string) => (item: TextItem) => 2 | item.text.includes(text); 3 | 4 | //Date Features 5 | 6 | import { FeatureSet, TextItem } from "../../types"; 7 | import { hasComma } from "../extract-profile"; 8 | 9 | const hasYear = (item: TextItem) => /(?:19|20)\d{2}/.test(item.text); 10 | 11 | const MONTHS = [ 12 | "January", 13 | "February", 14 | "March", 15 | "April", 16 | "May", 17 | "June", 18 | "July", 19 | "August", 20 | "September", 21 | "October", 22 | "November", 23 | "December", 24 | ]; 25 | 26 | const hasMonth = (item: TextItem) => 27 | MONTHS.some( 28 | (month) => 29 | item.text.includes(month) || item.text.includes(month.slice(0, 4)) 30 | ); 31 | 32 | const SEASONS = ["Summer", "Fall", "Spring", "Winter"]; 33 | 34 | const hasSeason = (item: TextItem) => 35 | SEASONS.some((season) => item.text.includes(season)); 36 | 37 | const hasPresent = (item: TextItem) => item.text.includes("Present"); 38 | 39 | export const DATE_FEATURE_SETS: FeatureSet[] = [ 40 | [hasYear, 1], 41 | [hasMonth, 1], 42 | [hasSeason, 1], 43 | [hasPresent, 1], 44 | [hasComma, -1], 45 | ]; 46 | -------------------------------------------------------------------------------- /app/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/feature-scoring-system.ts: -------------------------------------------------------------------------------- 1 | import { FeatureSet, TextItems, TextScores } from "../../types"; 2 | 3 | const computeFeatureScores = ( 4 | textItems: TextItems, 5 | featureSets: FeatureSet[] 6 | ): TextScores => { 7 | const textScores = textItems.map((item) => ({ 8 | text: item.text, 9 | score: 0, 10 | match: false, 11 | })); 12 | 13 | for (let i = 0; i < textItems.length; i++) { 14 | const textItem = textItems[i]; 15 | 16 | for (const featureSet of featureSets) { 17 | const [hasFeature, score, returnMatchingText] = featureSet; 18 | const result = hasFeature(textItem); 19 | if (result) { 20 | let text = textItem.text; 21 | if (returnMatchingText && typeof result === "object") { 22 | text = result[0]; 23 | } 24 | 25 | const textScore = textScores[i]; 26 | if (textItem.text === text) { 27 | textScore.score += score; 28 | if (returnMatchingText) { 29 | textScore.match = true; 30 | } 31 | } else { 32 | textScores.push({ text, score, match: true }); 33 | } 34 | } 35 | } 36 | } 37 | return textScores; 38 | }; 39 | 40 | export const getTextWithHighestFeatureScore = ( 41 | textItems: TextItems, 42 | featureSets: FeatureSet[], 43 | returnEmptyStringIfHighestScoreIsNotPositive = true, 44 | returnConcatenatedStringForTextsWithSameHighestScore = false 45 | ) => { 46 | const textScores = computeFeatureScores(textItems, featureSets); 47 | 48 | let textsWithHighestFeatureScore: string[] = []; 49 | let highestScore = -Infinity; 50 | for (const { text, score } of textScores) { 51 | if (score >= highestScore) { 52 | if (score > highestScore) { 53 | textsWithHighestFeatureScore = []; 54 | } 55 | 56 | textsWithHighestFeatureScore.push(text); 57 | highestScore = score; 58 | } 59 | } 60 | 61 | if (returnEmptyStringIfHighestScoreIsNotPositive && highestScore <= 0) { 62 | return ["", textScores] as const; 63 | } 64 | 65 | const text = !returnConcatenatedStringForTextsWithSameHighestScore 66 | ? textsWithHighestFeatureScore[0] ?? "" 67 | : textsWithHighestFeatureScore.map((s) => s.trim()).join(" "); 68 | 69 | return [text, textScores] as const; 70 | }; 71 | -------------------------------------------------------------------------------- /app/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/get-section-lines.ts: -------------------------------------------------------------------------------- 1 | import { ResumeSectionToLines } from "../../types"; 2 | 3 | export const getSectionLinesByKeywords = ( 4 | sections: ResumeSectionToLines, 5 | keywords: string[] 6 | ) => { 7 | for (const sectionName in sections) { 8 | const hasKeyWord = keywords.some((keyword) => 9 | sectionName.toLowerCase().includes(keyword) 10 | ); 11 | if (hasKeyWord) { 12 | return sections[sectionName]; 13 | } 14 | } 15 | 16 | return []; 17 | }; 18 | -------------------------------------------------------------------------------- /app/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/subsections.ts: -------------------------------------------------------------------------------- 1 | import { isBold } from "../../group-lines-into-sections"; 2 | import { Line, Lines, Subsections } from "../../types"; 3 | 4 | export const divideSectionIntoSubsections = (lines: Lines): Subsections => { 5 | const isLineNewSubsectionByLineGap = 6 | createIsLineNewSubsectionByLineGap(lines); 7 | 8 | let subsections = createSubsections(lines, isLineNewSubsectionByLineGap); 9 | 10 | if (subsections.length === 1) { 11 | const isLineNewSubsectionByBold = (line: Line, prevLine: Line) => { 12 | if (isBold(prevLine[0]) && isBold(line[0])) { 13 | return true; 14 | } 15 | return false; 16 | }; 17 | 18 | subsections = createSubsections(lines, isLineNewSubsectionByBold); 19 | } 20 | 21 | return subsections; 22 | }; 23 | 24 | type IsLineNewSubsection = (line: Line, prevLine: Line) => boolean; 25 | 26 | const createIsLineNewSubsectionByLineGap = ( 27 | lines: Lines 28 | ): IsLineNewSubsection => { 29 | const lineGapToCount: { [lineGap: number]: number } = {}; 30 | const linesY = lines.map((line) => line[0].y); 31 | 32 | let lineGapWithMostCount: number = 0; 33 | let maxCount = 0; 34 | for (let i = 1; i < linesY.length; i++) { 35 | const lineGap = Math.round(linesY[i - 1] - linesY[i]); 36 | if (!lineGapToCount[lineGap]) lineGapToCount[lineGap] = 0; 37 | lineGapToCount[lineGap] += 1; 38 | if (lineGapToCount[lineGap] > maxCount) { 39 | lineGapWithMostCount = lineGap; 40 | maxCount = lineGapToCount[lineGap]; 41 | } 42 | } 43 | 44 | const subsectionLineGapThreshold = lineGapWithMostCount * 1.4; 45 | 46 | const isLineNewSubsection = (line: Line, prevLine: Line) => { 47 | return Math.round(prevLine[0].y - line[0].y) > subsectionLineGapThreshold; 48 | }; 49 | 50 | return isLineNewSubsection; 51 | }; 52 | 53 | const createSubsections = ( 54 | lines: Lines, 55 | isLineNewSubsection: IsLineNewSubsection 56 | ): Subsections => { 57 | const subsections: Subsections = []; 58 | 59 | let subsection: Lines = []; 60 | 61 | for (let i = 0; i < lines.length; i++) { 62 | const line = lines[i]; 63 | if (i === 0) { 64 | subsection.push(line); 65 | continue; 66 | } 67 | if (isLineNewSubsection(line, lines[i - 1])) { 68 | subsections.push(subsection); 69 | subsection = []; 70 | } 71 | subsection.push(line); 72 | } 73 | if (subsection.length > 0) { 74 | subsections.push(subsection); 75 | } 76 | return subsections; 77 | }; 78 | -------------------------------------------------------------------------------- /app/lib/parse-resume-from-pdf/group-lines-into-sections.ts: -------------------------------------------------------------------------------- 1 | import { ResumeKey } from "../redux/types"; 2 | import { Line, Lines, ResumeSectionToLines, TextItem } from "./types"; 3 | 4 | export const PROFILE_SECTION: ResumeKey = "profile"; 5 | 6 | const SECTION_TITLE_PRIMARY_KEYWORDS = [ 7 | "experience", 8 | "education", 9 | "project", 10 | "skill", 11 | ]; 12 | 13 | const SECTION_TITLE_SECONDARY_KEYWORDS = [ 14 | "job", 15 | "course", 16 | "extracurricular", 17 | "objective", 18 | "summary", 19 | "award", 20 | "honor", 21 | "project", 22 | ]; 23 | 24 | const SECTION_TITLE_KEYWORDS = [ 25 | ...SECTION_TITLE_PRIMARY_KEYWORDS, 26 | ...SECTION_TITLE_SECONDARY_KEYWORDS, 27 | ]; 28 | 29 | export const groupLinesIntoSections = (lines: Lines) => { 30 | let sections: ResumeSectionToLines = {}; 31 | let sectionName: string = PROFILE_SECTION; 32 | let sectionLines: any = []; 33 | 34 | for (let i = 0; i < lines.length; i++) { 35 | const line = lines[i]; 36 | const text = line[0]?.text.trim(); 37 | if (isSectionTitle(line, i)) { 38 | sections[sectionName] = [...sectionLines]; 39 | sectionName = text; 40 | sectionLines = []; 41 | } else { 42 | sectionLines.push(line); 43 | } 44 | } 45 | if (sectionLines.length > 0) { 46 | sections[sectionName] = [...sectionLines]; 47 | } 48 | return sections; 49 | }; 50 | 51 | const isSectionTitle = (line: Line, lineNumber: number) => { 52 | const isFirstTwoLines = lineNumber < 2; 53 | const hasMoreThanOneItemInLine = line.length > 1; 54 | const hasNoItemInLine = line.length === 0; 55 | if (isFirstTwoLines || hasMoreThanOneItemInLine || hasNoItemInLine) { 56 | return false; 57 | } 58 | 59 | const textItem = line[0]; 60 | 61 | if (isBold(textItem) && hasLetterAndIsAllUpperCase(textItem)) { 62 | return true; 63 | } 64 | 65 | const text = textItem.text.trim(); 66 | 67 | const textHasAtMost2Words = 68 | text.split(" ").filter((s) => s !== "&").length <= 2; 69 | const startsWithCapitalLetter = /[A-Z]/.test(text.slice(0, 1)); 70 | 71 | if ( 72 | textHasAtMost2Words && 73 | hasOnlyLettersSpacesAmpersands(textItem) && 74 | startsWithCapitalLetter && 75 | SECTION_TITLE_KEYWORDS.some((keyword) => 76 | text.toLowerCase().includes(keyword) 77 | ) 78 | ) { 79 | return true; 80 | } 81 | 82 | return false; 83 | }; 84 | 85 | export const isBold = (item: TextItem) => isTextItemBold(item.fontName); 86 | 87 | const isTextItemBold = (fontName: string) => 88 | fontName.toLowerCase().includes("bold"); 89 | 90 | export const hasLetter = (item: TextItem) => /[a-zA-Z]/.test(item.text); 91 | 92 | export const hasLetterAndIsAllUpperCase = (item: TextItem) => 93 | hasLetter(item) && item.text.toUpperCase() === item.text; 94 | 95 | export const hasOnlyLettersSpacesAmpersands = (item: TextItem) => 96 | /^[A-Za-z\s&]+$/.test(item.text); 97 | -------------------------------------------------------------------------------- /app/lib/parse-resume-from-pdf/group-text-items-into-lines.ts: -------------------------------------------------------------------------------- 1 | import { Line, Lines, TextItems } from "./types"; 2 | 3 | export const groupTextItemsIntoLines = (textItems: TextItems): Lines => { 4 | const lines: Lines = []; 5 | 6 | let line: Line = []; 7 | for (let item of textItems) { 8 | if (item.hasEOL) { 9 | if (item.text.trim() !== "") { 10 | line.push({ ...item }); 11 | } 12 | lines.push(line); 13 | line = []; 14 | } else if (item.text.trim() !== "") { 15 | line.push({ ...item }); 16 | } 17 | } 18 | if (line.length > 0) { 19 | lines.push(line); 20 | } 21 | 22 | const typicalCharWidth = getTypicalCharWidth(lines.flat()); 23 | 24 | for (let line of lines) { 25 | for (let i = line.length - 1; i > 0; i--) { 26 | const currentItem = line[i]; 27 | const leftItem = line[i - 1]; 28 | const leftItemXEnd = leftItem.x + leftItem.width; 29 | const distance = currentItem.x - leftItemXEnd; 30 | if (distance <= typicalCharWidth) { 31 | if (shouldAddSpaceBetweenText(leftItem.text, currentItem.text)) { 32 | leftItem.text += " "; 33 | } 34 | leftItem.text += currentItem.text; 35 | 36 | const currentItemXEnd = currentItem.x + currentItem.width; 37 | leftItem.width = currentItemXEnd - leftItem.x; 38 | line.splice(i, 1); 39 | } 40 | } 41 | } 42 | 43 | return lines; 44 | }; 45 | 46 | const shouldAddSpaceBetweenText = (leftText: string, rightText: string) => { 47 | const leftTextEnd = leftText[leftText.length - 1]; 48 | const rightTextStart = rightText[0]; 49 | const conditions = [ 50 | [":", ",", "|", "."].includes(leftTextEnd) && rightTextStart !== " ", 51 | leftTextEnd !== " " && ["|"].includes(rightTextStart), 52 | ]; 53 | 54 | return conditions.some((condition) => condition); 55 | }; 56 | 57 | const getTypicalCharWidth = (textItems: TextItems): number => { 58 | textItems = textItems.filter((item) => item.text.trim() !== ""); 59 | 60 | const heightToCount: { [height: number]: number } = {}; 61 | let commonHeight = 0; 62 | let heightMaxCount = 0; 63 | 64 | const fontNameToCount: { [fontName: string]: number } = {}; 65 | let commonFontName = ""; 66 | let fontNameMaxCount = 0; 67 | 68 | for (let item of textItems) { 69 | const { text, height, fontName } = item; 70 | 71 | if (!heightToCount[height]) { 72 | heightToCount[height] = 0; 73 | } 74 | heightToCount[height]++; 75 | if (heightToCount[height] > heightMaxCount) { 76 | commonHeight = height; 77 | heightMaxCount = heightToCount[height]; 78 | } 79 | 80 | if (!fontNameToCount[fontName]) { 81 | fontNameToCount[fontName] = 0; 82 | } 83 | fontNameToCount[fontName] += text.length; 84 | if (fontNameToCount[fontName] > fontNameMaxCount) { 85 | commonFontName = fontName; 86 | fontNameMaxCount = fontNameToCount[fontName]; 87 | } 88 | } 89 | 90 | const commonTextItems = textItems.filter( 91 | (item) => item.fontName === commonFontName && item.height === commonHeight 92 | ); 93 | 94 | const [totalWidth, numChars] = commonTextItems.reduce( 95 | (acc, cur) => { 96 | const [preWidth, prevChars] = acc; 97 | return [preWidth + cur.width, prevChars + cur.text.length]; 98 | }, 99 | [0, 0] 100 | ); 101 | 102 | const typicalCharWidth = totalWidth / numChars; 103 | 104 | return typicalCharWidth; 105 | }; 106 | -------------------------------------------------------------------------------- /app/lib/parse-resume-from-pdf/index.ts: -------------------------------------------------------------------------------- 1 | import { extractResumeFromSections } from "./extract-resume-from-sections"; 2 | import { groupLinesIntoSections } from "./group-lines-into-sections"; 3 | import { groupTextItemsIntoLines } from "./group-text-items-into-lines"; 4 | import { readPdf } from "./read-pdf"; 5 | 6 | export const parseResumeFromPdf = async (fileUrl: string) => { 7 | //step 1. Read a pdf resume file into text items to prepare for processing 8 | const textItems = await readPdf(fileUrl); 9 | 10 | //step 2. Group text items into lines 11 | const lines = groupTextItemsIntoLines(textItems); 12 | 13 | //step 3. Group lines into sections 14 | const sections = groupLinesIntoSections(lines); 15 | 16 | //step 4. Extract resume from sections 17 | const resume = extractResumeFromSections(sections); 18 | 19 | return resume; 20 | }; 21 | -------------------------------------------------------------------------------- /app/lib/parse-resume-from-pdf/read-pdf.ts: -------------------------------------------------------------------------------- 1 | import { TextItem, TextItems } from "./types"; 2 | import * as pdfjs from "pdfjs-dist"; 3 | 4 | import pdfjsWorker from "pdfjs-dist/build/pdf.worker.entry"; 5 | pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorker; 6 | 7 | import type { TextItem as PdfjsTextItem } from "pdfjs-dist/types/src/display/api"; 8 | 9 | export const readPdf = async (fileUrl: string): Promise => { 10 | const pdffile = await pdfjs.getDocument(fileUrl).promise; 11 | let textItems: TextItems = []; 12 | 13 | for (let i = 1; i <= pdffile.numPages; i++) { 14 | const page = await pdffile.getPage(i); 15 | const textContent = await page.getTextContent(); 16 | 17 | await page.getOperatorList(); 18 | const commonObjs = page.commonObjs; 19 | 20 | const pageTextItems = textContent.items.map((item) => { 21 | const { 22 | str: text, 23 | dir, 24 | transform, 25 | fontName: pdfFontName, 26 | ...otherProps 27 | } = item as PdfjsTextItem; 28 | 29 | const x = transform[4]; 30 | const y = transform[5]; 31 | 32 | const fontObj = commonObjs.get(pdfFontName); 33 | const fontName = fontObj.name; 34 | 35 | const newText = text.replace(/--/g, "-"); 36 | 37 | const newItem = { 38 | ...otherProps, 39 | fontName, 40 | text: newText, 41 | x, 42 | y, 43 | }; 44 | 45 | return newItem; 46 | }); 47 | 48 | textItems.push(...pageTextItems); 49 | } 50 | 51 | const isEmptySpace = (textItem: TextItem) => 52 | !textItem.hasEOL && textItem.text.trim() === ""; 53 | 54 | textItems = textItems.filter((textItem) => !isEmptySpace(textItem)); 55 | 56 | return textItems; 57 | }; 58 | -------------------------------------------------------------------------------- /app/lib/parse-resume-from-pdf/types.ts: -------------------------------------------------------------------------------- 1 | import type { ResumeKey } from "../redux/types"; 2 | export interface TextItem { 3 | text: string; 4 | x: number; 5 | y: number; 6 | width: number; 7 | height: number; 8 | fontName: string; 9 | hasEOL: boolean; 10 | } 11 | 12 | export type TextItems = TextItem[]; 13 | 14 | export type Line = TextItem[]; 15 | export type Lines = Line[]; 16 | 17 | export type Subsections = Lines[]; 18 | 19 | export type ResumeSectionToLines = { [sectionName in ResumeKey]?: Lines } & { 20 | [otherSectionName: string]: Lines; 21 | }; 22 | 23 | type FeatureScore = -4 | -3 | -2 | -1 | 0 | 1 | 2 | 3 | 4; 24 | type ReturnMatchingTextOnly = boolean; 25 | 26 | export type FeatureSet = 27 | | [(item: TextItem) => boolean, FeatureScore] 28 | | [ 29 | (item: TextItem) => RegExpMatchArray | null, 30 | FeatureScore, 31 | ReturnMatchingTextOnly 32 | ]; 33 | 34 | export interface TextScore { 35 | text: string; 36 | score: number; 37 | match: boolean; 38 | } 39 | 40 | export type TextScores = TextScore[]; 41 | -------------------------------------------------------------------------------- /app/lib/redux/hooks.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { AppDispatch, RootState, store } from "./store"; 3 | import { 4 | loadStateFromLocalStorage, 5 | saveStateToLocalStorage, 6 | } from "./local-storage"; 7 | import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; 8 | import { deepMerge } from "../deep-merge"; 9 | import { initialResumeState, setResume } from "./resumeSlice"; 10 | import { Resume } from "./types"; 11 | import { Settings, initialSettings, setSettings } from "./settingsSlice"; 12 | 13 | export const useAppDispatch: () => AppDispatch = useDispatch; 14 | export const useAppSelector: TypedUseSelectorHook = useSelector; 15 | 16 | export const useSaveStateToLocalStorageOnChange = () => { 17 | useEffect(() => { 18 | const unsubscribe = store.subscribe(() => { 19 | saveStateToLocalStorage(store.getState()); 20 | }); 21 | return unsubscribe; 22 | }, []); 23 | }; 24 | 25 | export const useSetInitialStore = () => { 26 | const dispatch = useAppDispatch(); 27 | useEffect(() => { 28 | const state = loadStateFromLocalStorage(); 29 | if (!state) return; 30 | if (state.resume) { 31 | const mergedResumeState = deepMerge( 32 | initialResumeState, 33 | state.resume 34 | ) as Resume; 35 | dispatch(setResume(mergedResumeState)); 36 | } 37 | if (state.settings) { 38 | const mergedSettingsState = deepMerge( 39 | initialSettings, 40 | state.settings 41 | ) as Settings; 42 | dispatch(setSettings(mergedSettingsState)); 43 | } 44 | }, []); 45 | }; 46 | -------------------------------------------------------------------------------- /app/lib/redux/local-storage.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from "./store"; 2 | 3 | const LOCAL_STORAGE_KEY = "resume-builder-parser-state"; 4 | 5 | export const saveStateToLocalStorage = (state: RootState) => { 6 | try { 7 | const stringifiedState = JSON.stringify(state); 8 | localStorage.setItem(LOCAL_STORAGE_KEY, stringifiedState); 9 | } catch (e) {} 10 | }; 11 | 12 | export const loadStateFromLocalStorage = () => { 13 | try { 14 | const stringifiedState = localStorage.getItem(LOCAL_STORAGE_KEY); 15 | if (!stringifiedState) return undefined; 16 | return JSON.parse(stringifiedState); 17 | } catch (e) { 18 | return undefined; 19 | } 20 | }; 21 | 22 | export const getHasUsedAppBefore = () => Boolean(loadStateFromLocalStorage()); 23 | -------------------------------------------------------------------------------- /app/lib/redux/resumeSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; 2 | import { 3 | FeaturedSkill, 4 | Resume, 5 | ResumeEducation, 6 | ResumeProfile, 7 | ResumeProject, 8 | ResumeSkills, 9 | ResumeWorkExperience, 10 | } from "./types"; 11 | import { ShowForm } from "./settingsSlice"; 12 | import { RootState } from "./store"; 13 | 14 | export const initialProfile: ResumeProfile = { 15 | name: "", 16 | summary: "", 17 | email: "", 18 | phone: "", 19 | location: "", 20 | url: "", 21 | }; 22 | 23 | export const initialWorkExperience: ResumeWorkExperience = { 24 | company: "", 25 | jobTitle: "", 26 | date: "", 27 | descriptions: [], 28 | }; 29 | 30 | export const initialEducation: ResumeEducation = { 31 | school: "", 32 | date: "", 33 | degree: "", 34 | gpa: "", 35 | descriptions: [], 36 | }; 37 | 38 | export const initialProject: ResumeProject = { 39 | project: "", 40 | date: "", 41 | descriptions: [], 42 | }; 43 | 44 | export const initialFeaturedSkill: FeaturedSkill = { skill: "", rating: 4 }; 45 | export const initialFeaturedSkills: FeaturedSkill[] = Array(6).fill({ 46 | ...initialFeaturedSkill, 47 | }); 48 | 49 | export const initialSkills: ResumeSkills = { 50 | featuredSkills: initialFeaturedSkills, 51 | descriptions: [], 52 | }; 53 | 54 | export const initialCustom = { 55 | descriptions: [], 56 | }; 57 | 58 | export const initialResumeState: Resume = { 59 | profile: initialProfile, 60 | workExperiences: [initialWorkExperience], 61 | educations: [initialEducation], 62 | projects: [initialProject], 63 | skills: initialSkills, 64 | custom: initialCustom, 65 | }; 66 | 67 | export type CreateChangeActionWithDescriptions = { 68 | idx: number; 69 | } & ( 70 | | { 71 | field: Exclude; 72 | value: string; 73 | } 74 | | { 75 | field: "descriptions"; 76 | value: string[]; 77 | } 78 | ); 79 | 80 | export const resumeSlice = createSlice({ 81 | name: "resume", 82 | initialState: initialResumeState, 83 | reducers: { 84 | changeProfile: ( 85 | draft, 86 | action: PayloadAction<{ field: keyof ResumeProfile; value: string }> 87 | ) => { 88 | const { field, value } = action.payload; 89 | draft.profile[field] = value; 90 | }, 91 | 92 | changeWorkExperience: ( 93 | draft, 94 | action: PayloadAction< 95 | CreateChangeActionWithDescriptions 96 | > 97 | ) => { 98 | const { idx, field, value } = action.payload; 99 | const workExperience = draft.workExperiences[idx]; 100 | workExperience[field] = value as any; 101 | }, 102 | changeEducations: ( 103 | draft, 104 | action: PayloadAction> 105 | ) => { 106 | const { idx, field, value } = action.payload; 107 | const education = draft.educations[idx]; 108 | education[field] = value as any; 109 | }, 110 | changeProjects: ( 111 | draft, 112 | action: PayloadAction> 113 | ) => { 114 | const { idx, field, value } = action.payload; 115 | const project = draft.projects[idx]; 116 | project[field] = value as any; 117 | }, 118 | changeSkills: ( 119 | draft, 120 | action: PayloadAction< 121 | | { field: "descriptions"; value: string[] } 122 | | { 123 | field: "featuredSkills"; 124 | idx: number; 125 | skill: string; 126 | rating: number; 127 | } 128 | > 129 | ) => { 130 | const { field } = action.payload; 131 | if (field === "descriptions") { 132 | const { value } = action.payload; 133 | draft.skills.descriptions = value; 134 | } else { 135 | const { idx, skill, rating } = action.payload; 136 | const featuredSkill = draft.skills.featuredSkills[idx]; 137 | featuredSkill.skill = skill; 138 | featuredSkill.rating = rating; 139 | } 140 | }, 141 | changeCustom: ( 142 | draft, 143 | action: PayloadAction<{ field: "descriptions"; value: string[] }> 144 | ) => { 145 | const { value } = action.payload; 146 | draft.custom.descriptions = value; 147 | }, 148 | addSectionInForm: (draft, action: PayloadAction<{ form: ShowForm }>) => { 149 | const { form } = action.payload; 150 | switch (form) { 151 | case "workExperiences": { 152 | draft.workExperiences.push(structuredClone(initialWorkExperience)); 153 | return draft; 154 | } 155 | case "educations": { 156 | draft.educations.push(structuredClone(initialEducation)); 157 | return draft; 158 | } 159 | case "projects": { 160 | draft.projects.push(structuredClone(initialProject)); 161 | return draft; 162 | } 163 | } 164 | }, 165 | moveSectionInForm: ( 166 | draft, 167 | action: PayloadAction<{ 168 | form: ShowForm; 169 | idx: number; 170 | direction: "up" | "down"; 171 | }> 172 | ) => { 173 | const { form, idx, direction } = action.payload; 174 | if (form !== "skills" && form !== "custom") { 175 | if ( 176 | (idx === 0 && direction === "up") || 177 | (idx === draft[form].length - 1 && direction === "down") 178 | ) { 179 | return draft; 180 | } 181 | 182 | const section = draft[form][idx]; 183 | if (direction === "up") { 184 | draft[form][idx] = draft[form][idx - 1]; 185 | draft[form][idx - 1] = section; 186 | } else { 187 | draft[form][idx] = draft[form][idx + 1]; 188 | draft[form][idx + 1] = section; 189 | } 190 | } 191 | }, 192 | deleteSectionInFormByIdx: ( 193 | draft, 194 | action: PayloadAction<{ form: ShowForm; idx: number }> 195 | ) => { 196 | const { form, idx } = action.payload; 197 | if (form !== "skills" && form !== "custom") { 198 | draft[form].splice(idx, 1); 199 | } 200 | }, 201 | setResume: (draft, action: PayloadAction) => { 202 | return action.payload; 203 | }, 204 | }, 205 | }); 206 | 207 | export const { 208 | changeCustom, 209 | changeEducations, 210 | changeProjects, 211 | changeProfile, 212 | changeSkills, 213 | changeWorkExperience, 214 | addSectionInForm, 215 | moveSectionInForm, 216 | deleteSectionInFormByIdx, 217 | setResume, 218 | } = resumeSlice.actions; 219 | 220 | export const selectResume = (state: RootState) => state.resume; 221 | export const selectProfile = (state: RootState) => state.resume.profile; 222 | export const selectWorkExperiences = (state: RootState) => 223 | state.resume.workExperiences; 224 | export const selectEducations = (state: RootState) => state.resume.educations; 225 | export const selectProjects = (state: RootState) => state.resume.projects; 226 | export const selectSkills = (state: RootState) => state.resume.skills; 227 | export const selectCustom = (state: RootState) => state.resume.custom; 228 | 229 | export default resumeSlice.reducer; 230 | -------------------------------------------------------------------------------- /app/lib/redux/settingsSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; 2 | import { act } from "react-dom/test-utils"; 3 | import { RootState } from "./store"; 4 | 5 | export interface Settings { 6 | themeColor: string; 7 | fontFamily: string; 8 | fontSize: string; 9 | documentSize: string; 10 | formToShow: { 11 | workExperiences: boolean; 12 | educations: boolean; 13 | projects: boolean; 14 | skills: boolean; 15 | custom: boolean; 16 | }; 17 | formToHeading: { 18 | workExperiences: string; 19 | educations: string; 20 | projects: string; 21 | skills: string; 22 | custom: string; 23 | }; 24 | formsOrder: ShowForm[]; 25 | showBulletPoints: { 26 | educations: boolean; 27 | projects: boolean; 28 | skills: boolean; 29 | custom: boolean; 30 | }; 31 | } 32 | 33 | export type ShowForm = keyof Settings["formToShow"]; 34 | export type FormWithBulletPoints = keyof Settings["showBulletPoints"]; 35 | export type GeneralSetting = Exclude< 36 | keyof Settings, 37 | "formToShow" | "formToHeading" | "formsOrder" | "showBulletPoints" 38 | >; 39 | 40 | export const DEFAULT_THEME_COLOR = "#38bdf8"; 41 | export const DEAFULT_FONT_FAMILY = "Roboto"; 42 | export const DEFAULT_FONT_SIZE = "11"; 43 | export const DEFAULT_FONT_COLOR = "#171717"; 44 | 45 | export const initialSettings: Settings = { 46 | themeColor: DEFAULT_THEME_COLOR, 47 | fontFamily: DEAFULT_FONT_FAMILY, 48 | fontSize: DEFAULT_FONT_SIZE, 49 | documentSize: "Letter", 50 | formToShow: { 51 | workExperiences: true, 52 | educations: true, 53 | projects: true, 54 | skills: true, 55 | custom: true, 56 | }, 57 | formToHeading: { 58 | workExperiences: "WORK EXPERIENCE", 59 | educations: "EDUCATION", 60 | projects: "PROJECT", 61 | skills: "SKILLS", 62 | custom: "CUSTOM SECTION", 63 | }, 64 | formsOrder: ["workExperiences", "educations", "projects", "skills", "custom"], 65 | showBulletPoints: { 66 | educations: true, 67 | projects: true, 68 | skills: true, 69 | custom: true, 70 | }, 71 | }; 72 | 73 | export const settingsSlice = createSlice({ 74 | name: "settings", 75 | initialState: initialSettings, 76 | reducers: { 77 | changeSettings: ( 78 | draft, 79 | action: PayloadAction<{ field: GeneralSetting; value: string }> 80 | ) => { 81 | const { field, value } = action.payload; 82 | draft[field] = value; 83 | }, 84 | changeShowForm: ( 85 | draft, 86 | action: PayloadAction<{ field: ShowForm; value: boolean }> 87 | ) => { 88 | const { field, value } = action.payload; 89 | draft.formToShow[field] = value; 90 | }, 91 | changeFormHeading: ( 92 | draft, 93 | action: PayloadAction<{ field: ShowForm; value: string }> 94 | ) => { 95 | const { field, value } = action.payload; 96 | draft.formToHeading[field] = value; 97 | }, 98 | changeFormOrder: ( 99 | draft, 100 | action: PayloadAction<{ form: ShowForm; type: "up" | "down" }> 101 | ) => { 102 | const { form, type } = action.payload; 103 | const lastIdx = draft.formsOrder.length - 1; 104 | const pos = draft.formsOrder.indexOf(form); 105 | const newPos = type === "up" ? pos - 1 : pos + 1; 106 | const swapFormOrder = (idx1: number, idx2: number) => { 107 | const temp = draft.formsOrder[idx1]; 108 | draft.formsOrder[idx1] = draft.formsOrder[idx2]; 109 | draft.formsOrder[idx2] = temp; 110 | }; 111 | if (newPos >= 0 && newPos <= lastIdx) { 112 | swapFormOrder(pos, newPos); 113 | } 114 | }, 115 | changeShowBulletPoints: ( 116 | draft, 117 | action: PayloadAction<{ field: FormWithBulletPoints; value: boolean }> 118 | ) => { 119 | const { field, value } = action.payload; 120 | draft["showBulletPoints"][field] = value; 121 | }, 122 | setSettings: (draft, action: PayloadAction) => { 123 | return action.payload; 124 | }, 125 | }, 126 | }); 127 | 128 | export const { 129 | changeSettings, 130 | changeShowForm, 131 | changeFormHeading, 132 | changeFormOrder, 133 | changeShowBulletPoints, 134 | setSettings, 135 | } = settingsSlice.actions; 136 | 137 | export const selectSettings = (state: RootState) => state.settings; 138 | export const selectThemeColor = (state: RootState) => state.settings.themeColor; 139 | 140 | export const selectFormToShow = (state: RootState) => state.settings.formToShow; 141 | export const selectShowByForm = (form: ShowForm) => (state: RootState) => 142 | state.settings.formToShow[form]; 143 | 144 | export const selectFormToHeading = (state: RootState) => 145 | state.settings.formToHeading; 146 | export const selectHeadingByForm = (form: ShowForm) => (state: RootState) => 147 | state.settings.formToHeading[form]; 148 | 149 | export const selectFormsOrder = (state: RootState) => state.settings.formsOrder; 150 | export const selectIsFirstForm = (form: ShowForm) => (state: RootState) => 151 | state.settings.formsOrder[0] === form; 152 | export const selectIsLastForm = (form: ShowForm) => (state: RootState) => 153 | state.settings.formsOrder[state.settings.formsOrder.length - 1] === form; 154 | 155 | export const selectShowBulletPoints = 156 | (form: FormWithBulletPoints) => (state: RootState) => 157 | state.settings.showBulletPoints[form]; 158 | 159 | export default settingsSlice.reducer; 160 | -------------------------------------------------------------------------------- /app/lib/redux/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import resumeReducer from "./resumeSlice"; 3 | import settingsReducer from "./settingsSlice"; 4 | 5 | export const store = configureStore({ 6 | reducer: { 7 | resume: resumeReducer, 8 | settings: settingsReducer, 9 | }, 10 | }); 11 | 12 | export type RootState = ReturnType; 13 | export type AppDispatch = typeof store.dispatch; 14 | -------------------------------------------------------------------------------- /app/lib/redux/types.ts: -------------------------------------------------------------------------------- 1 | export interface ResumeProfile { 2 | name: string; 3 | email: string; 4 | phone: string; 5 | url: string; 6 | summary: string; 7 | location: string; 8 | } 9 | 10 | export interface ResumeWorkExperience { 11 | company: string; 12 | jobTitle: string; 13 | date: string; 14 | descriptions: string[]; 15 | } 16 | 17 | export interface ResumeEducation { 18 | school: string; 19 | degree: string; 20 | date: string; 21 | gpa: string; 22 | descriptions: string[]; 23 | } 24 | 25 | export interface ResumeProject { 26 | project: string; 27 | date: string; 28 | descriptions: string[]; 29 | } 30 | 31 | export interface FeaturedSkill { 32 | skill: string; 33 | rating: number; 34 | } 35 | 36 | export interface ResumeSkills { 37 | featuredSkills: FeaturedSkill[]; 38 | descriptions: string[]; 39 | } 40 | 41 | export interface ResumeCustom { 42 | descriptions: string[]; 43 | } 44 | 45 | export interface Resume { 46 | profile: ResumeProfile; 47 | workExperiences: ResumeWorkExperience[]; 48 | educations: ResumeEducation[]; 49 | projects: ResumeProject[]; 50 | skills: ResumeSkills; 51 | custom: ResumeCustom; 52 | } 53 | 54 | export type ResumeKey = keyof Resume; 55 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { Hero } from "./home/Hero"; 3 | import { Steps } from "./home/Steps"; 4 | 5 | export default function Home() { 6 | return ( 7 |
8 | 9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /app/resume-builder/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Provider } from "react-redux"; 4 | import { store } from "../lib/redux/store"; 5 | import { ResumeForm } from "../components/ResumeForm"; 6 | import { Resume } from "../components/Resume"; 7 | 8 | export default function Create() { 9 | return ( 10 | 11 |
12 |
13 |
14 | 15 |
16 |
17 | 18 |
19 |
20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/resume-import/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import { getHasUsedAppBefore } from "../lib/redux/local-storage"; 5 | import Link from "next/link"; 6 | import ResumeDropzone from "../components/ResumeDropzone"; 7 | 8 | export default function ImportResume() { 9 | const [hasUsedAppBefore, setHasUsedAppBefore] = useState(false); 10 | const [hasAddedResume, setHasAddedResume] = useState(false); 11 | 12 | const onFileUrlChange = (fileUrl: string) => { 13 | setHasAddedResume(Boolean(fileUrl)); 14 | }; 15 | 16 | useEffect(() => { 17 | setHasUsedAppBefore(getHasUsedAppBefore()); 18 | }, []); 19 | 20 | return ( 21 |
22 |
23 | {!hasUsedAppBefore ? ( 24 | <> 25 |

26 | Import data from an existing resume 27 |

28 | 32 | {!hasAddedResume && ( 33 | <> 34 | 35 | 39 | 40 | )} 41 | 42 | ) : ( 43 | <> 44 | {!hasAddedResume && ( 45 | <> 46 | 50 | 51 | 52 | )} 53 |

54 | Override data with a new resume 55 |

56 | 60 | 61 | )} 62 |
63 |
64 | ); 65 | } 66 | 67 | const OrDivider = () => ( 68 |