Moved to _dev
This commit is contained in:
46
open-resume/src/app/components/Button.tsx
Normal file
46
open-resume/src/app/components/Button.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { cx } from "lib/cx";
|
||||
import { Tooltip } from "components/Tooltip";
|
||||
|
||||
type ReactButtonProps = React.ComponentProps<"button">;
|
||||
type ReactAnchorProps = React.ComponentProps<"a">;
|
||||
type ButtonProps = ReactButtonProps | ReactAnchorProps;
|
||||
|
||||
const isAnchor = (props: ButtonProps): props is ReactAnchorProps => {
|
||||
return "href" in props;
|
||||
};
|
||||
|
||||
export const Button = (props: ButtonProps) => {
|
||||
if (isAnchor(props)) {
|
||||
return <a {...props} />;
|
||||
} else {
|
||||
return <button type="button" {...props} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const PrimaryButton = ({ className, ...props }: ButtonProps) => (
|
||||
<Button className={cx("btn-primary", className)} {...props} />
|
||||
);
|
||||
|
||||
type IconButtonProps = ButtonProps & {
|
||||
size?: "small" | "medium";
|
||||
tooltipText: string;
|
||||
};
|
||||
|
||||
export const IconButton = ({
|
||||
className,
|
||||
size = "medium",
|
||||
tooltipText,
|
||||
...props
|
||||
}: IconButtonProps) => (
|
||||
<Tooltip text={tooltipText}>
|
||||
<Button
|
||||
type="button"
|
||||
className={cx(
|
||||
"rounded-full outline-none hover:bg-gray-100 focus-visible:bg-gray-100",
|
||||
size === "medium" ? "p-1.5" : "p-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* ExpanderWithHeightTransition is a div wrapper with built-in transition animation based on height.
|
||||
* If expanded is true, it slowly expands its content and vice versa.
|
||||
*
|
||||
* Note: There is no easy way to animate height transition in CSS: https://github.com/w3c/csswg-drafts/issues/626.
|
||||
* This is a clever solution based on css grid and is borrowed from https://css-tricks.com/css-grid-can-do-auto-height-transitions/
|
||||
*
|
||||
*/
|
||||
export const ExpanderWithHeightTransition = ({
|
||||
expanded,
|
||||
children,
|
||||
}: {
|
||||
expanded: boolean;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`grid overflow-hidden transition-all duration-300 ${
|
||||
expanded ? "visible" : "invisible"
|
||||
}`}
|
||||
style={{ gridTemplateRows: expanded ? "1fr" : "0fr" }}
|
||||
>
|
||||
<div className="min-h-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
19
open-resume/src/app/components/FlexboxSpacer.tsx
Normal file
19
open-resume/src/app/components/FlexboxSpacer.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* FlexboxSpacer can be used to create empty space in flex.
|
||||
* It is a div that grows to fill the available space specified by maxWidth.
|
||||
* You can also set a minimum width with minWidth.
|
||||
*/
|
||||
export const FlexboxSpacer = ({
|
||||
maxWidth,
|
||||
minWidth = 0,
|
||||
className = "",
|
||||
}: {
|
||||
maxWidth: number;
|
||||
minWidth?: number;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div
|
||||
className={`invisible shrink-[10000] grow ${className}`}
|
||||
style={{ maxWidth: `${maxWidth}px`, minWidth: `${minWidth}px` }}
|
||||
/>
|
||||
);
|
||||
86
open-resume/src/app/components/Resume/ResumeControlBar.tsx
Normal file
86
open-resume/src/app/components/Resume/ResumeControlBar.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
import { useSetDefaultScale } from "components/Resume/hooks";
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
ArrowDownTrayIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { usePDF } from "@react-pdf/renderer";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const ResumeControlBar = ({
|
||||
scale,
|
||||
setScale,
|
||||
documentSize,
|
||||
document,
|
||||
fileName,
|
||||
}: {
|
||||
scale: number;
|
||||
setScale: (scale: number) => void;
|
||||
documentSize: string;
|
||||
document: JSX.Element;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const { scaleOnResize, setScaleOnResize } = useSetDefaultScale({
|
||||
setScale,
|
||||
documentSize,
|
||||
});
|
||||
|
||||
const [instance, update] = usePDF({ document });
|
||||
|
||||
// Hook to update pdf when document changes
|
||||
useEffect(() => {
|
||||
update();
|
||||
}, [update, document]);
|
||||
|
||||
return (
|
||||
<div className="sticky bottom-0 left-0 right-0 flex h-[var(--resume-control-bar-height)] items-center justify-center px-[var(--resume-padding)] text-gray-600 lg:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<MagnifyingGlassIcon className="h-5 w-5" aria-hidden="true" />
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={1.5}
|
||||
step={0.01}
|
||||
value={scale}
|
||||
onChange={(e) => {
|
||||
setScaleOnResize(false);
|
||||
setScale(Number(e.target.value));
|
||||
}}
|
||||
/>
|
||||
<div className="w-10">{`${Math.round(scale * 100)}%`}</div>
|
||||
<label className="hidden items-center gap-1 lg:flex">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 h-4 w-4"
|
||||
checked={scaleOnResize}
|
||||
onChange={() => setScaleOnResize((prev) => !prev)}
|
||||
/>
|
||||
<span className="select-none">Autoscale</span>
|
||||
</label>
|
||||
</div>
|
||||
<a
|
||||
className="ml-1 flex items-center gap-1 rounded-md border border-gray-300 px-3 py-0.5 hover:bg-gray-100 lg:ml-8"
|
||||
href={instance.url!}
|
||||
download={fileName}
|
||||
>
|
||||
<ArrowDownTrayIcon className="h-4 w-4" />
|
||||
<span className="whitespace-nowrap">Download Resume</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Load ResumeControlBar client side since it uses usePDF, which is a web specific API
|
||||
*/
|
||||
export const ResumeControlBarCSR = dynamic(
|
||||
() => Promise.resolve(ResumeControlBar),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
export const ResumeControlBarBorder = () => (
|
||||
<div className="absolute bottom-[var(--resume-control-bar-height)] w-full border-t-2 bg-gray-50" />
|
||||
);
|
||||
126
open-resume/src/app/components/Resume/ResumeIFrame.tsx
Normal file
126
open-resume/src/app/components/Resume/ResumeIFrame.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
import { useMemo } from "react";
|
||||
import Frame from "react-frame-component";
|
||||
import {
|
||||
A4_HEIGHT_PX,
|
||||
A4_WIDTH_PX,
|
||||
A4_WIDTH_PT,
|
||||
LETTER_HEIGHT_PX,
|
||||
LETTER_WIDTH_PX,
|
||||
LETTER_WIDTH_PT,
|
||||
} from "lib/constants";
|
||||
import dynamic from "next/dynamic";
|
||||
import { getAllFontFamiliesToLoad } from "components/fonts/lib";
|
||||
|
||||
const getIframeInitialContent = (isA4: boolean) => {
|
||||
const width = isA4 ? A4_WIDTH_PT : LETTER_WIDTH_PT;
|
||||
const allFontFamilies = getAllFontFamiliesToLoad();
|
||||
|
||||
const allFontFamiliesPreloadLinks = allFontFamilies
|
||||
.map(
|
||||
(
|
||||
font
|
||||
) => `<link rel="preload" as="font" href="/fonts/${font}-Regular.ttf" type="font/ttf" crossorigin="anonymous">
|
||||
<link rel="preload" as="font" href="/fonts/${font}-Bold.ttf" type="font/ttf" crossorigin="anonymous">`
|
||||
)
|
||||
.join("");
|
||||
|
||||
const allFontFamiliesFontFaces = allFontFamilies
|
||||
.map(
|
||||
(
|
||||
font
|
||||
) => `@font-face {font-family: "${font}"; src: url("/fonts/${font}-Regular.ttf");}
|
||||
@font-face {font-family: "${font}"; src: url("/fonts/${font}-Bold.ttf"); font-weight: bold;}`
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
${allFontFamiliesPreloadLinks}
|
||||
<style>
|
||||
${allFontFamiliesFontFaces}
|
||||
</style>
|
||||
</head>
|
||||
<body style='overflow: hidden; width: ${width}pt; margin: 0; padding: 0; -webkit-text-size-adjust:none;'>
|
||||
<div></div>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Iframe is used here for style isolation, since react pdf uses pt unit.
|
||||
* It creates a sandbox document body that uses letter/A4 pt size as width.
|
||||
*/
|
||||
const ResumeIframe = ({
|
||||
documentSize,
|
||||
scale,
|
||||
children,
|
||||
enablePDFViewer = false,
|
||||
}: {
|
||||
documentSize: string;
|
||||
scale: number;
|
||||
children: React.ReactNode;
|
||||
enablePDFViewer?: boolean;
|
||||
}) => {
|
||||
const isA4 = documentSize === "A4";
|
||||
const iframeInitialContent = useMemo(
|
||||
() => getIframeInitialContent(isA4),
|
||||
[isA4]
|
||||
);
|
||||
|
||||
if (enablePDFViewer) {
|
||||
return (
|
||||
<DynamicPDFViewer className="h-full w-full">
|
||||
{children as any}
|
||||
</DynamicPDFViewer>
|
||||
);
|
||||
}
|
||||
const width = isA4 ? A4_WIDTH_PX : LETTER_WIDTH_PX;
|
||||
const height = isA4 ? A4_HEIGHT_PX : LETTER_HEIGHT_PX;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: `${width * scale}px`,
|
||||
maxHeight: `${height * scale}px`,
|
||||
}}
|
||||
>
|
||||
{/* There is an outer div and an inner div here. The inner div sets the iframe width and uses transform scale to zoom in/out the resume iframe.
|
||||
While zooming out or scaling down via transform, the element appears smaller but still occupies the same width/height. Therefore, we use the
|
||||
outer div to restrict the max width & height proportionally */}
|
||||
<div
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
className={`origin-top-left bg-white shadow-lg`}
|
||||
>
|
||||
<Frame
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
initialContent={iframeInitialContent}
|
||||
// key is used to force component to re-mount when document size changes
|
||||
key={isA4 ? "A4" : "LETTER"}
|
||||
>
|
||||
{children}
|
||||
</Frame>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Load iframe client side since iframe can't be SSR
|
||||
*/
|
||||
export const ResumeIframeCSR = dynamic(() => Promise.resolve(ResumeIframe), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
// PDFViewer is only used for debugging. Its size is quite large, so we make it dynamic import
|
||||
const DynamicPDFViewer = dynamic(
|
||||
() => import("@react-pdf/renderer").then((module) => module.PDFViewer),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
import { View } from "@react-pdf/renderer";
|
||||
import {
|
||||
ResumePDFSection,
|
||||
ResumePDFBulletList,
|
||||
} from "components/Resume/ResumePDF/common";
|
||||
import { styles } from "components/Resume/ResumePDF/styles";
|
||||
import type { ResumeCustom } from "lib/redux/types";
|
||||
|
||||
export const ResumePDFCustom = ({
|
||||
heading,
|
||||
custom,
|
||||
themeColor,
|
||||
showBulletPoints,
|
||||
}: {
|
||||
heading: string;
|
||||
custom: ResumeCustom;
|
||||
themeColor: string;
|
||||
showBulletPoints: boolean;
|
||||
}) => {
|
||||
const { descriptions } = custom;
|
||||
|
||||
return (
|
||||
<ResumePDFSection themeColor={themeColor} heading={heading}>
|
||||
<View style={{ ...styles.flexCol }}>
|
||||
<ResumePDFBulletList
|
||||
items={descriptions}
|
||||
showBulletPoints={showBulletPoints}
|
||||
/>
|
||||
</View>
|
||||
</ResumePDFSection>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { View } from "@react-pdf/renderer";
|
||||
import {
|
||||
ResumePDFBulletList,
|
||||
ResumePDFSection,
|
||||
ResumePDFText,
|
||||
} from "components/Resume/ResumePDF/common";
|
||||
import { styles, spacing } from "components/Resume/ResumePDF/styles";
|
||||
import type { ResumeEducation } from "lib/redux/types";
|
||||
|
||||
export const ResumePDFEducation = ({
|
||||
heading,
|
||||
educations,
|
||||
themeColor,
|
||||
showBulletPoints,
|
||||
}: {
|
||||
heading: string;
|
||||
educations: ResumeEducation[];
|
||||
themeColor: string;
|
||||
showBulletPoints: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<ResumePDFSection themeColor={themeColor} heading={heading}>
|
||||
{educations.map(
|
||||
({ school, degree, date, gpa, descriptions = [] }, idx) => {
|
||||
// Hide school name if it is the same as the previous school
|
||||
const hideSchoolName =
|
||||
idx > 0 && school === educations[idx - 1].school;
|
||||
const showDescriptions = descriptions.join() !== "";
|
||||
|
||||
return (
|
||||
<View key={idx}>
|
||||
{!hideSchoolName && (
|
||||
<ResumePDFText bold={true}>{school}</ResumePDFText>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
...styles.flexRowBetween,
|
||||
marginTop: hideSchoolName
|
||||
? "-" + spacing["1"]
|
||||
: spacing["1.5"],
|
||||
}}
|
||||
>
|
||||
<ResumePDFText>{`${
|
||||
gpa
|
||||
? `${degree} - ${Number(gpa) ? gpa + " GPA" : gpa}`
|
||||
: degree
|
||||
}`}</ResumePDFText>
|
||||
<ResumePDFText>{date}</ResumePDFText>
|
||||
</View>
|
||||
{showDescriptions && (
|
||||
<View style={{ ...styles.flexCol, marginTop: spacing["1.5"] }}>
|
||||
<ResumePDFBulletList
|
||||
items={descriptions}
|
||||
showBulletPoints={showBulletPoints}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</ResumePDFSection>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { View } from "@react-pdf/renderer";
|
||||
import {
|
||||
ResumePDFIcon,
|
||||
type IconType,
|
||||
} from "components/Resume/ResumePDF/common/ResumePDFIcon";
|
||||
import { styles, spacing } from "components/Resume/ResumePDF/styles";
|
||||
import {
|
||||
ResumePDFLink,
|
||||
ResumePDFSection,
|
||||
ResumePDFText,
|
||||
} from "components/Resume/ResumePDF/common";
|
||||
import type { ResumeProfile } from "lib/redux/types";
|
||||
|
||||
export const ResumePDFProfile = ({
|
||||
profile,
|
||||
themeColor,
|
||||
isPDF,
|
||||
}: {
|
||||
profile: ResumeProfile;
|
||||
themeColor: string;
|
||||
isPDF: boolean;
|
||||
}) => {
|
||||
const { name, email, phone, url, summary, location } = profile;
|
||||
const iconProps = { email, phone, location, url };
|
||||
|
||||
return (
|
||||
<ResumePDFSection style={{ marginTop: spacing["4"] }}>
|
||||
<ResumePDFText
|
||||
bold={true}
|
||||
themeColor={themeColor}
|
||||
style={{ fontSize: "20pt" }}
|
||||
>
|
||||
{name}
|
||||
</ResumePDFText>
|
||||
{summary && <ResumePDFText>{summary}</ResumePDFText>}
|
||||
<View
|
||||
style={{
|
||||
...styles.flexRowBetween,
|
||||
flexWrap: "wrap",
|
||||
marginTop: spacing["0.5"],
|
||||
}}
|
||||
>
|
||||
{Object.entries(iconProps).map(([key, value]) => {
|
||||
if (!value) return null;
|
||||
|
||||
let iconType = key as IconType;
|
||||
if (key === "url") {
|
||||
if (value.includes("github")) {
|
||||
iconType = "url_github";
|
||||
} else if (value.includes("linkedin")) {
|
||||
iconType = "url_linkedin";
|
||||
}
|
||||
}
|
||||
|
||||
const shouldUseLinkWrapper = ["email", "url", "phone"].includes(key);
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
if (!shouldUseLinkWrapper) return <>{children}</>;
|
||||
|
||||
let src = "";
|
||||
switch (key) {
|
||||
case "email": {
|
||||
src = `mailto:${value}`;
|
||||
break;
|
||||
}
|
||||
case "phone": {
|
||||
src = `tel:${value.replace(/[^\d+]/g, "")}`; // Keep only + and digits
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
src = value.startsWith("http") ? value : `https://${value}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ResumePDFLink src={src} isPDF={isPDF}>
|
||||
{children}
|
||||
</ResumePDFLink>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
key={key}
|
||||
style={{
|
||||
...styles.flexRow,
|
||||
alignItems: "center",
|
||||
gap: spacing["1"],
|
||||
}}
|
||||
>
|
||||
<ResumePDFIcon type={iconType} isPDF={isPDF} />
|
||||
<Wrapper>
|
||||
<ResumePDFText>{value}</ResumePDFText>
|
||||
</Wrapper>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</ResumePDFSection>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { View } from "@react-pdf/renderer";
|
||||
import {
|
||||
ResumePDFSection,
|
||||
ResumePDFBulletList,
|
||||
ResumePDFText,
|
||||
} from "components/Resume/ResumePDF/common";
|
||||
import { styles, spacing } from "components/Resume/ResumePDF/styles";
|
||||
import type { ResumeProject } from "lib/redux/types";
|
||||
|
||||
export const ResumePDFProject = ({
|
||||
heading,
|
||||
projects,
|
||||
themeColor,
|
||||
}: {
|
||||
heading: string;
|
||||
projects: ResumeProject[];
|
||||
themeColor: string;
|
||||
}) => {
|
||||
return (
|
||||
<ResumePDFSection themeColor={themeColor} heading={heading}>
|
||||
{projects.map(({ project, date, descriptions }, idx) => (
|
||||
<View key={idx}>
|
||||
<View
|
||||
style={{
|
||||
...styles.flexRowBetween,
|
||||
marginTop: spacing["0.5"],
|
||||
}}
|
||||
>
|
||||
<ResumePDFText bold={true}>{project}</ResumePDFText>
|
||||
<ResumePDFText>{date}</ResumePDFText>
|
||||
</View>
|
||||
<View style={{ ...styles.flexCol, marginTop: spacing["0.5"] }}>
|
||||
<ResumePDFBulletList items={descriptions} />
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ResumePDFSection>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { View } from "@react-pdf/renderer";
|
||||
import {
|
||||
ResumePDFSection,
|
||||
ResumePDFBulletList,
|
||||
ResumeFeaturedSkill,
|
||||
} from "components/Resume/ResumePDF/common";
|
||||
import { styles, spacing } from "components/Resume/ResumePDF/styles";
|
||||
import type { ResumeSkills } from "lib/redux/types";
|
||||
|
||||
export const ResumePDFSkills = ({
|
||||
heading,
|
||||
skills,
|
||||
themeColor,
|
||||
showBulletPoints,
|
||||
}: {
|
||||
heading: string;
|
||||
skills: ResumeSkills;
|
||||
themeColor: string;
|
||||
showBulletPoints: boolean;
|
||||
}) => {
|
||||
const { descriptions, featuredSkills } = skills;
|
||||
const featuredSkillsWithText = featuredSkills.filter((item) => item.skill);
|
||||
const featuredSkillsPair = [
|
||||
[featuredSkillsWithText[0], featuredSkillsWithText[3]],
|
||||
[featuredSkillsWithText[1], featuredSkillsWithText[4]],
|
||||
[featuredSkillsWithText[2], featuredSkillsWithText[5]],
|
||||
];
|
||||
|
||||
return (
|
||||
<ResumePDFSection themeColor={themeColor} heading={heading}>
|
||||
{featuredSkillsWithText.length > 0 && (
|
||||
<View style={{ ...styles.flexRowBetween, marginTop: spacing["0.5"] }}>
|
||||
{featuredSkillsPair.map((pair, idx) => (
|
||||
<View
|
||||
key={idx}
|
||||
style={{
|
||||
...styles.flexCol,
|
||||
}}
|
||||
>
|
||||
{pair.map((featuredSkill, idx) => {
|
||||
if (!featuredSkill) return null;
|
||||
return (
|
||||
<ResumeFeaturedSkill
|
||||
key={idx}
|
||||
skill={featuredSkill.skill}
|
||||
rating={featuredSkill.rating}
|
||||
themeColor={themeColor}
|
||||
style={{
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
<View style={{ ...styles.flexCol }}>
|
||||
<ResumePDFBulletList
|
||||
items={descriptions}
|
||||
showBulletPoints={showBulletPoints}
|
||||
/>
|
||||
</View>
|
||||
</ResumePDFSection>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { View } from "@react-pdf/renderer";
|
||||
import {
|
||||
ResumePDFSection,
|
||||
ResumePDFBulletList,
|
||||
ResumePDFText,
|
||||
} from "components/Resume/ResumePDF/common";
|
||||
import { styles, spacing } from "components/Resume/ResumePDF/styles";
|
||||
import type { ResumeWorkExperience } from "lib/redux/types";
|
||||
|
||||
export const ResumePDFWorkExperience = ({
|
||||
heading,
|
||||
workExperiences,
|
||||
themeColor,
|
||||
}: {
|
||||
heading: string;
|
||||
workExperiences: ResumeWorkExperience[];
|
||||
themeColor: string;
|
||||
}) => {
|
||||
return (
|
||||
<ResumePDFSection themeColor={themeColor} heading={heading}>
|
||||
{workExperiences.map(({ company, jobTitle, date, descriptions }, idx) => {
|
||||
// Hide company name if it is the same as the previous company
|
||||
const hideCompanyName =
|
||||
idx > 0 && company === workExperiences[idx - 1].company;
|
||||
|
||||
return (
|
||||
<View key={idx} style={idx !== 0 ? { marginTop: spacing["2"] } : {}}>
|
||||
{!hideCompanyName && (
|
||||
<ResumePDFText bold={true}>{company}</ResumePDFText>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
...styles.flexRowBetween,
|
||||
marginTop: hideCompanyName
|
||||
? "-" + spacing["1"]
|
||||
: spacing["1.5"],
|
||||
}}
|
||||
>
|
||||
<ResumePDFText>{jobTitle}</ResumePDFText>
|
||||
<ResumePDFText>{date}</ResumePDFText>
|
||||
</View>
|
||||
<View style={{ ...styles.flexCol, marginTop: spacing["1.5"] }}>
|
||||
<ResumePDFBulletList items={descriptions} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</ResumePDFSection>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Svg, Path } from "@react-pdf/renderer";
|
||||
import { styles } from "components/Resume/ResumePDF/styles";
|
||||
|
||||
/**
|
||||
* Font Awesome Pro 6.4.0 by @fontawesome - https://fontawesome.com License
|
||||
* - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc.
|
||||
*/
|
||||
const EMAIL_PATH_D =
|
||||
"M64 112c-8.8 0-16 7.2-16 16v22.1L220.5 291.7c20.7 17 50.4 17 71.1 0L464 150.1V128c0-8.8-7.2-16-16-16H64zM48 212.2V384c0 8.8 7.2 16 16 16H448c8.8 0 16-7.2 16-16V212.2L322 328.8c-38.4 31.5-93.7 31.5-132 0L48 212.2zM0 128C0 92.7 28.7 64 64 64H448c35.3 0 64 28.7 64 64V384c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V128z";
|
||||
const PHONE_PATH_D =
|
||||
"M164.9 24.6c-7.7-18.6-28-28.5-47.4-23.2l-88 24C12.1 30.2 0 46 0 64C0 311.4 200.6 512 448 512c18 0 33.8-12.1 38.6-29.5l24-88c5.3-19.4-4.6-39.7-23.2-47.4l-96-40c-16.3-6.8-35.2-2.1-46.3 11.6L304.7 368C234.3 334.7 177.3 277.7 144 207.3L193.3 167c13.7-11.2 18.4-30 11.6-46.3l-40-96z";
|
||||
const LOCATION_PATH_D =
|
||||
"M215.7 499.2C267 435 384 279.4 384 192C384 86 298 0 192 0S0 86 0 192c0 87.4 117 243 168.3 307.2c12.3 15.3 35.1 15.3 47.4 0zM192 128a64 64 0 1 1 0 128 64 64 0 1 1 0-128z";
|
||||
const URL_PATH_D =
|
||||
"M256 64C256 46.33 270.3 32 288 32H415.1C415.1 32 415.1 32 415.1 32C420.3 32 424.5 32.86 428.2 34.43C431.1 35.98 435.5 38.27 438.6 41.3C438.6 41.35 438.6 41.4 438.7 41.44C444.9 47.66 447.1 55.78 448 63.9C448 63.94 448 63.97 448 64V192C448 209.7 433.7 224 416 224C398.3 224 384 209.7 384 192V141.3L214.6 310.6C202.1 323.1 181.9 323.1 169.4 310.6C156.9 298.1 156.9 277.9 169.4 265.4L338.7 96H288C270.3 96 256 81.67 256 64V64zM0 128C0 92.65 28.65 64 64 64H160C177.7 64 192 78.33 192 96C192 113.7 177.7 128 160 128H64V416H352V320C352 302.3 366.3 288 384 288C401.7 288 416 302.3 416 320V416C416 451.3 387.3 480 352 480H64C28.65 480 0 451.3 0 416V128z";
|
||||
const GITHUB_PATH_D =
|
||||
"M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z";
|
||||
const LINKEDIN_PATH_D =
|
||||
"M416 32H31.9C14.3 32 0 46.5 0 64.3v383.4C0 465.5 14.3 480 31.9 480H416c17.6 0 32-14.5 32-32.3V64.3c0-17.8-14.4-32.3-32-32.3zM135.4 416H69V202.2h66.5V416zm-33.2-243c-21.3 0-38.5-17.3-38.5-38.5S80.9 96 102.2 96c21.2 0 38.5 17.3 38.5 38.5 0 21.3-17.2 38.5-38.5 38.5zm282.1 243h-66.4V312c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9V416h-66.4V202.2h63.7v29.2h.9c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9V416z";
|
||||
const TYPE_TO_PATH_D = {
|
||||
email: EMAIL_PATH_D,
|
||||
phone: PHONE_PATH_D,
|
||||
location: LOCATION_PATH_D,
|
||||
url: URL_PATH_D,
|
||||
url_github: GITHUB_PATH_D,
|
||||
url_linkedin: LINKEDIN_PATH_D,
|
||||
} as const;
|
||||
|
||||
export type IconType =
|
||||
| "email"
|
||||
| "phone"
|
||||
| "location"
|
||||
| "url"
|
||||
| "url_github"
|
||||
| "url_linkedin";
|
||||
|
||||
export const ResumePDFIcon = ({
|
||||
type,
|
||||
isPDF,
|
||||
}: {
|
||||
type: IconType;
|
||||
isPDF: boolean;
|
||||
}) => {
|
||||
const pathD = TYPE_TO_PATH_D[type];
|
||||
if (isPDF) {
|
||||
return <PDFIcon pathD={pathD} />;
|
||||
}
|
||||
return <SVGIcon pathD={pathD} />;
|
||||
};
|
||||
|
||||
const { width, height, fill } = styles.icon;
|
||||
|
||||
const PDFIcon = ({ pathD }: { pathD: string }) => (
|
||||
<Svg viewBox="0 0 512 512" style={{ width, height }}>
|
||||
<Path d={pathD} fill={fill} />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
const SVGIcon = ({ pathD }: { pathD: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
style={{ width, height, fill }}
|
||||
>
|
||||
<path d={pathD} />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Suppress ResumePDF development errors.
|
||||
* See ResumePDF doc string for context.
|
||||
*/
|
||||
if (typeof window !== "undefined" && window.location.hostname === "localhost") {
|
||||
const consoleError = console.error;
|
||||
const SUPPRESSED_WARNINGS = ["DOCUMENT", "PAGE", "TEXT", "VIEW"];
|
||||
console.error = function filterWarnings(msg, ...args) {
|
||||
if (!SUPPRESSED_WARNINGS.some((entry) => args[0]?.includes(entry))) {
|
||||
consoleError(msg, ...args);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const SuppressResumePDFErrorMessage = () => {
|
||||
return <></>;
|
||||
};
|
||||
175
open-resume/src/app/components/Resume/ResumePDF/common/index.tsx
Normal file
175
open-resume/src/app/components/Resume/ResumePDF/common/index.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Text, View, Link } from "@react-pdf/renderer";
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import { styles, spacing } from "components/Resume/ResumePDF/styles";
|
||||
import { DEBUG_RESUME_PDF_FLAG } from "lib/constants";
|
||||
import { DEFAULT_FONT_COLOR } from "lib/redux/settingsSlice";
|
||||
|
||||
export const ResumePDFSection = ({
|
||||
themeColor,
|
||||
heading,
|
||||
style = {},
|
||||
children,
|
||||
}: {
|
||||
themeColor?: string;
|
||||
heading?: string;
|
||||
style?: Style;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<View
|
||||
style={{
|
||||
...styles.flexCol,
|
||||
gap: spacing["2"],
|
||||
marginTop: spacing["5"],
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{heading && (
|
||||
<View style={{ ...styles.flexRow, alignItems: "center" }}>
|
||||
{themeColor && (
|
||||
<View
|
||||
style={{
|
||||
height: "3.75pt",
|
||||
width: "30pt",
|
||||
backgroundColor: themeColor,
|
||||
marginRight: spacing["3.5"],
|
||||
}}
|
||||
debug={DEBUG_RESUME_PDF_FLAG}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
letterSpacing: "0.3pt", // tracking-wide -> 0.025em * 12 pt = 0.3pt
|
||||
}}
|
||||
debug={DEBUG_RESUME_PDF_FLAG}
|
||||
>
|
||||
{heading}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
|
||||
export const ResumePDFText = ({
|
||||
bold = false,
|
||||
themeColor,
|
||||
style = {},
|
||||
children,
|
||||
}: {
|
||||
bold?: boolean;
|
||||
themeColor?: string;
|
||||
style?: Style;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<Text
|
||||
style={{
|
||||
color: themeColor || DEFAULT_FONT_COLOR,
|
||||
fontWeight: bold ? "bold" : "normal",
|
||||
...style,
|
||||
}}
|
||||
debug={DEBUG_RESUME_PDF_FLAG}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResumePDFBulletList = ({
|
||||
items,
|
||||
showBulletPoints = true,
|
||||
}: {
|
||||
items: string[];
|
||||
showBulletPoints?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{items.map((item, idx) => (
|
||||
<View style={{ ...styles.flexRow }} key={idx}>
|
||||
{showBulletPoints && (
|
||||
<ResumePDFText
|
||||
style={{
|
||||
paddingLeft: spacing["2"],
|
||||
paddingRight: spacing["2"],
|
||||
lineHeight: "1.3",
|
||||
}}
|
||||
bold={true}
|
||||
>
|
||||
{"•"}
|
||||
</ResumePDFText>
|
||||
)}
|
||||
{/* A breaking change was introduced causing text layout to be wider than node's width
|
||||
https://github.com/diegomura/react-pdf/issues/2182. flexGrow & flexBasis fixes it */}
|
||||
<ResumePDFText
|
||||
style={{ lineHeight: "1.3", flexGrow: 1, flexBasis: 0 }}
|
||||
>
|
||||
{item}
|
||||
</ResumePDFText>
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResumePDFLink = ({
|
||||
src,
|
||||
isPDF,
|
||||
children,
|
||||
}: {
|
||||
src: string;
|
||||
isPDF: boolean;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
if (isPDF) {
|
||||
return (
|
||||
<Link src={src} style={{ textDecoration: "none" }}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={src}
|
||||
style={{ textDecoration: "none" }}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResumeFeaturedSkill = ({
|
||||
skill,
|
||||
rating,
|
||||
themeColor,
|
||||
style = {},
|
||||
}: {
|
||||
skill: string;
|
||||
rating: number;
|
||||
themeColor: string;
|
||||
style?: Style;
|
||||
}) => {
|
||||
const numCircles = 5;
|
||||
|
||||
return (
|
||||
<View style={{ ...styles.flexRow, alignItems: "center", ...style }}>
|
||||
<ResumePDFText style={{ marginRight: spacing[0.5] }}>
|
||||
{skill}
|
||||
</ResumePDFText>
|
||||
{[...Array(numCircles)].map((_, idx) => (
|
||||
<View
|
||||
key={idx}
|
||||
style={{
|
||||
height: "9pt",
|
||||
width: "9pt",
|
||||
marginLeft: "2.25pt",
|
||||
backgroundColor: rating >= idx ? themeColor : "#d9d9d9",
|
||||
borderRadius: "100%",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
137
open-resume/src/app/components/Resume/ResumePDF/index.tsx
Normal file
137
open-resume/src/app/components/Resume/ResumePDF/index.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Page, View, Document } from "@react-pdf/renderer";
|
||||
import { styles, spacing } from "components/Resume/ResumePDF/styles";
|
||||
import { ResumePDFProfile } from "components/Resume/ResumePDF/ResumePDFProfile";
|
||||
import { ResumePDFWorkExperience } from "components/Resume/ResumePDF/ResumePDFWorkExperience";
|
||||
import { ResumePDFEducation } from "components/Resume/ResumePDF/ResumePDFEducation";
|
||||
import { ResumePDFProject } from "components/Resume/ResumePDF/ResumePDFProject";
|
||||
import { ResumePDFSkills } from "components/Resume/ResumePDF/ResumePDFSkills";
|
||||
import { ResumePDFCustom } from "components/Resume/ResumePDF/ResumePDFCustom";
|
||||
import { DEFAULT_FONT_COLOR } from "lib/redux/settingsSlice";
|
||||
import type { Settings, ShowForm } from "lib/redux/settingsSlice";
|
||||
import type { Resume } from "lib/redux/types";
|
||||
import { SuppressResumePDFErrorMessage } from "components/Resume/ResumePDF/common/SuppressResumePDFErrorMessage";
|
||||
|
||||
/**
|
||||
* Note: ResumePDF is supposed to be rendered inside PDFViewer. However,
|
||||
* PDFViewer is rendered too slow and has noticeable delay as you enter
|
||||
* the resume form, so we render it without PDFViewer to make it render
|
||||
* instantly. There are 2 drawbacks with this approach:
|
||||
* 1. Not everything works out of box if not rendered inside PDFViewer,
|
||||
* e.g. svg doesn't work, so it takes in a isPDF flag that maps react
|
||||
* pdf element to the correct dom element.
|
||||
* 2. It throws a lot of errors in console log, e.g. "<VIEW /> is using incorrect
|
||||
* casing. Use PascalCase for React components, or lowercase for HTML elements."
|
||||
* in development, causing a lot of noises. We can possibly workaround this by
|
||||
* mapping every react pdf element to a dom element, but for now, we simply
|
||||
* suppress these messages in <SuppressResumePDFErrorMessage />.
|
||||
* https://github.com/diegomura/react-pdf/issues/239#issuecomment-487255027
|
||||
*/
|
||||
export const ResumePDF = ({
|
||||
resume,
|
||||
settings,
|
||||
isPDF = false,
|
||||
}: {
|
||||
resume: Resume;
|
||||
settings: Settings;
|
||||
isPDF?: boolean;
|
||||
}) => {
|
||||
const { profile, workExperiences, educations, projects, skills, custom } =
|
||||
resume;
|
||||
const { name } = profile;
|
||||
const {
|
||||
fontFamily,
|
||||
fontSize,
|
||||
documentSize,
|
||||
formToHeading,
|
||||
formToShow,
|
||||
formsOrder,
|
||||
showBulletPoints,
|
||||
} = settings;
|
||||
const themeColor = settings.themeColor || DEFAULT_FONT_COLOR;
|
||||
|
||||
const showFormsOrder = formsOrder.filter((form) => formToShow[form]);
|
||||
|
||||
const formTypeToComponent: { [type in ShowForm]: () => JSX.Element } = {
|
||||
workExperiences: () => (
|
||||
<ResumePDFWorkExperience
|
||||
heading={formToHeading["workExperiences"]}
|
||||
workExperiences={workExperiences}
|
||||
themeColor={themeColor}
|
||||
/>
|
||||
),
|
||||
educations: () => (
|
||||
<ResumePDFEducation
|
||||
heading={formToHeading["educations"]}
|
||||
educations={educations}
|
||||
themeColor={themeColor}
|
||||
showBulletPoints={showBulletPoints["educations"]}
|
||||
/>
|
||||
),
|
||||
projects: () => (
|
||||
<ResumePDFProject
|
||||
heading={formToHeading["projects"]}
|
||||
projects={projects}
|
||||
themeColor={themeColor}
|
||||
/>
|
||||
),
|
||||
skills: () => (
|
||||
<ResumePDFSkills
|
||||
heading={formToHeading["skills"]}
|
||||
skills={skills}
|
||||
themeColor={themeColor}
|
||||
showBulletPoints={showBulletPoints["skills"]}
|
||||
/>
|
||||
),
|
||||
custom: () => (
|
||||
<ResumePDFCustom
|
||||
heading={formToHeading["custom"]}
|
||||
custom={custom}
|
||||
themeColor={themeColor}
|
||||
showBulletPoints={showBulletPoints["custom"]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Document title={`${name} Resume`} author={name} producer={"OpenResume"}>
|
||||
<Page
|
||||
size={documentSize === "A4" ? "A4" : "LETTER"}
|
||||
style={{
|
||||
...styles.flexCol,
|
||||
color: DEFAULT_FONT_COLOR,
|
||||
fontFamily,
|
||||
fontSize: fontSize + "pt",
|
||||
}}
|
||||
>
|
||||
{Boolean(settings.themeColor) && (
|
||||
<View
|
||||
style={{
|
||||
width: spacing["full"],
|
||||
height: spacing[3.5],
|
||||
backgroundColor: themeColor,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
...styles.flexCol,
|
||||
padding: `${spacing[0]} ${spacing[20]}`,
|
||||
}}
|
||||
>
|
||||
<ResumePDFProfile
|
||||
profile={profile}
|
||||
themeColor={themeColor}
|
||||
isPDF={isPDF}
|
||||
/>
|
||||
{showFormsOrder.map((form) => {
|
||||
const Component = formTypeToComponent[form];
|
||||
return <Component key={form} />;
|
||||
})}
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
<SuppressResumePDFErrorMessage />
|
||||
</>
|
||||
);
|
||||
};
|
||||
62
open-resume/src/app/components/Resume/ResumePDF/styles.ts
Normal file
62
open-resume/src/app/components/Resume/ResumePDF/styles.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { StyleSheet } from "@react-pdf/renderer";
|
||||
|
||||
// Tailwindcss Spacing Design System: https://tailwindcss.com/docs/theme#spacing
|
||||
// It is converted from rem to pt (1rem = 12pt) since https://react-pdf.org/styling only accepts pt unit
|
||||
export const spacing = {
|
||||
0: "0",
|
||||
0.5: "1.5pt",
|
||||
1: "3pt",
|
||||
1.5: "4.5pt",
|
||||
2: "6pt",
|
||||
2.5: "7.5pt",
|
||||
3: "9pt",
|
||||
3.5: "10.5pt",
|
||||
4: "12pt",
|
||||
5: "15pt",
|
||||
6: "18pt",
|
||||
7: "21pt",
|
||||
8: "24pt",
|
||||
9: "27pt",
|
||||
10: "30pt",
|
||||
11: "33pt",
|
||||
12: "36pt",
|
||||
14: "42pt",
|
||||
16: "48pt",
|
||||
20: "60pt",
|
||||
24: "72pt",
|
||||
28: "84pt",
|
||||
32: "96pt",
|
||||
36: "108pt",
|
||||
40: "120pt",
|
||||
44: "132pt",
|
||||
48: "144pt",
|
||||
52: "156pt",
|
||||
56: "168pt",
|
||||
60: "180pt",
|
||||
64: "192pt",
|
||||
72: "216pt",
|
||||
80: "240pt",
|
||||
96: "288pt",
|
||||
full: "100%",
|
||||
} as const;
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
flexRow: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
},
|
||||
flexRowBetween: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
flexCol: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
icon: {
|
||||
width: "13pt",
|
||||
height: "13pt",
|
||||
fill: "#525252", // text-neutral-600
|
||||
},
|
||||
});
|
||||
62
open-resume/src/app/components/Resume/hooks.tsx
Normal file
62
open-resume/src/app/components/Resume/hooks.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { A4_HEIGHT_PX, LETTER_HEIGHT_PX } from "lib/constants";
|
||||
import { getPxPerRem } from "lib/get-px-per-rem";
|
||||
import { CSS_VARIABLES } from "globals-css";
|
||||
|
||||
/**
|
||||
* useSetDefaultScale sets the default scale of the resume on load.
|
||||
*
|
||||
* It computes the scale based on current screen height and derives the default
|
||||
* resume height by subtracting the screen height from the total heights of top
|
||||
* nav bar, resume control bar, and resume top & bottom padding.
|
||||
*/
|
||||
export const useSetDefaultScale = ({
|
||||
setScale,
|
||||
documentSize,
|
||||
}: {
|
||||
setScale: (scale: number) => void;
|
||||
documentSize: string;
|
||||
}) => {
|
||||
const [scaleOnResize, setScaleOnResize] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const getDefaultScale = () => {
|
||||
const screenHeightPx = window.innerHeight;
|
||||
const PX_PER_REM = getPxPerRem();
|
||||
const screenHeightRem = screenHeightPx / PX_PER_REM;
|
||||
const topNavBarHeightRem = parseFloat(
|
||||
CSS_VARIABLES["--top-nav-bar-height"]
|
||||
);
|
||||
const resumeControlBarHeight = parseFloat(
|
||||
CSS_VARIABLES["--resume-control-bar-height"]
|
||||
);
|
||||
const resumePadding = parseFloat(CSS_VARIABLES["--resume-padding"]);
|
||||
const topAndBottomResumePadding = resumePadding * 2;
|
||||
const defaultResumeHeightRem =
|
||||
screenHeightRem -
|
||||
topNavBarHeightRem -
|
||||
resumeControlBarHeight -
|
||||
topAndBottomResumePadding;
|
||||
const resumeHeightPx = defaultResumeHeightRem * PX_PER_REM;
|
||||
const height = documentSize === "A4" ? A4_HEIGHT_PX : LETTER_HEIGHT_PX;
|
||||
const defaultScale = Math.round((resumeHeightPx / height) * 100) / 100;
|
||||
return defaultScale;
|
||||
};
|
||||
|
||||
const setDefaultScale = () => {
|
||||
const defaultScale = getDefaultScale();
|
||||
setScale(defaultScale);
|
||||
};
|
||||
|
||||
if (scaleOnResize) {
|
||||
setDefaultScale();
|
||||
window.addEventListener("resize", setDefaultScale);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", setDefaultScale);
|
||||
};
|
||||
}, [setScale, scaleOnResize, documentSize]);
|
||||
|
||||
return { scaleOnResize, setScaleOnResize };
|
||||
};
|
||||
63
open-resume/src/app/components/Resume/index.tsx
Normal file
63
open-resume/src/app/components/Resume/index.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
import { useState, useMemo } from "react";
|
||||
import { ResumeIframeCSR } from "components/Resume/ResumeIFrame";
|
||||
import { ResumePDF } from "components/Resume/ResumePDF";
|
||||
import {
|
||||
ResumeControlBarCSR,
|
||||
ResumeControlBarBorder,
|
||||
} from "components/Resume/ResumeControlBar";
|
||||
import { FlexboxSpacer } from "components/FlexboxSpacer";
|
||||
import { useAppSelector } from "lib/redux/hooks";
|
||||
import { selectResume } from "lib/redux/resumeSlice";
|
||||
import { selectSettings } from "lib/redux/settingsSlice";
|
||||
import { DEBUG_RESUME_PDF_FLAG } from "lib/constants";
|
||||
import {
|
||||
useRegisterReactPDFFont,
|
||||
useRegisterReactPDFHyphenationCallback,
|
||||
} from "components/fonts/hooks";
|
||||
import { NonEnglishFontsCSSLazyLoader } from "components/fonts/NonEnglishFontsCSSLoader";
|
||||
|
||||
export const Resume = () => {
|
||||
const [scale, setScale] = useState(0.8);
|
||||
const resume = useAppSelector(selectResume);
|
||||
const settings = useAppSelector(selectSettings);
|
||||
const document = useMemo(
|
||||
() => <ResumePDF resume={resume} settings={settings} isPDF={true} />,
|
||||
[resume, settings]
|
||||
);
|
||||
|
||||
useRegisterReactPDFFont();
|
||||
useRegisterReactPDFHyphenationCallback(settings.fontFamily);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NonEnglishFontsCSSLazyLoader />
|
||||
<div className="relative flex justify-center md:justify-start">
|
||||
<FlexboxSpacer maxWidth={50} className="hidden md:block" />
|
||||
<div className="relative">
|
||||
<section className="h-[calc(100vh-var(--top-nav-bar-height)-var(--resume-control-bar-height))] overflow-hidden md:p-[var(--resume-padding)]">
|
||||
<ResumeIframeCSR
|
||||
documentSize={settings.documentSize}
|
||||
scale={scale}
|
||||
enablePDFViewer={DEBUG_RESUME_PDF_FLAG}
|
||||
>
|
||||
<ResumePDF
|
||||
resume={resume}
|
||||
settings={settings}
|
||||
isPDF={DEBUG_RESUME_PDF_FLAG}
|
||||
/>
|
||||
</ResumeIframeCSR>
|
||||
</section>
|
||||
<ResumeControlBarCSR
|
||||
scale={scale}
|
||||
setScale={setScale}
|
||||
documentSize={settings.documentSize}
|
||||
document={document}
|
||||
fileName={resume.profile.name + " - Resume"}
|
||||
/>
|
||||
</div>
|
||||
<ResumeControlBarBorder />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
209
open-resume/src/app/components/ResumeDropzone.tsx
Normal file
209
open-resume/src/app/components/ResumeDropzone.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useState } from "react";
|
||||
import { LockClosedIcon } from "@heroicons/react/24/solid";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { parseResumeFromPdf } from "lib/parse-resume-from-pdf";
|
||||
import {
|
||||
getHasUsedAppBefore,
|
||||
saveStateToLocalStorage,
|
||||
} from "lib/redux/local-storage";
|
||||
import { type ShowForm, initialSettings } from "lib/redux/settingsSlice";
|
||||
import { useRouter } from "next/navigation";
|
||||
import addPdfSrc from "public/assets/add-pdf.svg";
|
||||
import Image from "next/image";
|
||||
import { cx } from "lib/cx";
|
||||
import { deepClone } from "lib/deep-clone";
|
||||
|
||||
const defaultFileState = {
|
||||
name: "",
|
||||
size: 0,
|
||||
fileUrl: "",
|
||||
};
|
||||
|
||||
export const ResumeDropzone = ({
|
||||
onFileUrlChange,
|
||||
className,
|
||||
playgroundView = false,
|
||||
}: {
|
||||
onFileUrlChange: (fileUrl: string) => void;
|
||||
className?: string;
|
||||
playgroundView?: boolean;
|
||||
}) => {
|
||||
const [file, setFile] = useState(defaultFileState);
|
||||
const [isHoveredOnDropzone, setIsHoveredOnDropzone] = useState(false);
|
||||
const [hasNonPdfFile, setHasNonPdfFile] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const hasFile = Boolean(file.name);
|
||||
|
||||
const setNewFile = (newFile: File) => {
|
||||
if (file.fileUrl) {
|
||||
URL.revokeObjectURL(file.fileUrl);
|
||||
}
|
||||
|
||||
const { name, size } = newFile;
|
||||
const fileUrl = URL.createObjectURL(newFile);
|
||||
setFile({ name, size, fileUrl });
|
||||
onFileUrlChange(fileUrl);
|
||||
};
|
||||
|
||||
const onDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const newFile = event.dataTransfer.files[0];
|
||||
if (newFile.name.endsWith(".pdf")) {
|
||||
setHasNonPdfFile(false);
|
||||
setNewFile(newFile);
|
||||
} else {
|
||||
setHasNonPdfFile(true);
|
||||
}
|
||||
setIsHoveredOnDropzone(false);
|
||||
};
|
||||
|
||||
const onInputChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (!files) return;
|
||||
|
||||
const newFile = files[0];
|
||||
setNewFile(newFile);
|
||||
};
|
||||
|
||||
const onRemove = () => {
|
||||
setFile(defaultFileState);
|
||||
onFileUrlChange("");
|
||||
};
|
||||
|
||||
const onImportClick = async () => {
|
||||
const resume = await parseResumeFromPdf(file.fileUrl);
|
||||
const settings = deepClone(initialSettings);
|
||||
|
||||
// Set formToShow settings based on uploaded resume if users have used the app before
|
||||
if (getHasUsedAppBefore()) {
|
||||
const sections = Object.keys(settings.formToShow) as ShowForm[];
|
||||
const sectionToFormToShow: Record<ShowForm, boolean> = {
|
||||
workExperiences: resume.workExperiences.length > 0,
|
||||
educations: resume.educations.length > 0,
|
||||
projects: resume.projects.length > 0,
|
||||
skills: resume.skills.descriptions.length > 0,
|
||||
custom: resume.custom.descriptions.length > 0,
|
||||
};
|
||||
for (const section of sections) {
|
||||
settings.formToShow[section] = sectionToFormToShow[section];
|
||||
}
|
||||
}
|
||||
|
||||
saveStateToLocalStorage({ resume, settings });
|
||||
router.push("/resume-builder");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"flex justify-center rounded-md border-2 border-dashed border-gray-300 px-6 ",
|
||||
isHoveredOnDropzone && "border-sky-400",
|
||||
playgroundView ? "pb-6 pt-4" : "py-12",
|
||||
className
|
||||
)}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setIsHoveredOnDropzone(true);
|
||||
}}
|
||||
onDragLeave={() => setIsHoveredOnDropzone(false)}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
"text-center",
|
||||
playgroundView ? "space-y-2" : "space-y-3"
|
||||
)}
|
||||
>
|
||||
{!playgroundView && (
|
||||
<Image
|
||||
src={addPdfSrc}
|
||||
className="mx-auto h-14 w-14"
|
||||
alt="Add pdf"
|
||||
aria-hidden="true"
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
{!hasFile ? (
|
||||
<>
|
||||
<p
|
||||
className={cx(
|
||||
"pt-3 text-gray-700",
|
||||
!playgroundView && "text-lg font-semibold"
|
||||
)}
|
||||
>
|
||||
Browse a pdf file or drop it here
|
||||
</p>
|
||||
<p className="flex text-sm text-gray-500">
|
||||
<LockClosedIcon className="mr-1 mt-1 h-3 w-3 text-gray-400" />
|
||||
File data is used locally and never leaves your browser
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-3 pt-3">
|
||||
<div className="pl-7 font-semibold text-gray-900">
|
||||
{file.name} - {getFileSizeString(file.size)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="outline-theme-blue rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500"
|
||||
title="Remove file"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-4">
|
||||
{!hasFile ? (
|
||||
<>
|
||||
<label
|
||||
className={cx(
|
||||
"within-outline-theme-purple cursor-pointer rounded-full px-6 pb-2.5 pt-2 font-semibold shadow-sm",
|
||||
playgroundView ? "border" : "bg-primary"
|
||||
)}
|
||||
>
|
||||
Browse file
|
||||
<input
|
||||
type="file"
|
||||
className="sr-only"
|
||||
accept=".pdf"
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</label>
|
||||
{hasNonPdfFile && (
|
||||
<p className="mt-6 text-red-400">Only pdf file is supported</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!playgroundView && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={onImportClick}
|
||||
>
|
||||
Import and Continue <span aria-hidden="true">→</span>
|
||||
</button>
|
||||
)}
|
||||
<p className={cx(" text-gray-500", !playgroundView && "mt-6")}>
|
||||
Note: {!playgroundView ? "Import" : "Parser"} works best on
|
||||
single column resume
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getFileSizeString = (fileSizeB: number) => {
|
||||
const fileSizeKB = fileSizeB / 1024;
|
||||
const fileSizeMB = fileSizeKB / 1024;
|
||||
if (fileSizeKB < 1000) {
|
||||
return fileSizeKB.toPrecision(3) + " KB";
|
||||
} else {
|
||||
return fileSizeMB.toPrecision(3) + " MB";
|
||||
}
|
||||
};
|
||||
49
open-resume/src/app/components/ResumeForm/CustomForm.tsx
Normal file
49
open-resume/src/app/components/ResumeForm/CustomForm.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Form } from "components/ResumeForm/Form";
|
||||
import { BulletListIconButton } from "components/ResumeForm/Form/IconButton";
|
||||
import { BulletListTextarea } from "components/ResumeForm/Form/InputGroup";
|
||||
import { useAppDispatch, useAppSelector } from "lib/redux/hooks";
|
||||
import { changeCustom, selectCustom } from "lib/redux/resumeSlice";
|
||||
import {
|
||||
selectShowBulletPoints,
|
||||
changeShowBulletPoints,
|
||||
} from "lib/redux/settingsSlice";
|
||||
|
||||
export const CustomForm = () => {
|
||||
const custom = useAppSelector(selectCustom);
|
||||
const dispatch = useAppDispatch();
|
||||
const { descriptions } = custom;
|
||||
const form = "custom";
|
||||
const showBulletPoints = useAppSelector(selectShowBulletPoints(form));
|
||||
|
||||
const handleCustomChange = (field: "descriptions", value: string[]) => {
|
||||
dispatch(changeCustom({ field, value }));
|
||||
};
|
||||
|
||||
const handleShowBulletPoints = (value: boolean) => {
|
||||
dispatch(changeShowBulletPoints({ field: form, value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Form form={form}>
|
||||
<div className="col-span-full grid grid-cols-6 gap-3">
|
||||
<div className="relative col-span-full">
|
||||
<BulletListTextarea
|
||||
label="Custom Textbox"
|
||||
labelClassName="col-span-full"
|
||||
name="descriptions"
|
||||
placeholder="Bullet points"
|
||||
value={descriptions}
|
||||
onChange={handleCustomChange}
|
||||
showBulletPoints={showBulletPoints}
|
||||
/>
|
||||
<div className="absolute left-[7.7rem] top-[0.07rem]">
|
||||
<BulletListIconButton
|
||||
showBulletPoints={showBulletPoints}
|
||||
onClick={handleShowBulletPoints}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
106
open-resume/src/app/components/ResumeForm/EducationsForm.tsx
Normal file
106
open-resume/src/app/components/ResumeForm/EducationsForm.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Form, FormSection } from "components/ResumeForm/Form";
|
||||
import {
|
||||
BulletListTextarea,
|
||||
Input,
|
||||
} from "components/ResumeForm/Form/InputGroup";
|
||||
import { BulletListIconButton } from "components/ResumeForm/Form/IconButton";
|
||||
import type { CreateHandleChangeArgsWithDescriptions } from "components/ResumeForm/types";
|
||||
import { useAppDispatch, useAppSelector } from "lib/redux/hooks";
|
||||
import { changeEducations, selectEducations } from "lib/redux/resumeSlice";
|
||||
import type { ResumeEducation } from "lib/redux/types";
|
||||
import {
|
||||
changeShowBulletPoints,
|
||||
selectShowBulletPoints,
|
||||
} from "lib/redux/settingsSlice";
|
||||
|
||||
export const EducationsForm = () => {
|
||||
const educations = useAppSelector(selectEducations);
|
||||
const dispatch = useAppDispatch();
|
||||
const showDelete = educations.length > 1;
|
||||
const form = "educations";
|
||||
const showBulletPoints = useAppSelector(selectShowBulletPoints(form));
|
||||
|
||||
return (
|
||||
<Form form={form} addButtonText="Add School">
|
||||
{educations.map(({ school, degree, gpa, date, descriptions }, idx) => {
|
||||
const handleEducationChange = (
|
||||
...[
|
||||
field,
|
||||
value,
|
||||
]: CreateHandleChangeArgsWithDescriptions<ResumeEducation>
|
||||
) => {
|
||||
dispatch(changeEducations({ idx, field, value } as any));
|
||||
};
|
||||
|
||||
const handleShowBulletPoints = (value: boolean) => {
|
||||
dispatch(changeShowBulletPoints({ field: form, value }));
|
||||
};
|
||||
|
||||
const showMoveUp = idx !== 0;
|
||||
const showMoveDown = idx !== educations.length - 1;
|
||||
|
||||
return (
|
||||
<FormSection
|
||||
key={idx}
|
||||
form="educations"
|
||||
idx={idx}
|
||||
showMoveUp={showMoveUp}
|
||||
showMoveDown={showMoveDown}
|
||||
showDelete={showDelete}
|
||||
deleteButtonTooltipText="Delete school"
|
||||
>
|
||||
<Input
|
||||
label="School"
|
||||
labelClassName="col-span-4"
|
||||
name="school"
|
||||
placeholder="Cornell University"
|
||||
value={school}
|
||||
onChange={handleEducationChange}
|
||||
/>
|
||||
<Input
|
||||
label="Date"
|
||||
labelClassName="col-span-2"
|
||||
name="date"
|
||||
placeholder="May 2018"
|
||||
value={date}
|
||||
onChange={handleEducationChange}
|
||||
/>
|
||||
<Input
|
||||
label="Degree & Major"
|
||||
labelClassName="col-span-4"
|
||||
name="degree"
|
||||
placeholder="Bachelor of Science in Computer Engineering"
|
||||
value={degree}
|
||||
onChange={handleEducationChange}
|
||||
/>
|
||||
<Input
|
||||
label="GPA"
|
||||
labelClassName="col-span-2"
|
||||
name="gpa"
|
||||
placeholder="3.81"
|
||||
value={gpa}
|
||||
onChange={handleEducationChange}
|
||||
/>
|
||||
<div className="relative col-span-full">
|
||||
<BulletListTextarea
|
||||
label="Additional Information (Optional)"
|
||||
labelClassName="col-span-full"
|
||||
name="descriptions"
|
||||
placeholder="Free paragraph space to list out additional activities, courses, awards etc"
|
||||
value={descriptions}
|
||||
onChange={handleEducationChange}
|
||||
showBulletPoints={showBulletPoints}
|
||||
/>
|
||||
<div className="absolute left-[15.6rem] top-[0.07rem]">
|
||||
<BulletListIconButton
|
||||
showBulletPoints={showBulletPoints}
|
||||
onClick={handleShowBulletPoints}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
})}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { useState } from "react";
|
||||
import { INPUT_CLASS_NAME } from "components/ResumeForm/Form/InputGroup";
|
||||
|
||||
export const FeaturedSkillInput = ({
|
||||
skill,
|
||||
rating,
|
||||
setSkillRating,
|
||||
placeholder,
|
||||
className,
|
||||
circleColor,
|
||||
}: {
|
||||
skill: string;
|
||||
rating: number;
|
||||
setSkillRating: (skill: string, rating: number) => void;
|
||||
placeholder: string;
|
||||
className?: string;
|
||||
circleColor?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className={`flex ${className}`}>
|
||||
<input
|
||||
type="text"
|
||||
value={skill}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setSkillRating(e.target.value, rating)}
|
||||
className={INPUT_CLASS_NAME}
|
||||
/>
|
||||
<CircleRating
|
||||
rating={rating}
|
||||
setRating={(newRating) => setSkillRating(skill, newRating)}
|
||||
circleColor={circleColor}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CircleRating = ({
|
||||
rating,
|
||||
setRating,
|
||||
circleColor = "#38bdf8",
|
||||
}: {
|
||||
rating: number;
|
||||
setRating: (rating: number) => void;
|
||||
circleColor?: string;
|
||||
}) => {
|
||||
const numCircles = 5;
|
||||
const [hoverRating, setHoverRating] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex items-center p-2">
|
||||
{[...Array(numCircles)].map((_, idx) => (
|
||||
<div
|
||||
className={`cursor-pointer p-0.5`}
|
||||
key={idx}
|
||||
onClick={() => setRating(idx)}
|
||||
onMouseEnter={() => setHoverRating(idx)}
|
||||
onMouseLeave={() => setHoverRating(null)}
|
||||
>
|
||||
<div
|
||||
className="h-5 w-5 rounded-full transition-transform duration-200 hover:scale-[120%] "
|
||||
style={{
|
||||
backgroundColor:
|
||||
(hoverRating !== null && hoverRating >= idx) ||
|
||||
(hoverRating === null && rating >= idx)
|
||||
? circleColor
|
||||
: "#d1d5db", //gray-300
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
100
open-resume/src/app/components/ResumeForm/Form/IconButton.tsx
Normal file
100
open-resume/src/app/components/ResumeForm/Form/IconButton.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { IconButton } from "components/Button";
|
||||
import {
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
ArrowSmallUpIcon,
|
||||
ArrowSmallDownIcon,
|
||||
TrashIcon,
|
||||
ListBulletIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
export const ShowIconButton = ({
|
||||
show,
|
||||
setShow,
|
||||
}: {
|
||||
show: boolean;
|
||||
setShow: (show: boolean) => void;
|
||||
}) => {
|
||||
const tooltipText = show ? "Hide section" : "Show section";
|
||||
const onClick = () => {
|
||||
setShow(!show);
|
||||
};
|
||||
const Icon = show ? EyeIcon : EyeSlashIcon;
|
||||
|
||||
return (
|
||||
<IconButton onClick={onClick} tooltipText={tooltipText}>
|
||||
<Icon className="h-6 w-6 text-gray-400" aria-hidden="true" />
|
||||
<span className="sr-only">{tooltipText}</span>
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
type MoveIconButtonType = "up" | "down";
|
||||
export const MoveIconButton = ({
|
||||
type,
|
||||
size = "medium",
|
||||
onClick,
|
||||
}: {
|
||||
type: MoveIconButtonType;
|
||||
size?: "small" | "medium";
|
||||
onClick: (type: MoveIconButtonType) => void;
|
||||
}) => {
|
||||
const tooltipText = type === "up" ? "Move up" : "Move down";
|
||||
const sizeClassName = size === "medium" ? "h-6 w-6" : "h-4 w-4";
|
||||
const Icon = type === "up" ? ArrowSmallUpIcon : ArrowSmallDownIcon;
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
onClick={() => onClick(type)}
|
||||
tooltipText={tooltipText}
|
||||
size={size}
|
||||
>
|
||||
<Icon className={`${sizeClassName} text-gray-400`} aria-hidden="true" />
|
||||
<span className="sr-only">{tooltipText}</span>
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const DeleteIconButton = ({
|
||||
onClick,
|
||||
tooltipText,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
tooltipText: string;
|
||||
}) => {
|
||||
return (
|
||||
<IconButton onClick={onClick} tooltipText={tooltipText} size="small">
|
||||
<TrashIcon className="h-4 w-4 text-gray-400" aria-hidden="true" />
|
||||
<span className="sr-only">{tooltipText}</span>
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const BulletListIconButton = ({
|
||||
onClick,
|
||||
showBulletPoints,
|
||||
}: {
|
||||
onClick: (newShowBulletPoints: boolean) => void;
|
||||
showBulletPoints: boolean;
|
||||
}) => {
|
||||
const tooltipText = showBulletPoints
|
||||
? "Hide bullet points"
|
||||
: "Show bullet points";
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
onClick={() => onClick(!showBulletPoints)}
|
||||
tooltipText={tooltipText}
|
||||
size="small"
|
||||
className={showBulletPoints ? "!bg-sky-100" : ""}
|
||||
>
|
||||
<ListBulletIcon
|
||||
className={`h-4 w-4 ${
|
||||
showBulletPoints ? "text-gray-700" : "text-gray-400"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{tooltipText}</span>
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
276
open-resume/src/app/components/ResumeForm/Form/InputGroup.tsx
Normal file
276
open-resume/src/app/components/ResumeForm/Form/InputGroup.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import ContentEditable from "react-contenteditable";
|
||||
import { useAutosizeTextareaHeight } from "lib/hooks/useAutosizeTextareaHeight";
|
||||
|
||||
interface InputProps<K extends string, V extends string | string[]> {
|
||||
label: string;
|
||||
labelClassName?: string;
|
||||
// name is passed in as a const string. Therefore, we make it a generic type so its type can
|
||||
// be more restricted as a const for the first argument in onChange
|
||||
name: K;
|
||||
value?: V;
|
||||
placeholder: string;
|
||||
onChange: (name: K, value: V) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* InputGroupWrapper wraps a label element around a input children. This is preferable
|
||||
* than having input as a sibling since it makes clicking label auto focus input children
|
||||
*/
|
||||
export const InputGroupWrapper = ({
|
||||
label,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}) => (
|
||||
<label className={`text-base font-medium text-gray-700 ${className}`}>
|
||||
{label}
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
|
||||
export const INPUT_CLASS_NAME =
|
||||
"mt-1 px-3 py-2 block w-full rounded-md border border-gray-300 text-gray-900 shadow-sm outline-none font-normal text-base";
|
||||
|
||||
export const Input = <K extends string>({
|
||||
name,
|
||||
value = "",
|
||||
placeholder,
|
||||
onChange,
|
||||
label,
|
||||
labelClassName,
|
||||
}: InputProps<K, string>) => {
|
||||
return (
|
||||
<InputGroupWrapper label={label} className={labelClassName}>
|
||||
<input
|
||||
type="text"
|
||||
name={name}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(name, e.target.value)}
|
||||
className={INPUT_CLASS_NAME}
|
||||
/>
|
||||
</InputGroupWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const Textarea = <T extends string>({
|
||||
label,
|
||||
labelClassName: wrapperClassName,
|
||||
name,
|
||||
value = "",
|
||||
placeholder,
|
||||
onChange,
|
||||
}: InputProps<T, string>) => {
|
||||
const textareaRef = useAutosizeTextareaHeight({ value });
|
||||
|
||||
return (
|
||||
<InputGroupWrapper label={label} className={wrapperClassName}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
name={name}
|
||||
className={`${INPUT_CLASS_NAME} resize-none overflow-hidden`}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(name, e.target.value)}
|
||||
/>
|
||||
</InputGroupWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const BulletListTextarea = <T extends string>(
|
||||
props: InputProps<T, string[]> & { showBulletPoints?: boolean }
|
||||
) => {
|
||||
const [showFallback, setShowFallback] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isFirefox = navigator.userAgent.includes("Firefox");
|
||||
const isSafari =
|
||||
navigator.userAgent.includes("Safari") &&
|
||||
!navigator.userAgent.includes("Chrome"); // Note that Chrome also includes Safari in its userAgent
|
||||
if (isFirefox || isSafari) {
|
||||
setShowFallback(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (showFallback) {
|
||||
return <BulletListTextareaFallback {...props} />;
|
||||
}
|
||||
return <BulletListTextareaGeneral {...props} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* BulletListTextareaGeneral is a textarea where each new line starts with a bullet point.
|
||||
*
|
||||
* In its core, it uses a div with contentEditable set to True. However, when
|
||||
* contentEditable is True, user can paste in any arbitrary html and it would
|
||||
* render. So to make it behaves like a textarea, it strips down all html while
|
||||
* keeping only the text part.
|
||||
*
|
||||
* Reference: https://stackoverflow.com/a/74998090/7699841
|
||||
*/
|
||||
const BulletListTextareaGeneral = <T extends string>({
|
||||
label,
|
||||
labelClassName: wrapperClassName,
|
||||
name,
|
||||
value: bulletListStrings = [],
|
||||
placeholder,
|
||||
onChange,
|
||||
showBulletPoints = true,
|
||||
}: InputProps<T, string[]> & { showBulletPoints?: boolean }) => {
|
||||
const html = getHTMLFromBulletListStrings(bulletListStrings);
|
||||
return (
|
||||
<InputGroupWrapper label={label} className={wrapperClassName}>
|
||||
<ContentEditable
|
||||
contentEditable={true}
|
||||
className={`${INPUT_CLASS_NAME} cursor-text [&>div]:list-item ${
|
||||
showBulletPoints ? "pl-7" : "[&>div]:list-['']"
|
||||
}`}
|
||||
// Note: placeholder currently doesn't work
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
if (e.type === "input") {
|
||||
const { innerText } = e.currentTarget as HTMLDivElement;
|
||||
const newBulletListStrings =
|
||||
getBulletListStringsFromInnerText(innerText);
|
||||
onChange(name, newBulletListStrings);
|
||||
}
|
||||
}}
|
||||
html={html}
|
||||
/>
|
||||
</InputGroupWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const NORMALIZED_LINE_BREAK = "\n";
|
||||
/**
|
||||
* Normalize line breaks to be \n since different OS uses different line break
|
||||
* Windows -> \r\n (CRLF)
|
||||
* Unix -> \n (LF)
|
||||
* Mac -> \n (LF), or \r (CR) for earlier versions
|
||||
*/
|
||||
const normalizeLineBreak = (str: string) =>
|
||||
str.replace(/\r?\n/g, NORMALIZED_LINE_BREAK);
|
||||
const dedupeLineBreak = (str: string) =>
|
||||
str.replace(/\n\n/g, NORMALIZED_LINE_BREAK);
|
||||
const getStringsByLineBreak = (str: string) => str.split(NORMALIZED_LINE_BREAK);
|
||||
|
||||
const getBulletListStringsFromInnerText = (innerText: string) => {
|
||||
const innerTextWithNormalizedLineBreak = normalizeLineBreak(innerText);
|
||||
|
||||
// In Windows Chrome, pressing enter creates 2 line breaks "\n\n"
|
||||
// This dedupes it into 1 line break "\n"
|
||||
let newInnerText = dedupeLineBreak(innerTextWithNormalizedLineBreak);
|
||||
|
||||
// Handle the special case when content is empty
|
||||
if (newInnerText === NORMALIZED_LINE_BREAK) {
|
||||
newInnerText = "";
|
||||
}
|
||||
|
||||
return getStringsByLineBreak(newInnerText);
|
||||
};
|
||||
|
||||
const getHTMLFromBulletListStrings = (bulletListStrings: string[]) => {
|
||||
// If bulletListStrings is an empty array, make it an empty div
|
||||
if (bulletListStrings.length === 0) {
|
||||
return "<div></div>";
|
||||
}
|
||||
|
||||
return bulletListStrings.map((text) => `<div>${text}</div>`).join("");
|
||||
};
|
||||
|
||||
/**
|
||||
* BulletListTextareaFallback is a fallback for BulletListTextareaGeneral to work around
|
||||
* content editable div issue in some browsers. For example, in Firefox, if user enters
|
||||
* space in the content editable div at the end of line, Firefox returns it as a new
|
||||
* line character \n instead of space in innerText.
|
||||
*/
|
||||
const BulletListTextareaFallback = <T extends string>({
|
||||
label,
|
||||
labelClassName,
|
||||
name,
|
||||
value: bulletListStrings = [],
|
||||
placeholder,
|
||||
onChange,
|
||||
showBulletPoints = true,
|
||||
}: InputProps<T, string[]> & { showBulletPoints?: boolean }) => {
|
||||
const textareaValue = getTextareaValueFromBulletListStrings(
|
||||
bulletListStrings,
|
||||
showBulletPoints
|
||||
);
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
label={label}
|
||||
labelClassName={labelClassName}
|
||||
name={name}
|
||||
value={textareaValue}
|
||||
placeholder={placeholder}
|
||||
onChange={(name, value) => {
|
||||
onChange(
|
||||
name,
|
||||
getBulletListStringsFromTextareaValue(value, showBulletPoints)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getTextareaValueFromBulletListStrings = (
|
||||
bulletListStrings: string[],
|
||||
showBulletPoints: boolean
|
||||
) => {
|
||||
const prefix = showBulletPoints ? "• " : "";
|
||||
|
||||
if (bulletListStrings.length === 0) {
|
||||
return prefix;
|
||||
}
|
||||
|
||||
let value = "";
|
||||
for (let i = 0; i < bulletListStrings.length; i++) {
|
||||
const string = bulletListStrings[i];
|
||||
const isLastItem = i === bulletListStrings.length - 1;
|
||||
value += `${prefix}${string}${isLastItem ? "" : "\r\n"}`;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const getBulletListStringsFromTextareaValue = (
|
||||
textareaValue: string,
|
||||
showBulletPoints: boolean
|
||||
) => {
|
||||
const textareaValueWithNormalizedLineBreak =
|
||||
normalizeLineBreak(textareaValue);
|
||||
|
||||
const strings = getStringsByLineBreak(textareaValueWithNormalizedLineBreak);
|
||||
|
||||
if (showBulletPoints) {
|
||||
// Filter out empty strings
|
||||
const nonEmptyStrings = strings.filter((s) => s !== "•");
|
||||
|
||||
let newStrings: string[] = [];
|
||||
for (let string of nonEmptyStrings) {
|
||||
if (string.startsWith("• ")) {
|
||||
newStrings.push(string.slice(2));
|
||||
} else if (string.startsWith("•")) {
|
||||
// Handle the special case when user wants to delete the bullet point, in which case
|
||||
// we combine it with the previous line if previous line exists
|
||||
const lastItemIdx = newStrings.length - 1;
|
||||
if (lastItemIdx >= 0) {
|
||||
const lastItem = newStrings[lastItemIdx];
|
||||
newStrings[lastItemIdx] = `${lastItem}${string.slice(1)}`;
|
||||
} else {
|
||||
newStrings.push(string.slice(1));
|
||||
}
|
||||
} else {
|
||||
newStrings.push(string);
|
||||
}
|
||||
}
|
||||
return newStrings;
|
||||
}
|
||||
|
||||
return strings;
|
||||
};
|
||||
205
open-resume/src/app/components/ResumeForm/Form/index.tsx
Normal file
205
open-resume/src/app/components/ResumeForm/Form/index.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { ExpanderWithHeightTransition } from "components/ExpanderWithHeightTransition";
|
||||
import {
|
||||
DeleteIconButton,
|
||||
MoveIconButton,
|
||||
ShowIconButton,
|
||||
} from "components/ResumeForm/Form/IconButton";
|
||||
import { useAppDispatch, useAppSelector } from "lib/redux/hooks";
|
||||
import {
|
||||
changeFormHeading,
|
||||
changeFormOrder,
|
||||
changeShowForm,
|
||||
selectHeadingByForm,
|
||||
selectIsFirstForm,
|
||||
selectIsLastForm,
|
||||
selectShowByForm,
|
||||
ShowForm,
|
||||
} from "lib/redux/settingsSlice";
|
||||
import {
|
||||
BuildingOfficeIcon,
|
||||
AcademicCapIcon,
|
||||
LightBulbIcon,
|
||||
WrenchIcon,
|
||||
PlusSmallIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
addSectionInForm,
|
||||
deleteSectionInFormByIdx,
|
||||
moveSectionInForm,
|
||||
} from "lib/redux/resumeSlice";
|
||||
|
||||
/**
|
||||
* BaseForm is the bare bone form, i.e. just the outline with no title and no control buttons.
|
||||
* ProfileForm uses this to compose its outline.
|
||||
*/
|
||||
export const BaseForm = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<section
|
||||
className={`flex flex-col gap-3 rounded-md bg-white p-6 pt-4 shadow transition-opacity duration-200 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
|
||||
const FORM_TO_ICON: { [section in ShowForm]: typeof BuildingOfficeIcon } = {
|
||||
workExperiences: BuildingOfficeIcon,
|
||||
educations: AcademicCapIcon,
|
||||
projects: LightBulbIcon,
|
||||
skills: WrenchIcon,
|
||||
custom: WrenchIcon,
|
||||
};
|
||||
|
||||
export const Form = ({
|
||||
form,
|
||||
addButtonText,
|
||||
children,
|
||||
}: {
|
||||
form: ShowForm;
|
||||
addButtonText?: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const showForm = useAppSelector(selectShowByForm(form));
|
||||
const heading = useAppSelector(selectHeadingByForm(form));
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const setShowForm = (showForm: boolean) => {
|
||||
dispatch(changeShowForm({ field: form, value: showForm }));
|
||||
};
|
||||
const setHeading = (heading: string) => {
|
||||
dispatch(changeFormHeading({ field: form, value: heading }));
|
||||
};
|
||||
|
||||
const isFirstForm = useAppSelector(selectIsFirstForm(form));
|
||||
const isLastForm = useAppSelector(selectIsLastForm(form));
|
||||
|
||||
const handleMoveClick = (type: "up" | "down") => {
|
||||
dispatch(changeFormOrder({ form, type }));
|
||||
};
|
||||
|
||||
const Icon = FORM_TO_ICON[form];
|
||||
|
||||
return (
|
||||
<BaseForm
|
||||
className={`transition-opacity duration-200 ${
|
||||
showForm ? "pb-6" : "pb-2 opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex grow items-center gap-2">
|
||||
<Icon className="h-6 w-6 text-gray-600" aria-hidden="true" />
|
||||
<input
|
||||
type="text"
|
||||
className="block w-full border-b border-transparent text-lg font-semibold tracking-wide text-gray-900 outline-none hover:border-gray-300 hover:shadow-sm focus:border-gray-300 focus:shadow-sm"
|
||||
value={heading}
|
||||
onChange={(e) => setHeading(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{!isFirstForm && (
|
||||
<MoveIconButton type="up" onClick={handleMoveClick} />
|
||||
)}
|
||||
{!isLastForm && (
|
||||
<MoveIconButton type="down" onClick={handleMoveClick} />
|
||||
)}
|
||||
<ShowIconButton show={showForm} setShow={setShowForm} />
|
||||
</div>
|
||||
</div>
|
||||
<ExpanderWithHeightTransition expanded={showForm}>
|
||||
{children}
|
||||
</ExpanderWithHeightTransition>
|
||||
{showForm && addButtonText && (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
dispatch(addSectionInForm({ form }));
|
||||
}}
|
||||
className="flex items-center rounded-md bg-white py-2 pl-3 pr-4 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
|
||||
>
|
||||
<PlusSmallIcon
|
||||
className="-ml-0.5 mr-1.5 h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{addButtonText}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</BaseForm>
|
||||
);
|
||||
};
|
||||
|
||||
export const FormSection = ({
|
||||
form,
|
||||
idx,
|
||||
showMoveUp,
|
||||
showMoveDown,
|
||||
showDelete,
|
||||
deleteButtonTooltipText,
|
||||
children,
|
||||
}: {
|
||||
form: ShowForm;
|
||||
idx: number;
|
||||
showMoveUp: boolean;
|
||||
showMoveDown: boolean;
|
||||
showDelete: boolean;
|
||||
deleteButtonTooltipText: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const handleDeleteClick = () => {
|
||||
dispatch(deleteSectionInFormByIdx({ form, idx }));
|
||||
};
|
||||
const handleMoveClick = (direction: "up" | "down") => {
|
||||
dispatch(moveSectionInForm({ form, direction, idx }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{idx !== 0 && (
|
||||
<div className="mb-4 mt-6 border-t-2 border-dotted border-gray-200" />
|
||||
)}
|
||||
<div className="relative grid grid-cols-6 gap-3">
|
||||
{children}
|
||||
<div className={`absolute right-0 top-0 flex gap-0.5 `}>
|
||||
<div
|
||||
className={`transition-all duration-300 ${
|
||||
showMoveUp ? "" : "invisible opacity-0"
|
||||
} ${showMoveDown ? "" : "-mr-6"}`}
|
||||
>
|
||||
<MoveIconButton
|
||||
type="up"
|
||||
size="small"
|
||||
onClick={() => handleMoveClick("up")}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all duration-300 ${
|
||||
showMoveDown ? "" : "invisible opacity-0"
|
||||
}`}
|
||||
>
|
||||
<MoveIconButton
|
||||
type="down"
|
||||
size="small"
|
||||
onClick={() => handleMoveClick("down")}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all duration-300 ${
|
||||
showDelete ? "" : "invisible opacity-0"
|
||||
}`}
|
||||
>
|
||||
<DeleteIconButton
|
||||
onClick={handleDeleteClick}
|
||||
tooltipText={deleteButtonTooltipText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
70
open-resume/src/app/components/ResumeForm/ProfileForm.tsx
Normal file
70
open-resume/src/app/components/ResumeForm/ProfileForm.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { BaseForm } from "components/ResumeForm/Form";
|
||||
import { Input, Textarea } from "components/ResumeForm/Form/InputGroup";
|
||||
import { useAppDispatch, useAppSelector } from "lib/redux/hooks";
|
||||
import { changeProfile, selectProfile } from "lib/redux/resumeSlice";
|
||||
import { ResumeProfile } from "lib/redux/types";
|
||||
|
||||
export const ProfileForm = () => {
|
||||
const profile = useAppSelector(selectProfile);
|
||||
const dispatch = useAppDispatch();
|
||||
const { name, email, phone, url, summary, location } = profile;
|
||||
|
||||
const handleProfileChange = (field: keyof ResumeProfile, value: string) => {
|
||||
dispatch(changeProfile({ field, value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseForm>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<Input
|
||||
label="Name"
|
||||
labelClassName="col-span-full"
|
||||
name="name"
|
||||
placeholder="Sal Khan"
|
||||
value={name}
|
||||
onChange={handleProfileChange}
|
||||
/>
|
||||
<Textarea
|
||||
label="Objective"
|
||||
labelClassName="col-span-full"
|
||||
name="summary"
|
||||
placeholder="Entrepreneur and educator obsessed with making education free for anyone"
|
||||
value={summary}
|
||||
onChange={handleProfileChange}
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
labelClassName="col-span-4"
|
||||
name="email"
|
||||
placeholder="hello@khanacademy.org"
|
||||
value={email}
|
||||
onChange={handleProfileChange}
|
||||
/>
|
||||
<Input
|
||||
label="Phone"
|
||||
labelClassName="col-span-2"
|
||||
name="phone"
|
||||
placeholder="(123)456-7890"
|
||||
value={phone}
|
||||
onChange={handleProfileChange}
|
||||
/>
|
||||
<Input
|
||||
label="Website"
|
||||
labelClassName="col-span-4"
|
||||
name="url"
|
||||
placeholder="linkedin.com/in/khanacademy"
|
||||
value={url}
|
||||
onChange={handleProfileChange}
|
||||
/>
|
||||
<Input
|
||||
label="Location"
|
||||
labelClassName="col-span-2"
|
||||
name="location"
|
||||
placeholder="NYC, NY"
|
||||
value={location}
|
||||
onChange={handleProfileChange}
|
||||
/>
|
||||
</div>
|
||||
</BaseForm>
|
||||
);
|
||||
};
|
||||
69
open-resume/src/app/components/ResumeForm/ProjectsForm.tsx
Normal file
69
open-resume/src/app/components/ResumeForm/ProjectsForm.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Form, FormSection } from "components/ResumeForm/Form";
|
||||
import {
|
||||
Input,
|
||||
BulletListTextarea,
|
||||
} from "components/ResumeForm/Form/InputGroup";
|
||||
import type { CreateHandleChangeArgsWithDescriptions } from "components/ResumeForm/types";
|
||||
import { useAppDispatch, useAppSelector } from "lib/redux/hooks";
|
||||
import { selectProjects, changeProjects } from "lib/redux/resumeSlice";
|
||||
import type { ResumeProject } from "lib/redux/types";
|
||||
|
||||
export const ProjectsForm = () => {
|
||||
const projects = useAppSelector(selectProjects);
|
||||
const dispatch = useAppDispatch();
|
||||
const showDelete = projects.length > 1;
|
||||
|
||||
return (
|
||||
<Form form="projects" addButtonText="Add Project">
|
||||
{projects.map(({ project, date, descriptions }, idx) => {
|
||||
const handleProjectChange = (
|
||||
...[
|
||||
field,
|
||||
value,
|
||||
]: CreateHandleChangeArgsWithDescriptions<ResumeProject>
|
||||
) => {
|
||||
dispatch(changeProjects({ idx, field, value } as any));
|
||||
};
|
||||
const showMoveUp = idx !== 0;
|
||||
const showMoveDown = idx !== projects.length - 1;
|
||||
|
||||
return (
|
||||
<FormSection
|
||||
key={idx}
|
||||
form="projects"
|
||||
idx={idx}
|
||||
showMoveUp={showMoveUp}
|
||||
showMoveDown={showMoveDown}
|
||||
showDelete={showDelete}
|
||||
deleteButtonTooltipText={"Delete project"}
|
||||
>
|
||||
<Input
|
||||
name="project"
|
||||
label="Project Name"
|
||||
placeholder="OpenResume"
|
||||
value={project}
|
||||
onChange={handleProjectChange}
|
||||
labelClassName="col-span-4"
|
||||
/>
|
||||
<Input
|
||||
name="date"
|
||||
label="Date"
|
||||
placeholder="Winter 2022"
|
||||
value={date}
|
||||
onChange={handleProjectChange}
|
||||
labelClassName="col-span-2"
|
||||
/>
|
||||
<BulletListTextarea
|
||||
name="descriptions"
|
||||
label="Description"
|
||||
placeholder="Bullet points"
|
||||
value={descriptions}
|
||||
onChange={handleProjectChange}
|
||||
labelClassName="col-span-full"
|
||||
/>
|
||||
</FormSection>
|
||||
);
|
||||
})}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
85
open-resume/src/app/components/ResumeForm/SkillsForm.tsx
Normal file
85
open-resume/src/app/components/ResumeForm/SkillsForm.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Form } from "components/ResumeForm/Form";
|
||||
import {
|
||||
BulletListTextarea,
|
||||
InputGroupWrapper,
|
||||
} from "components/ResumeForm/Form/InputGroup";
|
||||
import { FeaturedSkillInput } from "components/ResumeForm/Form/FeaturedSkillInput";
|
||||
import { BulletListIconButton } from "components/ResumeForm/Form/IconButton";
|
||||
import { useAppDispatch, useAppSelector } from "lib/redux/hooks";
|
||||
import { selectSkills, changeSkills } from "lib/redux/resumeSlice";
|
||||
import {
|
||||
selectShowBulletPoints,
|
||||
changeShowBulletPoints,
|
||||
selectThemeColor,
|
||||
} from "lib/redux/settingsSlice";
|
||||
|
||||
export const SkillsForm = () => {
|
||||
const skills = useAppSelector(selectSkills);
|
||||
const dispatch = useAppDispatch();
|
||||
const { featuredSkills, descriptions } = skills;
|
||||
const form = "skills";
|
||||
const showBulletPoints = useAppSelector(selectShowBulletPoints(form));
|
||||
const themeColor = useAppSelector(selectThemeColor) || "#38bdf8";
|
||||
|
||||
const handleSkillsChange = (field: "descriptions", value: string[]) => {
|
||||
dispatch(changeSkills({ field, value }));
|
||||
};
|
||||
const handleFeaturedSkillsChange = (
|
||||
idx: number,
|
||||
skill: string,
|
||||
rating: number
|
||||
) => {
|
||||
dispatch(changeSkills({ field: "featuredSkills", idx, skill, rating }));
|
||||
};
|
||||
const handleShowBulletPoints = (value: boolean) => {
|
||||
dispatch(changeShowBulletPoints({ field: form, value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Form form={form}>
|
||||
<div className="col-span-full grid grid-cols-6 gap-3">
|
||||
<div className="relative col-span-full">
|
||||
<BulletListTextarea
|
||||
label="Skills List"
|
||||
labelClassName="col-span-full"
|
||||
name="descriptions"
|
||||
placeholder="Bullet points"
|
||||
value={descriptions}
|
||||
onChange={handleSkillsChange}
|
||||
showBulletPoints={showBulletPoints}
|
||||
/>
|
||||
<div className="absolute left-[4.5rem] top-[0.07rem]">
|
||||
<BulletListIconButton
|
||||
showBulletPoints={showBulletPoints}
|
||||
onClick={handleShowBulletPoints}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-full mb-4 mt-6 border-t-2 border-dotted border-gray-200" />
|
||||
<InputGroupWrapper
|
||||
label="Featured Skills (Optional)"
|
||||
className="col-span-full"
|
||||
>
|
||||
<p className="mt-2 text-sm font-normal text-gray-600">
|
||||
Featured skills is optional to highlight top skills, with more
|
||||
circles mean higher proficiency.
|
||||
</p>
|
||||
</InputGroupWrapper>
|
||||
|
||||
{featuredSkills.map(({ skill, rating }, idx) => (
|
||||
<FeaturedSkillInput
|
||||
key={idx}
|
||||
className="col-span-3"
|
||||
skill={skill}
|
||||
rating={rating}
|
||||
setSkillRating={(newSkill, newRating) => {
|
||||
handleFeaturedSkillsChange(idx, newSkill, newRating);
|
||||
}}
|
||||
placeholder={`Featured Skill ${idx + 1}`}
|
||||
circleColor={themeColor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
interface InputProps<K extends string, V extends string> {
|
||||
label: string;
|
||||
labelClassName?: string;
|
||||
name: K;
|
||||
value?: V;
|
||||
placeholder: string;
|
||||
inputStyle?: React.CSSProperties;
|
||||
onChange: (name: K, value: V) => void;
|
||||
}
|
||||
|
||||
export const InlineInput = <K extends string>({
|
||||
label,
|
||||
labelClassName,
|
||||
name,
|
||||
value = "",
|
||||
placeholder,
|
||||
inputStyle = {},
|
||||
onChange,
|
||||
}: InputProps<K, string>) => {
|
||||
return (
|
||||
<label
|
||||
className={`flex gap-2 text-base font-medium text-gray-700 ${labelClassName}`}
|
||||
>
|
||||
<span className="w-28">{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
name={name}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(name, e.target.value)}
|
||||
className="w-[5rem] border-b border-gray-300 text-center font-semibold leading-3 outline-none"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { GeneralSetting } from "lib/redux/settingsSlice";
|
||||
import { PX_PER_PT } from "lib/constants";
|
||||
import {
|
||||
FONT_FAMILY_TO_STANDARD_SIZE_IN_PT,
|
||||
FONT_FAMILY_TO_DISPLAY_NAME,
|
||||
type FontFamily,
|
||||
} from "components/fonts/constants";
|
||||
import { getAllFontFamiliesToLoad } from "components/fonts/lib";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const Selection = ({
|
||||
selectedColor,
|
||||
isSelected,
|
||||
style = {},
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
selectedColor: string;
|
||||
isSelected: boolean;
|
||||
style?: React.CSSProperties;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const selectedStyle = {
|
||||
color: "white",
|
||||
backgroundColor: selectedColor,
|
||||
borderColor: selectedColor,
|
||||
...style,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-[105px] cursor-pointer items-center justify-center rounded-md border border-gray-300 py-1.5 shadow-sm hover:border-gray-400 hover:bg-gray-100"
|
||||
onClick={onClick}
|
||||
style={isSelected ? selectedStyle : style}
|
||||
onKeyDown={(e) => {
|
||||
if (["Enter", " "].includes(e.key)) onClick();
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SelectionsWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
return <div className="mt-2 flex flex-wrap gap-3">{children}</div>;
|
||||
};
|
||||
|
||||
const FontFamilySelections = ({
|
||||
selectedFontFamily,
|
||||
themeColor,
|
||||
handleSettingsChange,
|
||||
}: {
|
||||
selectedFontFamily: string;
|
||||
themeColor: string;
|
||||
handleSettingsChange: (field: GeneralSetting, value: string) => void;
|
||||
}) => {
|
||||
const allFontFamilies = getAllFontFamiliesToLoad();
|
||||
return (
|
||||
<SelectionsWrapper>
|
||||
{allFontFamilies.map((fontFamily, idx) => {
|
||||
const isSelected = selectedFontFamily === fontFamily;
|
||||
const standardSizePt = FONT_FAMILY_TO_STANDARD_SIZE_IN_PT[fontFamily];
|
||||
return (
|
||||
<Selection
|
||||
key={idx}
|
||||
selectedColor={themeColor}
|
||||
isSelected={isSelected}
|
||||
style={{
|
||||
fontFamily,
|
||||
fontSize: `${standardSizePt * PX_PER_PT}px`,
|
||||
}}
|
||||
onClick={() => handleSettingsChange("fontFamily", fontFamily)}
|
||||
>
|
||||
{FONT_FAMILY_TO_DISPLAY_NAME[fontFamily]}
|
||||
</Selection>
|
||||
);
|
||||
})}
|
||||
</SelectionsWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Load FontFamilySelections client side since it calls getAllFontFamiliesToLoad,
|
||||
* which uses navigator object that is only available on client side
|
||||
*/
|
||||
export const FontFamilySelectionsCSR = dynamic(
|
||||
() => Promise.resolve(FontFamilySelections),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
export const FontSizeSelections = ({
|
||||
selectedFontSize,
|
||||
fontFamily,
|
||||
themeColor,
|
||||
handleSettingsChange,
|
||||
}: {
|
||||
fontFamily: FontFamily;
|
||||
themeColor: string;
|
||||
selectedFontSize: string;
|
||||
handleSettingsChange: (field: GeneralSetting, value: string) => void;
|
||||
}) => {
|
||||
const standardSizePt = FONT_FAMILY_TO_STANDARD_SIZE_IN_PT[fontFamily];
|
||||
const compactSizePt = standardSizePt - 1;
|
||||
|
||||
return (
|
||||
<SelectionsWrapper>
|
||||
{["Compact", "Standard", "Large"].map((type, idx) => {
|
||||
const fontSizePt = String(compactSizePt + idx);
|
||||
const isSelected = fontSizePt === selectedFontSize;
|
||||
return (
|
||||
<Selection
|
||||
key={idx}
|
||||
selectedColor={themeColor}
|
||||
isSelected={isSelected}
|
||||
style={{
|
||||
fontFamily,
|
||||
fontSize: `${Number(fontSizePt) * PX_PER_PT}px`,
|
||||
}}
|
||||
onClick={() => handleSettingsChange("fontSize", fontSizePt)}
|
||||
>
|
||||
{type}
|
||||
</Selection>
|
||||
);
|
||||
})}
|
||||
</SelectionsWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const DocumentSizeSelections = ({
|
||||
selectedDocumentSize,
|
||||
themeColor,
|
||||
handleSettingsChange,
|
||||
}: {
|
||||
themeColor: string;
|
||||
selectedDocumentSize: string;
|
||||
handleSettingsChange: (field: GeneralSetting, value: string) => void;
|
||||
}) => {
|
||||
return (
|
||||
<SelectionsWrapper>
|
||||
{["Letter", "A4"].map((type, idx) => {
|
||||
return (
|
||||
<Selection
|
||||
key={idx}
|
||||
selectedColor={themeColor}
|
||||
isSelected={type === selectedDocumentSize}
|
||||
onClick={() => handleSettingsChange("documentSize", type)}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<div>{type}</div>
|
||||
<div className="text-xs">
|
||||
{type === "Letter" ? "(US, Canada)" : "(other countries)"}
|
||||
</div>
|
||||
</div>
|
||||
</Selection>
|
||||
);
|
||||
})}
|
||||
</SelectionsWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
export const THEME_COLORS = [
|
||||
"#f87171", // Red-400
|
||||
"#ef4444", // Red-500
|
||||
"#fb923c", // Orange-400
|
||||
"#f97316", // Orange-500
|
||||
"#fbbf24", // Amber-400
|
||||
"#f59e0b", // Amber-500
|
||||
"#22c55e", // Green-500
|
||||
"#15803d", // Green-700
|
||||
"#38bdf8", // Sky-400
|
||||
"#0ea5e9", // Sky-500
|
||||
"#818cf8", // Indigo-400
|
||||
"#6366f1", // Indigo-500
|
||||
];
|
||||
100
open-resume/src/app/components/ResumeForm/ThemeForm/index.tsx
Normal file
100
open-resume/src/app/components/ResumeForm/ThemeForm/index.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { BaseForm } from "components/ResumeForm/Form";
|
||||
import { InputGroupWrapper } from "components/ResumeForm/Form/InputGroup";
|
||||
import { THEME_COLORS } from "components/ResumeForm/ThemeForm/constants";
|
||||
import { InlineInput } from "components/ResumeForm/ThemeForm/InlineInput";
|
||||
import {
|
||||
DocumentSizeSelections,
|
||||
FontFamilySelectionsCSR,
|
||||
FontSizeSelections,
|
||||
} from "components/ResumeForm/ThemeForm/Selection";
|
||||
import {
|
||||
changeSettings,
|
||||
DEFAULT_THEME_COLOR,
|
||||
selectSettings,
|
||||
type GeneralSetting,
|
||||
} from "lib/redux/settingsSlice";
|
||||
import { useAppDispatch, useAppSelector } from "lib/redux/hooks";
|
||||
import type { FontFamily } from "components/fonts/constants";
|
||||
import { Cog6ToothIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export const ThemeForm = () => {
|
||||
const settings = useAppSelector(selectSettings);
|
||||
const { fontSize, fontFamily, documentSize } = settings;
|
||||
const themeColor = settings.themeColor || DEFAULT_THEME_COLOR;
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleSettingsChange = (field: GeneralSetting, value: string) => {
|
||||
dispatch(changeSettings({ field, value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseForm>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cog6ToothIcon className="h-6 w-6 text-gray-600" aria-hidden="true" />
|
||||
<h1 className="text-lg font-semibold tracking-wide text-gray-900 ">
|
||||
Resume Setting
|
||||
</h1>
|
||||
</div>
|
||||
<div>
|
||||
<InlineInput
|
||||
label="Theme Color"
|
||||
name="themeColor"
|
||||
value={settings.themeColor}
|
||||
placeholder={DEFAULT_THEME_COLOR}
|
||||
onChange={handleSettingsChange}
|
||||
inputStyle={{ color: themeColor }}
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{THEME_COLORS.map((color, idx) => (
|
||||
<div
|
||||
className="flex h-10 w-10 cursor-pointer items-center justify-center rounded-md text-sm text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
key={idx}
|
||||
onClick={() => handleSettingsChange("themeColor", color)}
|
||||
onKeyDown={(e) => {
|
||||
if (["Enter", " "].includes(e.key))
|
||||
handleSettingsChange("themeColor", color);
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
{settings.themeColor === color ? "✓" : ""}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<InputGroupWrapper label="Font Family" />
|
||||
<FontFamilySelectionsCSR
|
||||
selectedFontFamily={fontFamily}
|
||||
themeColor={themeColor}
|
||||
handleSettingsChange={handleSettingsChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<InlineInput
|
||||
label="Font Size (pt)"
|
||||
name="fontSize"
|
||||
value={fontSize}
|
||||
placeholder="11"
|
||||
onChange={handleSettingsChange}
|
||||
/>
|
||||
<FontSizeSelections
|
||||
fontFamily={fontFamily as FontFamily}
|
||||
themeColor={themeColor}
|
||||
selectedFontSize={fontSize}
|
||||
handleSettingsChange={handleSettingsChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<InputGroupWrapper label="Document Size" />
|
||||
<DocumentSizeSelections
|
||||
themeColor={themeColor}
|
||||
selectedDocumentSize={documentSize}
|
||||
handleSettingsChange={handleSettingsChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</BaseForm>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Form, FormSection } from "components/ResumeForm/Form";
|
||||
import {
|
||||
Input,
|
||||
BulletListTextarea,
|
||||
} from "components/ResumeForm/Form/InputGroup";
|
||||
import type { CreateHandleChangeArgsWithDescriptions } from "components/ResumeForm/types";
|
||||
import { useAppDispatch, useAppSelector } from "lib/redux/hooks";
|
||||
import {
|
||||
changeWorkExperiences,
|
||||
selectWorkExperiences,
|
||||
} from "lib/redux/resumeSlice";
|
||||
import type { ResumeWorkExperience } from "lib/redux/types";
|
||||
|
||||
export const WorkExperiencesForm = () => {
|
||||
const workExperiences = useAppSelector(selectWorkExperiences);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const showDelete = workExperiences.length > 1;
|
||||
|
||||
return (
|
||||
<Form form="workExperiences" addButtonText="Add Job">
|
||||
{workExperiences.map(({ company, jobTitle, date, descriptions }, idx) => {
|
||||
const handleWorkExperienceChange = (
|
||||
...[
|
||||
field,
|
||||
value,
|
||||
]: CreateHandleChangeArgsWithDescriptions<ResumeWorkExperience>
|
||||
) => {
|
||||
// TS doesn't support passing union type to single call signature
|
||||
// https://github.com/microsoft/TypeScript/issues/54027
|
||||
// any is used here as a workaround
|
||||
dispatch(changeWorkExperiences({ idx, field, value } as any));
|
||||
};
|
||||
const showMoveUp = idx !== 0;
|
||||
const showMoveDown = idx !== workExperiences.length - 1;
|
||||
|
||||
return (
|
||||
<FormSection
|
||||
key={idx}
|
||||
form="workExperiences"
|
||||
idx={idx}
|
||||
showMoveUp={showMoveUp}
|
||||
showMoveDown={showMoveDown}
|
||||
showDelete={showDelete}
|
||||
deleteButtonTooltipText="Delete job"
|
||||
>
|
||||
<Input
|
||||
label="Company"
|
||||
labelClassName="col-span-full"
|
||||
name="company"
|
||||
placeholder="Khan Academy"
|
||||
value={company}
|
||||
onChange={handleWorkExperienceChange}
|
||||
/>
|
||||
<Input
|
||||
label="Job Title"
|
||||
labelClassName="col-span-4"
|
||||
name="jobTitle"
|
||||
placeholder="Software Engineer"
|
||||
value={jobTitle}
|
||||
onChange={handleWorkExperienceChange}
|
||||
/>
|
||||
<Input
|
||||
label="Date"
|
||||
labelClassName="col-span-2"
|
||||
name="date"
|
||||
placeholder="Jun 2022 - Present"
|
||||
value={date}
|
||||
onChange={handleWorkExperienceChange}
|
||||
/>
|
||||
<BulletListTextarea
|
||||
label="Description"
|
||||
labelClassName="col-span-full"
|
||||
name="descriptions"
|
||||
placeholder="Bullet points"
|
||||
value={descriptions}
|
||||
onChange={handleWorkExperienceChange}
|
||||
/>
|
||||
</FormSection>
|
||||
);
|
||||
})}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
55
open-resume/src/app/components/ResumeForm/index.tsx
Normal file
55
open-resume/src/app/components/ResumeForm/index.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useAppSelector,
|
||||
useSaveStateToLocalStorageOnChange,
|
||||
useSetInitialStore,
|
||||
} from "lib/redux/hooks";
|
||||
import { ShowForm, selectFormsOrder } from "lib/redux/settingsSlice";
|
||||
import { ProfileForm } from "components/ResumeForm/ProfileForm";
|
||||
import { WorkExperiencesForm } from "components/ResumeForm/WorkExperiencesForm";
|
||||
import { EducationsForm } from "components/ResumeForm/EducationsForm";
|
||||
import { ProjectsForm } from "components/ResumeForm/ProjectsForm";
|
||||
import { SkillsForm } from "components/ResumeForm/SkillsForm";
|
||||
import { ThemeForm } from "components/ResumeForm/ThemeForm";
|
||||
import { CustomForm } from "components/ResumeForm/CustomForm";
|
||||
import { FlexboxSpacer } from "components/FlexboxSpacer";
|
||||
import { cx } from "lib/cx";
|
||||
|
||||
const formTypeToComponent: { [type in ShowForm]: () => JSX.Element } = {
|
||||
workExperiences: WorkExperiencesForm,
|
||||
educations: EducationsForm,
|
||||
projects: ProjectsForm,
|
||||
skills: SkillsForm,
|
||||
custom: CustomForm,
|
||||
};
|
||||
|
||||
export const ResumeForm = () => {
|
||||
useSetInitialStore();
|
||||
useSaveStateToLocalStorageOnChange();
|
||||
|
||||
const formsOrder = useAppSelector(selectFormsOrder);
|
||||
const [isHover, setIsHover] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"flex justify-center scrollbar-thin scrollbar-track-gray-100 md:h-[calc(100vh-var(--top-nav-bar-height))] md:justify-end md:overflow-y-scroll",
|
||||
isHover ? "scrollbar-thumb-gray-200" : "scrollbar-thumb-gray-100"
|
||||
)}
|
||||
onMouseOver={() => setIsHover(true)}
|
||||
onMouseLeave={() => setIsHover(false)}
|
||||
>
|
||||
<section className="flex max-w-2xl flex-col gap-8 p-[var(--resume-padding)]">
|
||||
<ProfileForm />
|
||||
{formsOrder.map((form) => {
|
||||
const Component = formTypeToComponent[form];
|
||||
return <Component key={form} />;
|
||||
})}
|
||||
<ThemeForm />
|
||||
<br />
|
||||
</section>
|
||||
<FlexboxSpacer maxWidth={50} className="hidden md:block" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
3
open-resume/src/app/components/ResumeForm/types.ts
Normal file
3
open-resume/src/app/components/ResumeForm/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type CreateHandleChangeArgsWithDescriptions<T> =
|
||||
| [field: Exclude<keyof T, "descriptions">, value: string]
|
||||
| [field: "descriptions", value: string[]];
|
||||
73
open-resume/src/app/components/Tooltip.tsx
Normal file
73
open-resume/src/app/components/Tooltip.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
/**
|
||||
* A simple Tooltip component that shows tooltip text center below children on hover and on focus
|
||||
*
|
||||
* @example
|
||||
* <Tooltip text="Tooltip Text">
|
||||
* <div>Hello</div>
|
||||
* </Tooltip>
|
||||
*/
|
||||
export const Tooltip = ({
|
||||
text,
|
||||
children,
|
||||
}: {
|
||||
text: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [tooltipPos, setTooltipPos] = useState({ top: 0, left: 0 });
|
||||
|
||||
const [show, setShow] = useState(false);
|
||||
const showTooltip = () => setShow(true);
|
||||
const hideTooltip = () => setShow(false);
|
||||
|
||||
// Hook to set tooltip position to be right below children and centered
|
||||
useEffect(() => {
|
||||
const span = spanRef.current;
|
||||
const tooltip = tooltipRef.current;
|
||||
if (span && tooltip) {
|
||||
const rect = span.getBoundingClientRect();
|
||||
const TOP_OFFSET = 6;
|
||||
const newTop = rect.top + rect.height + TOP_OFFSET;
|
||||
const newLeft = rect.left - tooltip.offsetWidth / 2 + rect.width / 2;
|
||||
setTooltipPos({
|
||||
top: newTop,
|
||||
left: newLeft,
|
||||
});
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={spanRef}
|
||||
onMouseEnter={showTooltip}
|
||||
onMouseLeave={hideTooltip}
|
||||
onFocus={showTooltip}
|
||||
onBlur={hideTooltip}
|
||||
// hide tooltip onClick to handle the edge case where the element position is changed after lick
|
||||
onClick={hideTooltip}
|
||||
>
|
||||
{children}
|
||||
{show &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
role="tooltip"
|
||||
className="absolute left-0 top-0 z-10 w-max rounded-md bg-gray-600 px-2 py-0.5 text-sm text-white"
|
||||
style={{
|
||||
left: `${tooltipPos.left}px`,
|
||||
top: `${tooltipPos.top}px`,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
59
open-resume/src/app/components/TopNavBar.tsx
Normal file
59
open-resume/src/app/components/TopNavBar.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import logoSrc from "public/logo.svg";
|
||||
import { cx } from "lib/cx";
|
||||
|
||||
export const TopNavBar = () => {
|
||||
const pathName = usePathname();
|
||||
const isHomePage = pathName === "/";
|
||||
|
||||
return (
|
||||
<header
|
||||
aria-label="Site Header"
|
||||
className={cx(
|
||||
"flex h-[var(--top-nav-bar-height)] items-center border-b-2 border-gray-100 px-3 lg:px-12",
|
||||
isHomePage && "bg-dot"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-10 w-full items-center justify-between">
|
||||
<Link href="/">
|
||||
<span className="sr-only">OpenResume</span>
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt="OpenResume Logo"
|
||||
className="h-8 w-full"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
<nav
|
||||
aria-label="Site Nav Bar"
|
||||
className="flex items-center gap-2 text-sm font-medium"
|
||||
>
|
||||
{[
|
||||
["/resume-builder", "Builder"],
|
||||
["/resume-parser", "Parser"],
|
||||
].map(([href, text]) => (
|
||||
<Link
|
||||
key={text}
|
||||
className="rounded-md px-1.5 py-2 text-gray-500 hover:bg-gray-100 focus-visible:bg-gray-100 lg:px-4"
|
||||
href={href}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
))}
|
||||
<div className="ml-1 mt-1">
|
||||
<iframe
|
||||
src="https://ghbtns.com/github-btn.html?user=xitanggg&repo=open-resume&type=star&count=true"
|
||||
width="100"
|
||||
height="20"
|
||||
className="overflow-hidden border-none"
|
||||
title="GitHub"
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
5
open-resume/src/app/components/documentation/Badge.tsx
Normal file
5
open-resume/src/app/components/documentation/Badge.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
export const Badge = ({ children }: { children: React.ReactNode }) => (
|
||||
<span className="inline-flex rounded-md bg-blue-50 px-2 pb-0.5 align-text-bottom text-xs font-semibold text-blue-700 ring-1 ring-inset ring-blue-700/10">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
31
open-resume/src/app/components/documentation/Heading.tsx
Normal file
31
open-resume/src/app/components/documentation/Heading.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { cx } from "lib/cx";
|
||||
|
||||
const HEADING_CLASSNAMES = {
|
||||
1: "text-2xl font-bold",
|
||||
2: "text-xl font-bold",
|
||||
3: "text-lg font-semibold",
|
||||
};
|
||||
|
||||
export const Heading = ({
|
||||
level = 1,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
level?: 1 | 2 | 3;
|
||||
smallMarginTop?: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
const Component = `h${level}` as const;
|
||||
return (
|
||||
<Component
|
||||
className={cx(
|
||||
"mt-[2em] text-gray-900",
|
||||
HEADING_CLASSNAMES[level],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
24
open-resume/src/app/components/documentation/Link.tsx
Normal file
24
open-resume/src/app/components/documentation/Link.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { cx } from "lib/cx";
|
||||
|
||||
export const Link = ({
|
||||
href,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
className={cx(
|
||||
"underline underline-offset-2 hover:decoration-2",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
23
open-resume/src/app/components/documentation/Paragraph.tsx
Normal file
23
open-resume/src/app/components/documentation/Paragraph.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { cx } from "lib/cx";
|
||||
|
||||
export const Paragraph = ({
|
||||
smallMarginTop = false,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
smallMarginTop?: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<p
|
||||
className={cx(
|
||||
smallMarginTop ? "mt-[0.8em]" : "mt-[1.5em]",
|
||||
"text-lg text-gray-700",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
58
open-resume/src/app/components/documentation/Table.tsx
Normal file
58
open-resume/src/app/components/documentation/Table.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { cx } from "lib/cx";
|
||||
|
||||
export const Table = ({
|
||||
table,
|
||||
title,
|
||||
className,
|
||||
trClassNames = [],
|
||||
tdClassNames = [],
|
||||
}: {
|
||||
table: React.ReactNode[][];
|
||||
title?: string;
|
||||
className?: string;
|
||||
trClassNames?: string[];
|
||||
tdClassNames?: string[];
|
||||
}) => {
|
||||
const tableHeader = table[0];
|
||||
const tableBody = table.slice(1);
|
||||
return (
|
||||
<table
|
||||
className={cx("w-full divide-y border text-sm text-gray-900", className)}
|
||||
>
|
||||
<thead className="divide-y bg-gray-50 text-left align-top">
|
||||
{title && (
|
||||
<tr className="divide-x bg-gray-50">
|
||||
<th
|
||||
className="px-2 py-1.5 font-bold"
|
||||
scope="colSpan"
|
||||
colSpan={tableHeader.length}
|
||||
>
|
||||
{title}
|
||||
</th>
|
||||
</tr>
|
||||
)}
|
||||
<tr className="divide-x bg-gray-50">
|
||||
{tableHeader.map((item, idx) => (
|
||||
<th className="px-2 py-1.5 font-semibold" scope="col" key={idx}>
|
||||
{item}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y text-left align-top">
|
||||
{tableBody.map((row, rowIdx) => (
|
||||
<tr className={cx("divide-x", trClassNames[rowIdx])} key={rowIdx}>
|
||||
{row.map((item, colIdx) => (
|
||||
<td
|
||||
className={cx("px-2 py-1.5", tdClassNames[colIdx])}
|
||||
key={colIdx}
|
||||
>
|
||||
{item}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
7
open-resume/src/app/components/documentation/index.tsx
Normal file
7
open-resume/src/app/components/documentation/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Heading } from "components/documentation/Heading";
|
||||
import { Paragraph } from "components/documentation/Paragraph";
|
||||
import { Link } from "components/documentation/Link";
|
||||
import { Badge } from "components/documentation/Badge";
|
||||
import { Table } from "components/documentation/Table";
|
||||
|
||||
export { Heading, Paragraph, Link, Badge, Table };
|
||||
7
open-resume/src/app/components/fonts/FontsZh.tsx
Normal file
7
open-resume/src/app/components/fonts/FontsZh.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import "public/fonts/fonts-zh.css";
|
||||
|
||||
/**
|
||||
* Empty component. Main purpose is to load fonts-zh.css
|
||||
*/
|
||||
const FontsZh = () => <></>;
|
||||
export default FontsZh;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { getAllFontFamiliesToLoad } from "components/fonts/lib";
|
||||
|
||||
const FontsZhCSR = dynamic(() => import("components/fonts/FontsZh"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Empty component to lazy load non-english fonts CSS conditionally
|
||||
*
|
||||
* Reference: https://prawira.medium.com/react-conditional-import-conditional-css-import-110cc58e0da6
|
||||
*/
|
||||
export const NonEnglishFontsCSSLazyLoader = () => {
|
||||
const [shouldLoadFontsZh, setShouldLoadFontsZh] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (getAllFontFamiliesToLoad().includes("NotoSansSC")) {
|
||||
setShouldLoadFontsZh(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <>{shouldLoadFontsZh && <FontsZhCSR />}</>;
|
||||
};
|
||||
95
open-resume/src/app/components/fonts/constants.ts
Normal file
95
open-resume/src/app/components/fonts/constants.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Adding a new font family involves 4 steps:
|
||||
* Step 1. Add it to one of the below FONT_FAMILIES variable array:
|
||||
* English fonts -> SANS_SERIF_ENGLISH_FONT_FAMILIES or SERIF_ENGLISH_FONT_FAMILIES
|
||||
* Non-English fonts -> NON_ENGLISH_FONT_FAMILIES
|
||||
* Once the font is added, it would take care of
|
||||
* a. Registering font family for React PDF at "components/fonts/hooks.tsx"
|
||||
* b. Loading font family for React PDF iframe at "components/Resume/ResumeIFrame.tsx"
|
||||
* c. Adding font family selection to Resume Settings at "components/ResumeForm/ThemeForm/Selection.tsx"
|
||||
* Step 2. To load css correctly for the Resume Form:
|
||||
* English fonts -> add it to the "public\fonts\fonts.css" file
|
||||
* Non-English fonts -> create/update "public\fonts\fonts-<language>.css" and update "components/fonts/NonEnglishFontsCSSLazyLoader.tsx"
|
||||
* Step 3. Update FONT_FAMILY_TO_STANDARD_SIZE_IN_PT and FONT_FAMILY_TO_DISPLAY_NAME accordingly
|
||||
* Step 4. Update "public/fonts/OFL.txt" to include the new font family and credit the font creator
|
||||
*
|
||||
* IMPORTANT NOTE:
|
||||
* One major problem with adding a new font family is that most font family doesn't work with
|
||||
* React PDF out of box. The texts would appear fine in the PDF, but copying and pasting them
|
||||
* would result in different texts. See issues: https://github.com/diegomura/react-pdf/issues/915
|
||||
* and https://github.com/diegomura/react-pdf/issues/629
|
||||
*
|
||||
* A solution to this problem is to import and re-export the font with a font editor, e.g. fontforge or birdfont.
|
||||
*
|
||||
* If using fontforge, the following command can be used to export the font:
|
||||
* ./fontforge -lang=ff -c 'Open($1); Generate($2); Close();' old_font.ttf new_font.ttf
|
||||
* Note that fontforge doesn't work on non-english fonts: https://github.com/fontforge/fontforge/issues/1534
|
||||
* Also, some fonts might still not work after re-export.
|
||||
*/
|
||||
|
||||
const SANS_SERIF_ENGLISH_FONT_FAMILIES = [
|
||||
"Roboto",
|
||||
"Lato",
|
||||
"Montserrat",
|
||||
"OpenSans",
|
||||
"Raleway",
|
||||
] as const;
|
||||
|
||||
const SERIF_ENGLISH_FONT_FAMILIES = [
|
||||
"Caladea",
|
||||
"Lora",
|
||||
"RobotoSlab",
|
||||
"PlayfairDisplay",
|
||||
"Merriweather",
|
||||
] as const;
|
||||
|
||||
export const ENGLISH_FONT_FAMILIES = [
|
||||
...SANS_SERIF_ENGLISH_FONT_FAMILIES,
|
||||
...SERIF_ENGLISH_FONT_FAMILIES,
|
||||
];
|
||||
type EnglishFontFamily = (typeof ENGLISH_FONT_FAMILIES)[number];
|
||||
|
||||
export const NON_ENGLISH_FONT_FAMILIES = ["NotoSansSC"] as const;
|
||||
type NonEnglishFontFamily = (typeof NON_ENGLISH_FONT_FAMILIES)[number];
|
||||
|
||||
export const NON_ENGLISH_FONT_FAMILY_TO_LANGUAGE: Record<
|
||||
NonEnglishFontFamily,
|
||||
string[]
|
||||
> = {
|
||||
NotoSansSC: ["zh", "zh-CN", "zh-TW"],
|
||||
};
|
||||
|
||||
export type FontFamily = EnglishFontFamily | NonEnglishFontFamily;
|
||||
export const FONT_FAMILY_TO_STANDARD_SIZE_IN_PT: Record<FontFamily, number> = {
|
||||
// Sans Serif Fonts
|
||||
Roboto: 11,
|
||||
Lato: 11,
|
||||
Montserrat: 10,
|
||||
OpenSans: 10,
|
||||
Raleway: 10,
|
||||
// Serif Fonts
|
||||
Caladea: 11,
|
||||
Lora: 11,
|
||||
RobotoSlab: 10,
|
||||
PlayfairDisplay: 10,
|
||||
Merriweather: 10,
|
||||
// Non-English Fonts
|
||||
NotoSansSC: 11,
|
||||
};
|
||||
|
||||
export const FONT_FAMILY_TO_DISPLAY_NAME: Record<FontFamily, string> = {
|
||||
// Sans Serif Fonts
|
||||
Roboto: "Roboto",
|
||||
Lato: "Lato",
|
||||
Montserrat: "Montserrat",
|
||||
OpenSans: "Open Sans",
|
||||
Raleway: "Raleway",
|
||||
// Serif Fonts
|
||||
Caladea: "Caladea",
|
||||
Lora: "Lora",
|
||||
RobotoSlab: "Roboto Slab",
|
||||
PlayfairDisplay: "Playfair Display",
|
||||
Merriweather: "Merriweather",
|
||||
// Non-English Fonts
|
||||
NotoSansSC: "思源黑体(简体)",
|
||||
};
|
||||
47
open-resume/src/app/components/fonts/hooks.tsx
Normal file
47
open-resume/src/app/components/fonts/hooks.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect } from "react";
|
||||
import { Font } from "@react-pdf/renderer";
|
||||
import { ENGLISH_FONT_FAMILIES } from "components/fonts/constants";
|
||||
import { getAllFontFamiliesToLoad } from "components/fonts/lib";
|
||||
|
||||
/**
|
||||
* Register all fonts to React PDF so it can render fonts correctly in PDF
|
||||
*/
|
||||
export const useRegisterReactPDFFont = () => {
|
||||
useEffect(() => {
|
||||
const allFontFamilies = getAllFontFamiliesToLoad();
|
||||
allFontFamilies.forEach((fontFamily) => {
|
||||
Font.register({
|
||||
family: fontFamily,
|
||||
fonts: [
|
||||
{
|
||||
src: `fonts/${fontFamily}-Regular.ttf`,
|
||||
},
|
||||
{
|
||||
src: `fonts/${fontFamily}-Bold.ttf`,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const useRegisterReactPDFHyphenationCallback = (fontFamily: string) => {
|
||||
useEffect(() => {
|
||||
if (ENGLISH_FONT_FAMILIES.includes(fontFamily as any)) {
|
||||
// Disable hyphenation for English Font Family so the word wraps each line
|
||||
// https://github.com/diegomura/react-pdf/issues/311#issuecomment-548301604
|
||||
Font.registerHyphenationCallback((word) => [word]);
|
||||
} else {
|
||||
// React PDF doesn't understand how to wrap non-english word on line break
|
||||
// A workaround is to add an empty character after each word
|
||||
// Reference https://github.com/diegomura/react-pdf/issues/1568
|
||||
Font.registerHyphenationCallback((word) =>
|
||||
word
|
||||
.split("")
|
||||
.map((char) => [char, ""])
|
||||
.flat()
|
||||
);
|
||||
}
|
||||
}, [fontFamily]);
|
||||
};
|
||||
24
open-resume/src/app/components/fonts/lib.ts
Normal file
24
open-resume/src/app/components/fonts/lib.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
import {
|
||||
ENGLISH_FONT_FAMILIES,
|
||||
NON_ENGLISH_FONT_FAMILIES,
|
||||
NON_ENGLISH_FONT_FAMILY_TO_LANGUAGE,
|
||||
} from "components/fonts/constants";
|
||||
|
||||
/**
|
||||
* getPreferredNonEnglishFontFamilies returns non-english font families that are included in
|
||||
* user's preferred languages. This is to avoid loading fonts/languages that users won't use.
|
||||
*/
|
||||
const getPreferredNonEnglishFontFamilies = () => {
|
||||
return NON_ENGLISH_FONT_FAMILIES.filter((fontFamily) => {
|
||||
const fontLanguages = NON_ENGLISH_FONT_FAMILY_TO_LANGUAGE[fontFamily];
|
||||
const userPreferredLanguages = navigator.languages ?? [navigator.language];
|
||||
return userPreferredLanguages.some((preferredLanguage) =>
|
||||
fontLanguages.includes(preferredLanguage)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const getAllFontFamiliesToLoad = () => {
|
||||
return [...ENGLISH_FONT_FAMILIES, ...getPreferredNonEnglishFontFamilies()];
|
||||
};
|
||||
BIN
open-resume/src/app/favicon.ico
Normal file
BIN
open-resume/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
5
open-resume/src/app/globals-css.ts
Normal file
5
open-resume/src/app/globals-css.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const CSS_VARIABLES = {
|
||||
"--top-nav-bar-height": "3.5rem",
|
||||
"--resume-control-bar-height": "3rem",
|
||||
"--resume-padding": "1.5rem",
|
||||
} as const;
|
||||
34
open-resume/src/app/globals.css
Normal file
34
open-resume/src/app/globals.css
Normal file
@@ -0,0 +1,34 @@
|
||||
@import url("/fonts/fonts.css");
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply bg-primary outline-theme-purple inline-block rounded-full px-6 py-2 font-semibold shadow-sm;
|
||||
}
|
||||
.text-primary {
|
||||
@apply bg-gradient-to-r from-[color:var(--theme-purple)] to-[color:var(--theme-blue)] bg-clip-text text-transparent !important;
|
||||
}
|
||||
.bg-primary {
|
||||
@apply bg-gradient-to-r from-[color:var(--theme-purple)] to-[color:var(--theme-blue)] text-white;
|
||||
}
|
||||
.outline-theme-purple {
|
||||
@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)];
|
||||
}
|
||||
.outline-theme-blue {
|
||||
@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)];
|
||||
}
|
||||
.within-outline-theme-purple {
|
||||
@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)];
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--theme-purple: #5d52d9;
|
||||
--theme-blue: #4fc5eb;
|
||||
/* Keep the below variable names in sync with CSS_VARIABLES in globals-css.ts */
|
||||
--top-nav-bar-height: 3.5rem;
|
||||
--resume-control-bar-height: 3rem;
|
||||
--resume-padding: 1.5rem;
|
||||
}
|
||||
83
open-resume/src/app/home/AutoTypingResume.tsx
Normal file
83
open-resume/src/app/home/AutoTypingResume.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { ResumePDF } from "components/Resume/ResumePDF";
|
||||
import { initialResumeState } from "lib/redux/resumeSlice";
|
||||
import { initialSettings } from "lib/redux/settingsSlice";
|
||||
import { ResumeIframeCSR } from "components/Resume/ResumeIFrame";
|
||||
import { START_HOME_RESUME, END_HOME_RESUME } from "home/constants";
|
||||
import { makeObjectCharIterator } from "lib/make-object-char-iterator";
|
||||
import { useTailwindBreakpoints } from "lib/hooks/useTailwindBreakpoints";
|
||||
import { deepClone } from "lib/deep-clone";
|
||||
|
||||
// countObjectChar(END_HOME_RESUME) -> ~1800 chars
|
||||
const INTERVAL_MS = 50; // 20 Intervals Per Second
|
||||
const CHARS_PER_INTERVAL = 10;
|
||||
// Auto Typing Time:
|
||||
// 10 CHARS_PER_INTERVAL -> ~1800 / (20*10) = 9s (let's go with 9s so it feels fast)
|
||||
// 9 CHARS_PER_INTERVAL -> ~1800 / (20*9) = 10s
|
||||
// 8 CHARS_PER_INTERVAL -> ~1800 / (20*8) = 11s
|
||||
|
||||
const RESET_INTERVAL_MS = 60 * 1000; // 60s
|
||||
|
||||
export const AutoTypingResume = () => {
|
||||
const [resume, setResume] = useState(deepClone(initialResumeState));
|
||||
const resumeCharIterator = useRef(
|
||||
makeObjectCharIterator(START_HOME_RESUME, END_HOME_RESUME)
|
||||
);
|
||||
const hasSetEndResume = useRef(false);
|
||||
const { isLg } = useTailwindBreakpoints();
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
let next = resumeCharIterator.current.next();
|
||||
for (let i = 0; i < CHARS_PER_INTERVAL - 1; i++) {
|
||||
next = resumeCharIterator.current.next();
|
||||
}
|
||||
if (!next.done) {
|
||||
setResume(next.value);
|
||||
} else {
|
||||
// Sometimes the iterator doesn't end on the last char,
|
||||
// so we manually set its end state here
|
||||
if (!hasSetEndResume.current) {
|
||||
setResume(END_HOME_RESUME);
|
||||
hasSetEndResume.current = true;
|
||||
}
|
||||
}
|
||||
}, INTERVAL_MS);
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
resumeCharIterator.current = makeObjectCharIterator(
|
||||
START_HOME_RESUME,
|
||||
END_HOME_RESUME
|
||||
);
|
||||
hasSetEndResume.current = false;
|
||||
}, RESET_INTERVAL_MS);
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResumeIframeCSR documentSize="Letter" scale={isLg ? 0.7 : 0.5}>
|
||||
<ResumePDF
|
||||
resume={resume}
|
||||
settings={{
|
||||
...initialSettings,
|
||||
fontSize: "12",
|
||||
formToHeading: {
|
||||
workExperiences: resume.workExperiences[0].company
|
||||
? "WORK EXPERIENCE"
|
||||
: "",
|
||||
educations: resume.educations[0].school ? "EDUCATION" : "",
|
||||
projects: resume.projects[0].project ? "PROJECT" : "",
|
||||
skills: resume.skills.featuredSkills[0].skill ? "SKILLS" : "",
|
||||
custom: "CUSTOM SECTION",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ResumeIframeCSR>
|
||||
</>
|
||||
);
|
||||
};
|
||||
63
open-resume/src/app/home/Features.tsx
Normal file
63
open-resume/src/app/home/Features.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import Image from "next/image";
|
||||
import featureFreeSrc from "public/assets/feature-free.svg";
|
||||
import featureUSSrc from "public/assets/feature-us.svg";
|
||||
import featurePrivacySrc from "public/assets/feature-privacy.svg";
|
||||
import featureOpenSourceSrc from "public/assets/feature-open-source.svg";
|
||||
import { Link } from "components/documentation";
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
src: featureFreeSrc,
|
||||
title: "Free Forever",
|
||||
text: "OpenResume is created with the belief that everyone should have free and easy access to a modern professional resume design",
|
||||
},
|
||||
{
|
||||
src: featureUSSrc,
|
||||
title: "U.S. Best Practices",
|
||||
text: "OpenResume has built-in best practices for the U.S. job market and works well with top ATS platforms such as Greenhouse and Lever",
|
||||
},
|
||||
{
|
||||
src: featurePrivacySrc,
|
||||
title: "Privacy Focus",
|
||||
text: "OpenResume stores data locally in your browser so only you have access to your data and with complete control",
|
||||
},
|
||||
{
|
||||
src: featureOpenSourceSrc,
|
||||
title: "Open-Source",
|
||||
text: (
|
||||
<>
|
||||
OpenResume is an open-source project, and its source code can be viewed
|
||||
by anyone on its{" "}
|
||||
<Link href="https://github.com/xitanggg/open-resume">
|
||||
GitHub repository
|
||||
</Link>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export const Features = () => {
|
||||
return (
|
||||
<section className="py-16 lg:py-36">
|
||||
<div className="mx-auto lg:max-w-4xl">
|
||||
<dl className="grid grid-cols-1 justify-items-center gap-y-8 lg:grid-cols-2 lg:gap-x-6 lg:gap-y-16">
|
||||
{FEATURES.map(({ src, title, text }) => (
|
||||
<div className="px-2" key={title}>
|
||||
<div className="relative w-96 self-center pl-16">
|
||||
<dt className="text-2xl font-bold">
|
||||
<Image
|
||||
src={src}
|
||||
className="absolute left-0 top-1 h-12 w-12"
|
||||
alt="Feature icon"
|
||||
/>
|
||||
{title}
|
||||
</dt>
|
||||
<dd className="mt-2">{text}</dd>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
35
open-resume/src/app/home/Hero.tsx
Normal file
35
open-resume/src/app/home/Hero.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import Link from "next/link";
|
||||
import { FlexboxSpacer } from "components/FlexboxSpacer";
|
||||
import { AutoTypingResume } from "home/AutoTypingResume";
|
||||
|
||||
export const Hero = () => {
|
||||
return (
|
||||
<section className="lg:flex lg:h-[825px] lg:justify-center">
|
||||
<FlexboxSpacer maxWidth={75} minWidth={0} className="hidden lg:block" />
|
||||
<div className="mx-auto max-w-xl pt-8 text-center lg:mx-0 lg:grow lg:pt-32 lg:text-left">
|
||||
<h1 className="text-primary pb-2 text-4xl font-bold lg:text-5xl">
|
||||
Create a professional
|
||||
<br />
|
||||
resume easily
|
||||
</h1>
|
||||
<p className="mt-3 text-lg lg:mt-5 lg:text-xl">
|
||||
With this free, open-source, and powerful resume builder
|
||||
</p>
|
||||
<Link href="/resume-import" className="btn-primary mt-6 lg:mt-14">
|
||||
Create Resume <span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
<p className="ml-6 mt-3 text-sm text-gray-600">No sign up required</p>
|
||||
<p className="mt-3 text-sm text-gray-600 lg:mt-36">
|
||||
Already have a resume? Test its ATS readability with the{" "}
|
||||
<Link href="/resume-parser" className="underline underline-offset-2">
|
||||
resume parser
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<FlexboxSpacer maxWidth={100} minWidth={50} className="hidden lg:block" />
|
||||
<div className="mt-6 flex justify-center lg:mt-4 lg:block lg:grow">
|
||||
<AutoTypingResume />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
36
open-resume/src/app/home/LogoCloud.tsx
Normal file
36
open-resume/src/app/home/LogoCloud.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import logoCornellSrc from "public/assets/logo-cornell.svg";
|
||||
import logoColumbiaSrc from "public/assets/logo-columbia.svg";
|
||||
import logoNortheasternSrc from "public/assets/logo-northeastern.svg";
|
||||
import logoDropboxSrc from "public/assets/logo-dropbox.svg";
|
||||
import logoGoogleSrc from "public/assets/logo-google.svg";
|
||||
import logoAmazonSrc from "public/assets/logo-amazon.svg";
|
||||
import Image from "next/image";
|
||||
|
||||
const LOGOS = [
|
||||
{ src: logoCornellSrc, alt: "Cornell University logo" },
|
||||
{ src: logoColumbiaSrc, alt: "Columbia University logo" },
|
||||
{ src: logoNortheasternSrc, alt: "Northeastern University logo" },
|
||||
{ src: logoDropboxSrc, alt: "Dropbox logo" },
|
||||
{ src: logoGoogleSrc, alt: "Google logo" },
|
||||
{ src: logoAmazonSrc, alt: "Amazon logo" },
|
||||
];
|
||||
|
||||
// LogoCloud is disabled per issue: https://github.com/xitanggg/open-resume/issues/7
|
||||
export const LogoCloud = () => (
|
||||
<section className="mt-14 lg:mt-10">
|
||||
<h2 className="text-center font-semibold text-gray-500">
|
||||
Trusted by students and employees from top universities and companies
|
||||
worldwide
|
||||
</h2>
|
||||
<div className="mt-6 grid grid-cols-6 items-center justify-items-center gap-x-8 gap-y-10">
|
||||
{LOGOS.map(({ src, alt }, idx) => (
|
||||
<Image
|
||||
key={idx}
|
||||
className="col-span-3 h-full max-h-10 max-w-[130px] lg:col-span-1 lg:max-w-[160px]"
|
||||
src={src}
|
||||
alt={alt}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
141
open-resume/src/app/home/QuestionsAndAnswers.tsx
Normal file
141
open-resume/src/app/home/QuestionsAndAnswers.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Link } from "components/documentation";
|
||||
|
||||
const QAS = [
|
||||
{
|
||||
question:
|
||||
"Q1. What is a resume builder? Why resume builder is better than resume template doc?",
|
||||
answer: (
|
||||
<>
|
||||
<p>
|
||||
There are two ways to create a resume today. One option is to use a
|
||||
resume template, such as an office/google doc, and customize it
|
||||
according to your needs. The other option is to use a resume builder,
|
||||
an online tool that allows you to input your information and
|
||||
automatically generates a resume for you.
|
||||
</p>
|
||||
<p>
|
||||
Using a resume template requires manual formatting work, like copying
|
||||
and pasting text sections and adjusting spacing, which can be
|
||||
time-consuming and error-prone. It is easy to run into formatting
|
||||
issues, such as using different bullet points or font styles after
|
||||
copying and pasting. On the other hand, a resume builder like
|
||||
OpenResume saves time and prevents formatting mistakes by
|
||||
automatically formatting the resume. It also offers the convenience of
|
||||
easily changing font types or sizes with a simple click. In summary, a
|
||||
resume builder is easier to use compared to a resume template.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
question:
|
||||
"Q2. What uniquely sets OpenResume apart from other resume builders and templates?",
|
||||
answer: (
|
||||
<>
|
||||
<p>
|
||||
Other than OpenResume, there are some great free resume builders out
|
||||
there, e.g. <Link href="https://rxresu.me/">Reactive Resume</Link>,{" "}
|
||||
<Link href="https://flowcv.com/">FlowCV</Link>. However, OpenResume
|
||||
stands out with 2 distinctive features:
|
||||
</p>{" "}
|
||||
<p>
|
||||
<span className="font-semibold">
|
||||
1. OpenResume is designed specifically for the U.S. job market and
|
||||
best practices.
|
||||
</span>
|
||||
<br />
|
||||
Unlike other resume builders that target a global audience and offer
|
||||
many customization options, OpenResume intentionally only offers
|
||||
options that are aligned with U.S. best practices. For example, it
|
||||
excludes the option to add a profile picture to avoid bias and
|
||||
discrimination. It offers only the core sections, e.g. profile, work
|
||||
experience, education, and skills, while omitting unnecessary sections
|
||||
like references. Additionally, OpenResume only offers a top down
|
||||
single column resume design as opposed to two column design, because
|
||||
single column design works best for AST. <br />{" "}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-semibold">
|
||||
2. OpenResume is super privacy focus.
|
||||
</span>{" "}
|
||||
<br />
|
||||
While other resume builders may require email sign up and store user
|
||||
data in their databases, OpenResume believes that resume data should
|
||||
remain private and accessible only on user’s local machine. Therefore,
|
||||
OpenResume doesn’t require sign up to use the app, and all inputted
|
||||
data is stored in user’s browser that only user has access to.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: "Q3. Who created OpenResume and why?",
|
||||
answer: (
|
||||
<p>
|
||||
OpenResume was created by{" "}
|
||||
<Link href="https://github.com/xitanggg">Xitang Zhao</Link> and designed
|
||||
by <Link href="https://www.linkedin.com/in/imzhi">Zhigang Wen</Link> as
|
||||
a weekend project. As immigrants to the US, we had made many mistakes
|
||||
when creating our first resumes and applying for internships and jobs.
|
||||
It took us a long while to learn some of the best practices. While
|
||||
mentoring first generation students and reviewing their resumes, we
|
||||
noticed students were making the same mistakes that we had made before.
|
||||
This led us to think about how we can be of help with the knowledge and
|
||||
skills we have gained. We started chatting and working over the weekends
|
||||
that led to OpenResume, where we integrated best practices and our
|
||||
knowledge into this resume builder. Our hope is that OpenResume can help
|
||||
anyone to easily create a modern professional resume that follows best
|
||||
practices and enable anyone to apply for jobs with confidence.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: "Q4. How can I support OpenResume?",
|
||||
answer: (
|
||||
<>
|
||||
<p>
|
||||
The best way to support OpenResume is to share your thoughts and
|
||||
feedback with us to help further improve it. You can send us an email
|
||||
at{" "}
|
||||
<Link href="mailto:hello@open-resume.com">hello@open-resume.com</Link>{" "}
|
||||
or{" "}
|
||||
<Link href="https://github.com/xitanggg/open-resume/issues/new">
|
||||
open an issue
|
||||
</Link>{" "}
|
||||
at our Github repository. Whether you like it or not, we would love to
|
||||
hear from you.
|
||||
</p>
|
||||
<p>
|
||||
Another great way to support OpenResume is by spreading the words.
|
||||
Share it with your friends, on social media platforms, or with your
|
||||
school’s career center. Our goal is to reach more people who struggle
|
||||
with creating their resume, and your word-of-mouth support would be
|
||||
greatly appreciated. If you use Github, you can also show your support
|
||||
by{" "}
|
||||
<Link href="https://github.com/xitanggg/open-resume">
|
||||
giving the project a star
|
||||
</Link>{" "}
|
||||
to help increase its popularity and reach.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export const QuestionsAndAnswers = () => {
|
||||
return (
|
||||
<section className="mx-auto max-w-3xl divide-y divide-gray-300 lg:mt-4 lg:px-2">
|
||||
<h2 className="text-center text-3xl font-bold">Questions & Answers</h2>
|
||||
<div className="mt-6 divide-y divide-gray-300">
|
||||
{QAS.map(({ question, answer }) => (
|
||||
<div key={question} className="py-6">
|
||||
<h3 className="font-semibold leading-7">{question}</h3>
|
||||
<div className="mt-3 grid gap-2 leading-7 text-gray-600">
|
||||
{answer}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
32
open-resume/src/app/home/Steps.tsx
Normal file
32
open-resume/src/app/home/Steps.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
const STEPS = [
|
||||
{ title: "Add a resume pdf", text: "or create from scratch" },
|
||||
{ title: "Preview design", text: "and make edits" },
|
||||
{ title: "Download new resume", text: "and apply with confidence" },
|
||||
];
|
||||
|
||||
export const Steps = () => {
|
||||
return (
|
||||
<section className="mx-auto mt-8 rounded-2xl bg-sky-50 bg-dot px-8 pb-12 pt-10 lg:mt-2">
|
||||
<h1 className="text-center text-3xl font-bold">3 Simple Steps</h1>
|
||||
<div className="mt-8 flex justify-center">
|
||||
<dl className="flex flex-col gap-y-10 lg:flex-row lg:justify-center lg:gap-x-20">
|
||||
{STEPS.map(({ title, text }, idx) => (
|
||||
<div className="relative self-start pl-14" key={idx}>
|
||||
<dt className="text-lg font-bold">
|
||||
<div className="bg-primary absolute left-0 top-1 flex h-10 w-10 select-none items-center justify-center rounded-full p-[3.5px] opacity-80">
|
||||
<div className="flex h-full w-full items-center justify-center rounded-full bg-white">
|
||||
<div className="text-primary -mt-0.5 text-2xl">
|
||||
{idx + 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{title}
|
||||
</dt>
|
||||
<dd>{text}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
136
open-resume/src/app/home/Testimonials.tsx
Normal file
136
open-resume/src/app/home/Testimonials.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
import heartSrc from "public/assets/heart.svg";
|
||||
import testimonialSpiegelSrc from "public/assets/testimonial-spiegel.jpg";
|
||||
import testimonialSantiSrc from "public/assets/testimonial-santi.jpg";
|
||||
import testimonialVivianSrc from "public/assets/testimonial-vivian.jpg";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useTailwindBreakpoints } from "lib/hooks/useTailwindBreakpoints";
|
||||
|
||||
const TESTIMONIALS = [
|
||||
{
|
||||
src: testimonialSpiegelSrc,
|
||||
quote:
|
||||
"Students often make silly mistakes on their resume by using inconsistent bullet points or font sizes. OpenResume’s auto format feature is a great help to ensure consistent format.",
|
||||
name: "Ms. Spiegel",
|
||||
title: "Educator",
|
||||
},
|
||||
{
|
||||
src: testimonialSantiSrc,
|
||||
quote:
|
||||
"I used OpenResume during my last job search and was invited to interview at top tech companies such as Google and Amazon thanks to its slick yet professional resume design.",
|
||||
name: "Santi",
|
||||
title: "Software Engineer",
|
||||
},
|
||||
{
|
||||
src: testimonialVivianSrc,
|
||||
quote:
|
||||
"Creating a professional resume on OpenResume is so smooth and easy! It saves me so much time and headache to not deal with google doc template.",
|
||||
name: "Vivian",
|
||||
title: "College Student",
|
||||
},
|
||||
];
|
||||
|
||||
const LG_TESTIMONIALS_CLASSNAMES = [
|
||||
"z-10",
|
||||
"translate-x-44 translate-y-24 opacity-40",
|
||||
"translate-x-32 -translate-y-28 opacity-40",
|
||||
];
|
||||
const SM_TESTIMONIALS_CLASSNAMES = ["z-10", "opacity-0", "opacity-0"];
|
||||
const ROTATION_INTERVAL_MS = 8 * 1000; // 8s
|
||||
|
||||
export const Testimonials = ({ children }: { children?: React.ReactNode }) => {
|
||||
const [testimonialsClassNames, setTestimonialsClassNames] = useState(
|
||||
LG_TESTIMONIALS_CLASSNAMES
|
||||
);
|
||||
const isHoveredOnTestimonial = useRef(false);
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
if (!isHoveredOnTestimonial.current) {
|
||||
setTestimonialsClassNames((preClassNames) => {
|
||||
return [preClassNames[1], preClassNames[2], preClassNames[0]];
|
||||
});
|
||||
}
|
||||
}, ROTATION_INTERVAL_MS);
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
const { isLg } = useTailwindBreakpoints();
|
||||
useEffect(() => {
|
||||
setTestimonialsClassNames(
|
||||
isLg ? LG_TESTIMONIALS_CLASSNAMES : SM_TESTIMONIALS_CLASSNAMES
|
||||
);
|
||||
}, [isLg]);
|
||||
|
||||
return (
|
||||
<section className="mx-auto -mt-2 px-8 pb-24">
|
||||
<h2 className="mb-8 text-center text-3xl font-bold">
|
||||
People{" "}
|
||||
<Image src={heartSrc} alt="love" className="-mt-1 inline-block w-7" />{" "}
|
||||
OpenResume
|
||||
</h2>
|
||||
<div className="mx-auto mt-10 h-[235px] max-w-lg lg:h-[400px] lg:pt-28">
|
||||
<div className="relative lg:ml-[-50px]">
|
||||
{TESTIMONIALS.map(({ src, quote, name, title }, idx) => {
|
||||
const className = testimonialsClassNames[idx];
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`bg-primary absolute max-w-lg rounded-[1.7rem] bg-opacity-30 shadow-md transition-all duration-1000 ease-linear ${className}`}
|
||||
onMouseEnter={() => {
|
||||
if (className === "z-10") {
|
||||
isHoveredOnTestimonial.current = true;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (className === "z-10") {
|
||||
isHoveredOnTestimonial.current = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<figure className="m-1 flex gap-5 rounded-3xl bg-white p-5 text-gray-900 lg:p-7">
|
||||
<Image
|
||||
className="hidden h-24 w-24 select-none rounded-full lg:block"
|
||||
src={src}
|
||||
alt="profile"
|
||||
/>
|
||||
<div>
|
||||
<blockquote>
|
||||
<p className="before:content-['“'] after:content-['”']">
|
||||
{quote}
|
||||
</p>
|
||||
</blockquote>
|
||||
<figcaption className="mt-3">
|
||||
<div className="hidden gap-2 lg:flex">
|
||||
<div className="font-semibold">{name}</div>
|
||||
<div
|
||||
className="select-none text-gray-700"
|
||||
aria-hidden="true"
|
||||
>
|
||||
•
|
||||
</div>
|
||||
<div className="text-gray-600">{title}</div>
|
||||
</div>
|
||||
<div className="flex gap-4 lg:hidden">
|
||||
<Image
|
||||
className=" block h-12 w-12 select-none rounded-full"
|
||||
src={src}
|
||||
alt="profile"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-semibold">{name}</div>
|
||||
<div className="text-gray-600">{title}</div>
|
||||
</div>
|
||||
</div>
|
||||
</figcaption>
|
||||
</div>
|
||||
</figure>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
109
open-resume/src/app/home/constants.ts
Normal file
109
open-resume/src/app/home/constants.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
initialEducation,
|
||||
initialProfile,
|
||||
initialProject,
|
||||
initialWorkExperience,
|
||||
} from "lib/redux/resumeSlice";
|
||||
import type { Resume } from "lib/redux/types";
|
||||
import { deepClone } from "lib/deep-clone";
|
||||
|
||||
export const END_HOME_RESUME: Resume = {
|
||||
profile: {
|
||||
name: "John Doe",
|
||||
summary:
|
||||
"Software engineer obsessed with building exceptional products that people love",
|
||||
email: "hello@openresume.com",
|
||||
phone: "123-456-7890",
|
||||
location: "NYC, NY",
|
||||
url: "linkedin.com/in/john-doe",
|
||||
},
|
||||
workExperiences: [
|
||||
{
|
||||
company: "ABC Company",
|
||||
jobTitle: "Software Engineer",
|
||||
date: "May 2023 - Present",
|
||||
descriptions: [
|
||||
"Lead a cross-functional team of 5 engineers in developing a search bar, which enables thousands of daily active users to search content across the entire platform",
|
||||
"Create stunning home page product demo animations that drives up sign up rate by 20%",
|
||||
"Write clean code that is modular and easy to maintain while ensuring 100% test coverage",
|
||||
],
|
||||
},
|
||||
{
|
||||
company: "DEF Organization",
|
||||
jobTitle: "Software Engineer Intern",
|
||||
date: "Summer 2022",
|
||||
descriptions: [
|
||||
"Re-architected the existing content editor to be mobile responsive that led to a 10% increase in mobile user engagement",
|
||||
"Created a progress bar to help users track progress that drove up user retention by 15%",
|
||||
"Discovered and fixed 5 bugs in the existing codebase to enhance user experience",
|
||||
],
|
||||
},
|
||||
{
|
||||
company: "XYZ University",
|
||||
jobTitle: "Research Assistant",
|
||||
date: "Summer 2021",
|
||||
descriptions: [
|
||||
"Devised a new NLP algorithm in text classification that results in 10% accuracy increase",
|
||||
"Compiled and presented research findings to a group of 20+ faculty and students",
|
||||
],
|
||||
},
|
||||
],
|
||||
educations: [
|
||||
{
|
||||
school: "XYZ University",
|
||||
degree: "Bachelor of Science in Computer Science",
|
||||
date: "Sep 2019 - May 2023",
|
||||
gpa: "3.8",
|
||||
descriptions: [
|
||||
"Won 1st place in 2022 Education Hackathon, 2nd place in 2023 Health Tech Competition",
|
||||
"Teaching Assistant for Programming for the Web (2022 - 2023)",
|
||||
"Coursework: Object-Oriented Programming (A+), Programming for the Web (A+), Cloud Computing (A), Introduction to Machine Learning (A-), Algorithms Analysis (A-)",
|
||||
],
|
||||
},
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
project: "OpenResume",
|
||||
date: "Spring 2023",
|
||||
descriptions: [
|
||||
"Created and launched a free resume builder web app that allows thousands of users to create professional resume easily and land their dream jobs",
|
||||
],
|
||||
},
|
||||
],
|
||||
skills: {
|
||||
featuredSkills: [
|
||||
{ skill: "HTML", rating: 4 },
|
||||
{ skill: "CSS", rating: 4 },
|
||||
{ skill: "Python", rating: 3 },
|
||||
{ skill: "TypeScript", rating: 3 },
|
||||
{ skill: "React", rating: 3 },
|
||||
{ skill: "C++", rating: 2 },
|
||||
],
|
||||
descriptions: [
|
||||
"Tech: React Hooks, GraphQL, Node.js, SQL, Postgres, NoSql, Redis, REST API, Git",
|
||||
"Soft: Teamwork, Creative Problem Solving, Communication, Learning Mindset, Agile",
|
||||
],
|
||||
},
|
||||
custom: {
|
||||
descriptions: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const START_HOME_RESUME: Resume = {
|
||||
profile: deepClone(initialProfile),
|
||||
workExperiences: END_HOME_RESUME.workExperiences.map(() =>
|
||||
deepClone(initialWorkExperience)
|
||||
),
|
||||
educations: [deepClone(initialEducation)],
|
||||
projects: [deepClone(initialProject)],
|
||||
skills: {
|
||||
featuredSkills: END_HOME_RESUME.skills.featuredSkills.map((item) => ({
|
||||
skill: "",
|
||||
rating: item.rating,
|
||||
})),
|
||||
descriptions: [],
|
||||
},
|
||||
custom: {
|
||||
descriptions: [],
|
||||
},
|
||||
};
|
||||
25
open-resume/src/app/layout.tsx
Normal file
25
open-resume/src/app/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import "globals.css";
|
||||
import { TopNavBar } from "components/TopNavBar";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
|
||||
export const metadata = {
|
||||
title: "OpenResume - Free Open-source Resume Builder and Parser",
|
||||
description:
|
||||
"OpenResume is a free, open-source, and powerful resume builder that allows anyone to create a modern professional resume in 3 simple steps. For those who have an existing resume, OpenResume also provides a resume parser to help test and confirm its ATS readability.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<TopNavBar />
|
||||
{children}
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
7
open-resume/src/app/lib/__tests__/cx.test.ts
Normal file
7
open-resume/src/app/lib/__tests__/cx.test.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { cx } from "lib/cx";
|
||||
|
||||
test("cx", () => {
|
||||
expect(cx("px-1", "mt-2")).toEqual("px-1 mt-2");
|
||||
expect(cx("px-1", true && "mt-2")).toEqual("px-1 mt-2");
|
||||
expect(cx("px-1", false && "mt-2")).toEqual("px-1");
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { makeObjectCharIterator } from "lib/make-object-char-iterator";
|
||||
|
||||
test("Simple object", () => {
|
||||
const start = { a: "" };
|
||||
const end = { a: "abc" };
|
||||
const iterator = makeObjectCharIterator(start, end);
|
||||
expect(iterator.next().value).toEqual({ a: "a" });
|
||||
expect(iterator.next().value).toEqual({ a: "ab" });
|
||||
expect(iterator.next().value).toEqual({ a: "abc" });
|
||||
expect(iterator.next().value).toEqual(undefined);
|
||||
});
|
||||
|
||||
test("Nested object", () => {
|
||||
const start = { a: { b: "" } };
|
||||
const end = { a: { b: "abc" } };
|
||||
const iterator = makeObjectCharIterator(start, end);
|
||||
expect(iterator.next().value).toEqual({ a: { b: "a" } });
|
||||
expect(iterator.next().value).toEqual({ a: { b: "ab" } });
|
||||
expect(iterator.next().value).toEqual({ a: { b: "abc" } });
|
||||
expect(iterator.next().value).toEqual(undefined);
|
||||
});
|
||||
16
open-resume/src/app/lib/constants.ts
Normal file
16
open-resume/src/app/lib/constants.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const PX_PER_PT = 4 / 3;
|
||||
|
||||
// Reference: https://www.prepressure.com/library/paper-size/letter
|
||||
// Letter size is commonly used in US & Canada, while A4 is the standard for rest of world.
|
||||
export const LETTER_WIDTH_PT = 612;
|
||||
const LETTER_HEIGHT_PT = 792;
|
||||
export const LETTER_WIDTH_PX = LETTER_WIDTH_PT * PX_PER_PT;
|
||||
export const LETTER_HEIGHT_PX = LETTER_HEIGHT_PT * PX_PER_PT;
|
||||
|
||||
// Reference: https://www.prepressure.com/library/paper-size/din-a4
|
||||
export const A4_WIDTH_PT = 595;
|
||||
const A4_HEIGHT_PT = 842;
|
||||
export const A4_WIDTH_PX = A4_WIDTH_PT * PX_PER_PT;
|
||||
export const A4_HEIGHT_PX = A4_HEIGHT_PT * PX_PER_PT;
|
||||
|
||||
export const DEBUG_RESUME_PDF_FLAG: true | undefined = undefined; // use undefined to disable to deal with a weird error message
|
||||
18
open-resume/src/app/lib/cx.ts
Normal file
18
open-resume/src/app/lib/cx.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* cx is a simple util to join classNames together. Think of it as a simplified version of the open source classnames util
|
||||
* Reference: https://dev.to/gugaguichard/replace-clsx-classnames-or-classcat-with-your-own-little-helper-3bf
|
||||
*
|
||||
* @example
|
||||
* cx('px-1', 'mt-2'); // => 'px-1 mt-2'
|
||||
* cx('px-1', true && 'mt-2'); // => 'px-1 mt-2'
|
||||
* cx('px-1', false && 'mt-2'); // => 'px-1'
|
||||
*/
|
||||
export const cx = (...classes: Array<string | boolean | undefined>) => {
|
||||
const newClasses = [];
|
||||
for (const c of classes) {
|
||||
if (typeof c === "string") {
|
||||
newClasses.push(c.trim());
|
||||
}
|
||||
}
|
||||
return newClasses.join(" ");
|
||||
};
|
||||
8
open-resume/src/app/lib/deep-clone.ts
Normal file
8
open-resume/src/app/lib/deep-clone.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Server side object deep clone util using JSON serialization.
|
||||
* Not efficient for large objects but good enough for most use cases.
|
||||
*
|
||||
* Client side can simply use structuredClone.
|
||||
*/
|
||||
export const deepClone = <T extends { [key: string]: any }>(object: T) =>
|
||||
JSON.parse(JSON.stringify(object)) as T;
|
||||
28
open-resume/src/app/lib/deep-merge.ts
Normal file
28
open-resume/src/app/lib/deep-merge.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
type Object = { [key: string]: any };
|
||||
|
||||
const isObject = (item: any): item is Object => {
|
||||
return item && typeof item === "object" && !Array.isArray(item);
|
||||
};
|
||||
|
||||
/**
|
||||
* Deep merge two objects by overriding target with fields in source.
|
||||
* It returns a new object and doesn't modify any object in place since
|
||||
* it deep clones the target object first.
|
||||
*/
|
||||
export const deepMerge = (target: Object, source: Object, level = 0) => {
|
||||
const copyTarget = level === 0 ? structuredClone(target) : target;
|
||||
for (const key in source) {
|
||||
const sourceValue = source[key];
|
||||
// Assign source value to copyTarget if source value is not an object.
|
||||
// Otherwise, call deepMerge recursively to merge all its keys
|
||||
if (!isObject(sourceValue)) {
|
||||
copyTarget[key] = sourceValue;
|
||||
} else {
|
||||
if (!isObject(copyTarget[key])) {
|
||||
copyTarget[key] = {};
|
||||
}
|
||||
deepMerge(copyTarget[key], sourceValue, level + 1);
|
||||
}
|
||||
}
|
||||
return copyTarget;
|
||||
};
|
||||
6
open-resume/src/app/lib/get-px-per-rem.ts
Normal file
6
open-resume/src/app/lib/get-px-per-rem.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const getPxPerRem = () => {
|
||||
const bodyComputedStyle = getComputedStyle(
|
||||
document.querySelector("body")!
|
||||
) as any;
|
||||
return parseFloat(bodyComputedStyle["font-size"]) || 16;
|
||||
};
|
||||
36
open-resume/src/app/lib/hooks/useAutosizeTextareaHeight.tsx
Normal file
36
open-resume/src/app/lib/hooks/useAutosizeTextareaHeight.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Hook to autosize textarea height.
|
||||
*
|
||||
* The trick to resize is to first set its height to 0 and then set it back to scroll height.
|
||||
* Reference: https://stackoverflow.com/a/25621277/7699841
|
||||
*
|
||||
* @example // Tailwind CSS
|
||||
* const textareaRef = useAutosizeTextareaHeight({ value });
|
||||
* <textarea ref={textareaRef} className="resize-none overflow-hidden"/>
|
||||
*/
|
||||
export const useAutosizeTextareaHeight = ({ value }: { value: string }) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const resizeHeight = () => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = "0px";
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
}
|
||||
};
|
||||
|
||||
// Resize height when value changes
|
||||
useEffect(() => {
|
||||
resizeHeight();
|
||||
}, [value]);
|
||||
|
||||
// Resize height when viewport resizes
|
||||
useEffect(() => {
|
||||
window.addEventListener("resize", resizeHeight);
|
||||
return () => window.removeEventListener("resize", resizeHeight);
|
||||
}, []);
|
||||
|
||||
return textareaRef;
|
||||
};
|
||||
33
open-resume/src/app/lib/hooks/useTailwindBreakpoints.tsx
Normal file
33
open-resume/src/app/lib/hooks/useTailwindBreakpoints.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const enum TailwindBreakpoint {
|
||||
sm = 640,
|
||||
md = 768,
|
||||
lg = 1024,
|
||||
xl = 1280,
|
||||
"2xl" = 1536,
|
||||
}
|
||||
|
||||
export const useTailwindBreakpoints = () => {
|
||||
const [isSm, setIsSm] = useState(false);
|
||||
const [isMd, setIsMd] = useState(false);
|
||||
const [isLg, setIsLg] = useState(false);
|
||||
const [isXl, setIsXl] = useState(false);
|
||||
const [is2xl, setIs2xl] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const screenWidth = window.innerWidth;
|
||||
setIsSm(screenWidth >= TailwindBreakpoint.sm);
|
||||
setIsMd(screenWidth >= TailwindBreakpoint.md);
|
||||
setIsLg(screenWidth >= TailwindBreakpoint.lg);
|
||||
setIsXl(screenWidth >= TailwindBreakpoint.xl);
|
||||
setIs2xl(screenWidth >= TailwindBreakpoint["2xl"]);
|
||||
};
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
return { isSm, isMd, isLg, isXl, is2xl };
|
||||
};
|
||||
60
open-resume/src/app/lib/make-object-char-iterator.ts
Normal file
60
open-resume/src/app/lib/make-object-char-iterator.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { deepClone } from "lib/deep-clone";
|
||||
|
||||
type Object = { [key: string]: any };
|
||||
|
||||
/**
|
||||
* makeObjectCharIterator is a generator function that iterates a start object to
|
||||
* match an end object state by iterating through each string character.
|
||||
*
|
||||
* Note: Start object and end object must have the same structure and same keys.
|
||||
* And they must have string or array or object as values.
|
||||
*
|
||||
* @example
|
||||
* const start = {a : ""}
|
||||
* const end = {a : "abc"};
|
||||
* const iterator = makeObjectCharIterator(start, end);
|
||||
* iterator.next().value // {a : "a"}
|
||||
* iterator.next().value // {a : "ab"}
|
||||
* iterator.next().value // {a : "abc"}
|
||||
*/
|
||||
export function* makeObjectCharIterator<T extends Object>(
|
||||
start: T,
|
||||
end: T,
|
||||
level = 0
|
||||
) {
|
||||
// Have to manually cast Object type and return T type due to https://github.com/microsoft/TypeScript/issues/47357
|
||||
const object: Object = level === 0 ? deepClone(start) : start;
|
||||
for (const [key, endValue] of Object.entries(end)) {
|
||||
if (typeof endValue === "object") {
|
||||
const recursiveIterator = makeObjectCharIterator(
|
||||
object[key],
|
||||
endValue,
|
||||
level + 1
|
||||
);
|
||||
while (true) {
|
||||
const next = recursiveIterator.next();
|
||||
if (next.done) {
|
||||
break;
|
||||
}
|
||||
yield deepClone(object) as T;
|
||||
}
|
||||
} else {
|
||||
for (let i = 1; i <= endValue.length; i++) {
|
||||
object[key] = endValue.slice(0, i);
|
||||
yield deepClone(object) as T;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const countObjectChar = (object: Object) => {
|
||||
let count = 0;
|
||||
for (const value of Object.values(object)) {
|
||||
if (typeof value === "object") {
|
||||
count += countObjectChar(value);
|
||||
} else if (typeof value === "string") {
|
||||
count += value.length;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
TextItem,
|
||||
FeatureSet,
|
||||
ResumeSectionToLines,
|
||||
} from "lib/parse-resume-from-pdf/types";
|
||||
import type { ResumeEducation } from "lib/redux/types";
|
||||
import { getSectionLinesByKeywords } from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/get-section-lines";
|
||||
import { divideSectionIntoSubsections } from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/subsections";
|
||||
import {
|
||||
DATE_FEATURE_SETS,
|
||||
hasComma,
|
||||
hasLetter,
|
||||
hasNumber,
|
||||
} from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/common-features";
|
||||
import { getTextWithHighestFeatureScore } from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/feature-scoring-system";
|
||||
import {
|
||||
getBulletPointsFromLines,
|
||||
getDescriptionsLineIdx,
|
||||
} from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/bullet-points";
|
||||
|
||||
/**
|
||||
* Unique Attribute
|
||||
* School Has school
|
||||
* Degree Has degree
|
||||
* GPA Has number
|
||||
*/
|
||||
|
||||
// prettier-ignore
|
||||
const SCHOOLS = ['College', 'University', 'Institute', 'School', 'Academy', 'BASIS', 'Magnet']
|
||||
const hasSchool = (item: TextItem) =>
|
||||
SCHOOLS.some((school) => item.text.includes(school));
|
||||
// prettier-ignore
|
||||
const DEGREES = ["Associate", "Bachelor", "Master", "PhD", "Ph."];
|
||||
const hasDegree = (item: TextItem) =>
|
||||
DEGREES.some((degree) => item.text.includes(degree)) ||
|
||||
/[ABM][A-Z\.]/.test(item.text); // Match AA, B.S., MBA, etc.
|
||||
const matchGPA = (item: TextItem) => item.text.match(/[0-4]\.\d{1,2}/);
|
||||
const matchGrade = (item: TextItem) => {
|
||||
const grade = parseFloat(item.text);
|
||||
if (Number.isFinite(grade) && grade <= 110) {
|
||||
return [String(grade)] as RegExpMatchArray;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const SCHOOL_FEATURE_SETS: FeatureSet[] = [
|
||||
[hasSchool, 4],
|
||||
[hasDegree, -4],
|
||||
[hasNumber, -4],
|
||||
];
|
||||
|
||||
const DEGREE_FEATURE_SETS: FeatureSet[] = [
|
||||
[hasDegree, 4],
|
||||
[hasSchool, -4],
|
||||
[hasNumber, -3],
|
||||
];
|
||||
|
||||
const GPA_FEATURE_SETS: FeatureSet[] = [
|
||||
[matchGPA, 4, true],
|
||||
[matchGrade, 3, true],
|
||||
[hasComma, -3],
|
||||
[hasLetter, -4],
|
||||
];
|
||||
|
||||
export const extractEducation = (sections: ResumeSectionToLines) => {
|
||||
const educations: ResumeEducation[] = [];
|
||||
const educationsScores = [];
|
||||
const lines = getSectionLinesByKeywords(sections, ["education"]);
|
||||
const subsections = divideSectionIntoSubsections(lines);
|
||||
for (const subsectionLines of subsections) {
|
||||
const textItems = subsectionLines.flat();
|
||||
const [school, schoolScores] = getTextWithHighestFeatureScore(
|
||||
textItems,
|
||||
SCHOOL_FEATURE_SETS
|
||||
);
|
||||
const [degree, degreeScores] = getTextWithHighestFeatureScore(
|
||||
textItems,
|
||||
DEGREE_FEATURE_SETS
|
||||
);
|
||||
const [gpa, gpaScores] = getTextWithHighestFeatureScore(
|
||||
textItems,
|
||||
GPA_FEATURE_SETS
|
||||
);
|
||||
const [date, dateScores] = getTextWithHighestFeatureScore(
|
||||
textItems,
|
||||
DATE_FEATURE_SETS
|
||||
);
|
||||
|
||||
let descriptions: string[] = [];
|
||||
const descriptionsLineIdx = getDescriptionsLineIdx(subsectionLines);
|
||||
if (descriptionsLineIdx !== undefined) {
|
||||
const descriptionsLines = subsectionLines.slice(descriptionsLineIdx);
|
||||
descriptions = getBulletPointsFromLines(descriptionsLines);
|
||||
}
|
||||
|
||||
educations.push({ school, degree, gpa, date, descriptions });
|
||||
educationsScores.push({
|
||||
schoolScores,
|
||||
degreeScores,
|
||||
gpaScores,
|
||||
dateScores,
|
||||
});
|
||||
}
|
||||
|
||||
if (educations.length !== 0) {
|
||||
const coursesLines = getSectionLinesByKeywords(sections, ["course"]);
|
||||
if (coursesLines.length !== 0) {
|
||||
educations[0].descriptions.push(
|
||||
"Courses: " +
|
||||
coursesLines
|
||||
.flat()
|
||||
.map((item) => item.text)
|
||||
.join(" ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
educations,
|
||||
educationsScores,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
import type {
|
||||
ResumeSectionToLines,
|
||||
TextItem,
|
||||
FeatureSet,
|
||||
} from "lib/parse-resume-from-pdf/types";
|
||||
import { getSectionLinesByKeywords } from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/get-section-lines";
|
||||
import {
|
||||
isBold,
|
||||
hasNumber,
|
||||
hasComma,
|
||||
hasLetter,
|
||||
hasLetterAndIsAllUpperCase,
|
||||
} from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/common-features";
|
||||
import { getTextWithHighestFeatureScore } from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/feature-scoring-system";
|
||||
|
||||
// Name
|
||||
export const matchOnlyLetterSpaceOrPeriod = (item: TextItem) =>
|
||||
item.text.match(/^[a-zA-Z\s\.]+$/);
|
||||
|
||||
// Email
|
||||
// Simple email regex: xxx@xxx.xxx (xxx = anything not space)
|
||||
export const matchEmail = (item: TextItem) => item.text.match(/\S+@\S+\.\S+/);
|
||||
const hasAt = (item: TextItem) => item.text.includes("@");
|
||||
|
||||
// Phone
|
||||
// Simple phone regex that matches (xxx)-xxx-xxxx where () and - are optional, - can also be space
|
||||
export const matchPhone = (item: TextItem) =>
|
||||
item.text.match(/\(?\d{3}\)?[\s-]?\d{3}[\s-]?\d{4}/);
|
||||
const hasParenthesis = (item: TextItem) => /\([0-9]+\)/.test(item.text);
|
||||
|
||||
// Location
|
||||
// Simple location regex that matches "<City>, <ST>"
|
||||
export const matchCityAndState = (item: TextItem) =>
|
||||
item.text.match(/[A-Z][a-zA-Z\s]+, [A-Z]{2}/);
|
||||
|
||||
// Url
|
||||
// Simple url regex that matches "xxx.xxx/xxx" (xxx = anything not space)
|
||||
export const matchUrl = (item: TextItem) => item.text.match(/\S+\.[a-z]+\/\S+/);
|
||||
// Match https://xxx.xxx where s is optional
|
||||
const matchUrlHttpFallback = (item: TextItem) =>
|
||||
item.text.match(/https?:\/\/\S+\.\S+/);
|
||||
// Match www.xxx.xxx
|
||||
const matchUrlWwwFallback = (item: TextItem) =>
|
||||
item.text.match(/www\.\S+\.\S+/);
|
||||
const hasSlash = (item: TextItem) => item.text.includes("/");
|
||||
|
||||
// Summary
|
||||
const has4OrMoreWords = (item: TextItem) => item.text.split(" ").length >= 4;
|
||||
|
||||
/**
|
||||
* Unique Attribute
|
||||
* Name Bold or Has all uppercase letter
|
||||
* Email Has @
|
||||
* Phone Has ()
|
||||
* Location Has , (overlap with summary)
|
||||
* Url Has slash
|
||||
* Summary Has 4 or more words
|
||||
*/
|
||||
|
||||
/**
|
||||
* Name -> contains only letters/space/period, e.g. Leonardo W. DiCaprio
|
||||
* (it isn't common to include middle initial in resume)
|
||||
* -> is bolded or has all letters as uppercase
|
||||
*/
|
||||
const NAME_FEATURE_SETS: FeatureSet[] = [
|
||||
[matchOnlyLetterSpaceOrPeriod, 3, true],
|
||||
[isBold, 2],
|
||||
[hasLetterAndIsAllUpperCase, 2],
|
||||
// Match against other unique attributes
|
||||
[hasAt, -4], // Email
|
||||
[hasNumber, -4], // Phone
|
||||
[hasParenthesis, -4], // Phone
|
||||
[hasComma, -4], // Location
|
||||
[hasSlash, -4], // Url
|
||||
[has4OrMoreWords, -2], // Summary
|
||||
];
|
||||
|
||||
// Email -> match email regex xxx@xxx.xxx
|
||||
const EMAIL_FEATURE_SETS: FeatureSet[] = [
|
||||
[matchEmail, 4, true],
|
||||
[isBold, -1], // Name
|
||||
[hasLetterAndIsAllUpperCase, -1], // Name
|
||||
[hasParenthesis, -4], // Phone
|
||||
[hasComma, -4], // Location
|
||||
[hasSlash, -4], // Url
|
||||
[has4OrMoreWords, -4], // Summary
|
||||
];
|
||||
|
||||
// Phone -> match phone regex (xxx)-xxx-xxxx
|
||||
const PHONE_FEATURE_SETS: FeatureSet[] = [
|
||||
[matchPhone, 4, true],
|
||||
[hasLetter, -4], // Name, Email, Location, Url, Summary
|
||||
];
|
||||
|
||||
// Location -> match location regex <City>, <ST>
|
||||
const LOCATION_FEATURE_SETS: FeatureSet[] = [
|
||||
[matchCityAndState, 4, true],
|
||||
[isBold, -1], // Name
|
||||
[hasAt, -4], // Email
|
||||
[hasParenthesis, -3], // Phone
|
||||
[hasSlash, -4], // Url
|
||||
];
|
||||
|
||||
// URL -> match url regex xxx.xxx/xxx
|
||||
const URL_FEATURE_SETS: FeatureSet[] = [
|
||||
[matchUrl, 4, true],
|
||||
[matchUrlHttpFallback, 3, true],
|
||||
[matchUrlWwwFallback, 3, true],
|
||||
[isBold, -1], // Name
|
||||
[hasAt, -4], // Email
|
||||
[hasParenthesis, -3], // Phone
|
||||
[hasComma, -4], // Location
|
||||
[has4OrMoreWords, -4], // Summary
|
||||
];
|
||||
|
||||
// Summary -> has 4 or more words
|
||||
const SUMMARY_FEATURE_SETS: FeatureSet[] = [
|
||||
[has4OrMoreWords, 4],
|
||||
[isBold, -1], // Name
|
||||
[hasAt, -4], // Email
|
||||
[hasParenthesis, -3], // Phone
|
||||
[matchCityAndState, -4, false], // Location
|
||||
];
|
||||
|
||||
export const extractProfile = (sections: ResumeSectionToLines) => {
|
||||
const lines = sections.profile || [];
|
||||
const textItems = lines.flat();
|
||||
|
||||
const [name, nameScores] = getTextWithHighestFeatureScore(
|
||||
textItems,
|
||||
NAME_FEATURE_SETS
|
||||
);
|
||||
const [email, emailScores] = getTextWithHighestFeatureScore(
|
||||
textItems,
|
||||
EMAIL_FEATURE_SETS
|
||||
);
|
||||
const [phone, phoneScores] = getTextWithHighestFeatureScore(
|
||||
textItems,
|
||||
PHONE_FEATURE_SETS
|
||||
);
|
||||
const [location, locationScores] = getTextWithHighestFeatureScore(
|
||||
textItems,
|
||||
LOCATION_FEATURE_SETS
|
||||
);
|
||||
const [url, urlScores] = getTextWithHighestFeatureScore(
|
||||
textItems,
|
||||
URL_FEATURE_SETS
|
||||
);
|
||||
const [summary, summaryScores] = getTextWithHighestFeatureScore(
|
||||
textItems,
|
||||
SUMMARY_FEATURE_SETS,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
|
||||
const summaryLines = getSectionLinesByKeywords(sections, ["summary"]);
|
||||
const summarySection = summaryLines
|
||||
.flat()
|
||||
.map((textItem) => textItem.text)
|
||||
.join(" ");
|
||||
const objectiveLines = getSectionLinesByKeywords(sections, ["objective"]);
|
||||
const objectiveSection = objectiveLines
|
||||
.flat()
|
||||
.map((textItem) => textItem.text)
|
||||
.join(" ");
|
||||
|
||||
return {
|
||||
profile: {
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
location,
|
||||
url,
|
||||
// Dedicated section takes higher precedence over profile summary
|
||||
summary: summarySection || objectiveSection || summary,
|
||||
},
|
||||
// For debugging
|
||||
profileScores: {
|
||||
name: nameScores,
|
||||
email: emailScores,
|
||||
phone: phoneScores,
|
||||
location: locationScores,
|
||||
url: urlScores,
|
||||
summary: summaryScores,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ResumeProject } from "lib/redux/types";
|
||||
import type {
|
||||
FeatureSet,
|
||||
ResumeSectionToLines,
|
||||
} from "lib/parse-resume-from-pdf/types";
|
||||
import { getSectionLinesByKeywords } from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/get-section-lines";
|
||||
import {
|
||||
DATE_FEATURE_SETS,
|
||||
getHasText,
|
||||
isBold,
|
||||
} from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/common-features";
|
||||
import { divideSectionIntoSubsections } from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/subsections";
|
||||
import { getTextWithHighestFeatureScore } from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/feature-scoring-system";
|
||||
import {
|
||||
getBulletPointsFromLines,
|
||||
getDescriptionsLineIdx,
|
||||
} from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/bullet-points";
|
||||
|
||||
export const extractProject = (sections: ResumeSectionToLines) => {
|
||||
const projects: ResumeProject[] = [];
|
||||
const projectsScores = [];
|
||||
const lines = getSectionLinesByKeywords(sections, ["project"]);
|
||||
const subsections = divideSectionIntoSubsections(lines);
|
||||
|
||||
for (const subsectionLines of subsections) {
|
||||
const descriptionsLineIdx = getDescriptionsLineIdx(subsectionLines) ?? 1;
|
||||
|
||||
const subsectionInfoTextItems = subsectionLines
|
||||
.slice(0, descriptionsLineIdx)
|
||||
.flat();
|
||||
const [date, dateScores] = getTextWithHighestFeatureScore(
|
||||
subsectionInfoTextItems,
|
||||
DATE_FEATURE_SETS
|
||||
);
|
||||
const PROJECT_FEATURE_SET: FeatureSet[] = [
|
||||
[isBold, 2],
|
||||
[getHasText(date), -4],
|
||||
];
|
||||
const [project, projectScores] = getTextWithHighestFeatureScore(
|
||||
subsectionInfoTextItems,
|
||||
PROJECT_FEATURE_SET,
|
||||
false
|
||||
);
|
||||
|
||||
const descriptionsLines = subsectionLines.slice(descriptionsLineIdx);
|
||||
const descriptions = getBulletPointsFromLines(descriptionsLines);
|
||||
|
||||
projects.push({ project, date, descriptions });
|
||||
projectsScores.push({
|
||||
projectScores,
|
||||
dateScores,
|
||||
});
|
||||
}
|
||||
return { projects, projectsScores };
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
matchOnlyLetterSpaceOrPeriod,
|
||||
matchEmail,
|
||||
matchPhone,
|
||||
matchUrl,
|
||||
} from "lib/parse-resume-from-pdf/extract-resume-from-sections/extract-profile";
|
||||
import type { TextItem } from "lib/parse-resume-from-pdf/types";
|
||||
|
||||
const makeTextItem = (text: string) =>
|
||||
({
|
||||
text,
|
||||
} as TextItem);
|
||||
|
||||
describe("extract-profile tests - ", () => {
|
||||
it("Name", () => {
|
||||
expect(
|
||||
matchOnlyLetterSpaceOrPeriod(makeTextItem("Leonardo W. DiCaprio"))![0]
|
||||
).toBe("Leonardo W. DiCaprio");
|
||||
});
|
||||
|
||||
it("Email", () => {
|
||||
expect(matchEmail(makeTextItem(" hello@open-resume.org "))![0]).toBe(
|
||||
"hello@open-resume.org"
|
||||
);
|
||||
});
|
||||
|
||||
it("Phone", () => {
|
||||
expect(matchPhone(makeTextItem(" (123)456-7890 "))![0]).toBe(
|
||||
"(123)456-7890"
|
||||
);
|
||||
});
|
||||
|
||||
it("Url", () => {
|
||||
expect(matchUrl(makeTextItem(" linkedin.com/in/open-resume "))![0]).toBe(
|
||||
"linkedin.com/in/open-resume"
|
||||
);
|
||||
expect(matchUrl(makeTextItem("hello@open-resume.org"))).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ResumeSkills } from "lib/redux/types";
|
||||
import type { ResumeSectionToLines } from "lib/parse-resume-from-pdf/types";
|
||||
import { deepClone } from "lib/deep-clone";
|
||||
import { getSectionLinesByKeywords } from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/get-section-lines";
|
||||
import { initialFeaturedSkills } from "lib/redux/resumeSlice";
|
||||
import {
|
||||
getBulletPointsFromLines,
|
||||
getDescriptionsLineIdx,
|
||||
} from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/bullet-points";
|
||||
|
||||
export const extractSkills = (sections: ResumeSectionToLines) => {
|
||||
const lines = getSectionLinesByKeywords(sections, ["skill"]);
|
||||
const descriptionsLineIdx = getDescriptionsLineIdx(lines) ?? 0;
|
||||
const descriptionsLines = lines.slice(descriptionsLineIdx);
|
||||
const descriptions = getBulletPointsFromLines(descriptionsLines);
|
||||
|
||||
const featuredSkills = deepClone(initialFeaturedSkills);
|
||||
if (descriptionsLineIdx !== 0) {
|
||||
const featuredSkillsLines = lines.slice(0, descriptionsLineIdx);
|
||||
const featuredSkillsTextItems = featuredSkillsLines
|
||||
.flat()
|
||||
.filter((item) => item.text.trim())
|
||||
.slice(0, 6);
|
||||
for (let i = 0; i < featuredSkillsTextItems.length; i++) {
|
||||
featuredSkills[i].skill = featuredSkillsTextItems[i].text;
|
||||
}
|
||||
}
|
||||
|
||||
const skills: ResumeSkills = {
|
||||
featuredSkills,
|
||||
descriptions,
|
||||
};
|
||||
|
||||
return { skills };
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { ResumeWorkExperience } from "lib/redux/types";
|
||||
import type {
|
||||
TextItem,
|
||||
FeatureSet,
|
||||
ResumeSectionToLines,
|
||||
} from "lib/parse-resume-from-pdf/types";
|
||||
import { getSectionLinesByKeywords } from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/get-section-lines";
|
||||
import {
|
||||
DATE_FEATURE_SETS,
|
||||
hasNumber,
|
||||
getHasText,
|
||||
isBold,
|
||||
} from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/common-features";
|
||||
import { divideSectionIntoSubsections } from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/subsections";
|
||||
import { getTextWithHighestFeatureScore } from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/feature-scoring-system";
|
||||
import {
|
||||
getBulletPointsFromLines,
|
||||
getDescriptionsLineIdx,
|
||||
} from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/bullet-points";
|
||||
|
||||
// prettier-ignore
|
||||
const WORK_EXPERIENCE_KEYWORDS_LOWERCASE = ['work', 'experience', 'employment', 'history', 'job'];
|
||||
// prettier-ignore
|
||||
const JOB_TITLES = ['Accountant', 'Administrator', 'Advisor', 'Agent', 'Analyst', 'Apprentice', 'Architect', 'Assistant', 'Associate', 'Auditor', 'Bartender', 'Biologist', 'Bookkeeper', 'Buyer', 'Carpenter', 'Cashier', 'CEO', 'Clerk', 'Co-op', 'Co-Founder', 'Consultant', 'Coordinator', 'CTO', 'Developer', 'Designer', 'Director', 'Driver', 'Editor', 'Electrician', 'Engineer', 'Extern', 'Founder', 'Freelancer', 'Head', 'Intern', 'Janitor', 'Journalist', 'Laborer', 'Lawyer', 'Lead', 'Manager', 'Mechanic', 'Member', 'Nurse', 'Officer', 'Operator', 'Operation', 'Photographer', 'President', 'Producer', 'Recruiter', 'Representative', 'Researcher', 'Sales', 'Server', 'Scientist', 'Specialist', 'Supervisor', 'Teacher', 'Technician', 'Trader', 'Trainee', 'Treasurer', 'Tutor', 'Vice', 'VP', 'Volunteer', 'Webmaster', 'Worker'];
|
||||
|
||||
const hasJobTitle = (item: TextItem) =>
|
||||
JOB_TITLES.some((jobTitle) =>
|
||||
item.text.split(/\s/).some((word) => word === jobTitle)
|
||||
);
|
||||
const hasMoreThan5Words = (item: TextItem) => item.text.split(/\s/).length > 5;
|
||||
const JOB_TITLE_FEATURE_SET: FeatureSet[] = [
|
||||
[hasJobTitle, 4],
|
||||
[hasNumber, -4],
|
||||
[hasMoreThan5Words, -2],
|
||||
];
|
||||
|
||||
export const extractWorkExperience = (sections: ResumeSectionToLines) => {
|
||||
const workExperiences: ResumeWorkExperience[] = [];
|
||||
const workExperiencesScores = [];
|
||||
const lines = getSectionLinesByKeywords(
|
||||
sections,
|
||||
WORK_EXPERIENCE_KEYWORDS_LOWERCASE
|
||||
);
|
||||
const subsections = divideSectionIntoSubsections(lines);
|
||||
|
||||
for (const subsectionLines of subsections) {
|
||||
const descriptionsLineIdx = getDescriptionsLineIdx(subsectionLines) ?? 2;
|
||||
|
||||
const subsectionInfoTextItems = subsectionLines
|
||||
.slice(0, descriptionsLineIdx)
|
||||
.flat();
|
||||
const [date, dateScores] = getTextWithHighestFeatureScore(
|
||||
subsectionInfoTextItems,
|
||||
DATE_FEATURE_SETS
|
||||
);
|
||||
const [jobTitle, jobTitleScores] = getTextWithHighestFeatureScore(
|
||||
subsectionInfoTextItems,
|
||||
JOB_TITLE_FEATURE_SET
|
||||
);
|
||||
const COMPANY_FEATURE_SET: FeatureSet[] = [
|
||||
[isBold, 2],
|
||||
[getHasText(date), -4],
|
||||
[getHasText(jobTitle), -4],
|
||||
];
|
||||
const [company, companyScores] = getTextWithHighestFeatureScore(
|
||||
subsectionInfoTextItems,
|
||||
COMPANY_FEATURE_SET,
|
||||
false
|
||||
);
|
||||
|
||||
const subsectionDescriptionsLines =
|
||||
subsectionLines.slice(descriptionsLineIdx);
|
||||
const descriptions = getBulletPointsFromLines(subsectionDescriptionsLines);
|
||||
|
||||
workExperiences.push({ company, jobTitle, date, descriptions });
|
||||
workExperiencesScores.push({
|
||||
companyScores,
|
||||
jobTitleScores,
|
||||
dateScores,
|
||||
});
|
||||
}
|
||||
return { workExperiences, workExperiencesScores };
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Resume } from "lib/redux/types";
|
||||
import type { ResumeSectionToLines } from "lib/parse-resume-from-pdf/types";
|
||||
import { extractProfile } from "lib/parse-resume-from-pdf/extract-resume-from-sections/extract-profile";
|
||||
import { extractEducation } from "lib/parse-resume-from-pdf/extract-resume-from-sections/extract-education";
|
||||
import { extractWorkExperience } from "lib/parse-resume-from-pdf/extract-resume-from-sections/extract-work-experience";
|
||||
import { extractProject } from "lib/parse-resume-from-pdf/extract-resume-from-sections/extract-project";
|
||||
import { extractSkills } from "lib/parse-resume-from-pdf/extract-resume-from-sections/extract-skills";
|
||||
|
||||
/**
|
||||
* Step 4. Extract resume from sections.
|
||||
*
|
||||
* This is the core of the resume parser to resume information from the sections.
|
||||
*
|
||||
* The gist of the extraction engine is a feature scoring system. Each resume attribute
|
||||
* to be extracted has a custom feature sets, where each feature set consists of a
|
||||
* feature matching function and a feature matching score if matched (feature matching
|
||||
* score can be a positive or negative number). To compute the final feature score of
|
||||
* a text item for a particular resume attribute, it would run the text item through
|
||||
* all its feature sets and sum up the matching feature scores. This process is carried
|
||||
* out for all text items within the section, and the text item with the highest computed
|
||||
* feature score is identified as the extracted resume attribute.
|
||||
*/
|
||||
export const extractResumeFromSections = (
|
||||
sections: ResumeSectionToLines
|
||||
): Resume => {
|
||||
const { profile } = extractProfile(sections);
|
||||
const { educations } = extractEducation(sections);
|
||||
const { workExperiences } = extractWorkExperience(sections);
|
||||
const { projects } = extractProject(sections);
|
||||
const { skills } = extractSkills(sections);
|
||||
|
||||
return {
|
||||
profile,
|
||||
educations,
|
||||
workExperiences,
|
||||
projects,
|
||||
skills,
|
||||
custom: {
|
||||
descriptions: [],
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Lines, TextItem } from "lib/parse-resume-from-pdf/types";
|
||||
|
||||
/**
|
||||
* List of bullet points
|
||||
* Reference: https://stackoverflow.com/questions/56540160/why-isnt-there-a-medium-small-black-circle-in-unicode
|
||||
* U+22C5 DOT OPERATOR (⋅)
|
||||
* U+2219 BULLET OPERATOR (∙)
|
||||
* U+1F784 BLACK SLIGHTLY SMALL CIRCLE (🞄)
|
||||
* U+2022 BULLET (•) -------- most common
|
||||
* U+2981 Z NOTATION SPOT (⦁)
|
||||
* U+26AB MEDIUM BLACK CIRCLE (⚫︎)
|
||||
* U+25CF BLACK CIRCLE (●)
|
||||
* U+2B24 BLACK LARGE CIRCLE (⬤)
|
||||
* U+26AC MEDIUM SMALL WHITE CIRCLE ⚬
|
||||
* U+25CB WHITE CIRCLE ○
|
||||
*/
|
||||
export const BULLET_POINTS = [
|
||||
"⋅",
|
||||
"∙",
|
||||
"🞄",
|
||||
"•",
|
||||
"⦁",
|
||||
"⚫︎",
|
||||
"●",
|
||||
"⬤",
|
||||
"⚬",
|
||||
"○",
|
||||
];
|
||||
|
||||
/**
|
||||
* Convert bullet point lines into a string array aka descriptions.
|
||||
*/
|
||||
export const getBulletPointsFromLines = (lines: Lines): string[] => {
|
||||
// Simply return all lines with text item joined together if there is no bullet point
|
||||
const firstBulletPointLineIndex = getFirstBulletPointLineIdx(lines);
|
||||
if (firstBulletPointLineIndex === undefined) {
|
||||
return lines.map((line) => line.map((item) => item.text).join(" "));
|
||||
}
|
||||
|
||||
// Otherwise, process and remove bullet points
|
||||
|
||||
// Combine all lines into a single string
|
||||
let lineStr = "";
|
||||
for (let item of lines.flat()) {
|
||||
const text = item.text;
|
||||
// Make sure a space is added between 2 words
|
||||
if (!lineStr.endsWith(" ") && !text.startsWith(" ")) {
|
||||
lineStr += " ";
|
||||
}
|
||||
lineStr += text;
|
||||
}
|
||||
|
||||
// Get the most common bullet point
|
||||
const commonBulletPoint = getMostCommonBulletPoint(lineStr);
|
||||
|
||||
// Start line string from the beginning of the first bullet point
|
||||
const firstBulletPointIndex = lineStr.indexOf(commonBulletPoint);
|
||||
if (firstBulletPointIndex !== -1) {
|
||||
lineStr = lineStr.slice(firstBulletPointIndex);
|
||||
}
|
||||
|
||||
// Divide the single string using bullet point as divider
|
||||
return lineStr
|
||||
.split(commonBulletPoint)
|
||||
.map((text) => text.trim())
|
||||
.filter((text) => !!text);
|
||||
};
|
||||
|
||||
const getMostCommonBulletPoint = (str: string): string => {
|
||||
const bulletToCount: { [bullet: string]: number } = BULLET_POINTS.reduce(
|
||||
(acc: { [bullet: string]: number }, cur) => {
|
||||
acc[cur] = 0;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
let bulletWithMostCount = BULLET_POINTS[0];
|
||||
let bulletMaxCount = 0;
|
||||
for (let char of str) {
|
||||
if (bulletToCount.hasOwnProperty(char)) {
|
||||
bulletToCount[char]++;
|
||||
if (bulletToCount[char] > bulletMaxCount) {
|
||||
bulletWithMostCount = char;
|
||||
}
|
||||
}
|
||||
}
|
||||
return bulletWithMostCount;
|
||||
};
|
||||
|
||||
const getFirstBulletPointLineIdx = (lines: Lines): number | undefined => {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
for (let item of lines[i]) {
|
||||
if (BULLET_POINTS.some((bullet) => item.text.includes(bullet))) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Only consider words that don't contain numbers
|
||||
const isWord = (str: string) => /^[^0-9]+$/.test(str);
|
||||
const hasAtLeast8Words = (item: TextItem) =>
|
||||
item.text.split(/\s/).filter(isWord).length >= 8;
|
||||
|
||||
export const getDescriptionsLineIdx = (lines: Lines): number | undefined => {
|
||||
// The main heuristic to determine descriptions is to check if has bullet point
|
||||
let idx = getFirstBulletPointLineIdx(lines);
|
||||
|
||||
// Fallback heuristic if the main heuristic doesn't apply (e.g. LinkedIn resume) to
|
||||
// check if the line has at least 8 words
|
||||
if (idx === undefined) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.length === 1 && hasAtLeast8Words(line[0])) {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return idx;
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { TextItem, FeatureSet } from "lib/parse-resume-from-pdf/types";
|
||||
|
||||
const isTextItemBold = (fontName: string) =>
|
||||
fontName.toLowerCase().includes("bold");
|
||||
export const isBold = (item: TextItem) => isTextItemBold(item.fontName);
|
||||
export const hasLetter = (item: TextItem) => /[a-zA-Z]/.test(item.text);
|
||||
export const hasNumber = (item: TextItem) => /[0-9]/.test(item.text);
|
||||
export const hasComma = (item: TextItem) => item.text.includes(",");
|
||||
export const getHasText = (text: string) => (item: TextItem) =>
|
||||
item.text.includes(text);
|
||||
export const hasOnlyLettersSpacesAmpersands = (item: TextItem) =>
|
||||
/^[A-Za-z\s&]+$/.test(item.text);
|
||||
export const hasLetterAndIsAllUpperCase = (item: TextItem) =>
|
||||
hasLetter(item) && item.text.toUpperCase() === item.text;
|
||||
|
||||
// Date Features
|
||||
const hasYear = (item: TextItem) => /(?:19|20)\d{2}/.test(item.text);
|
||||
// prettier-ignore
|
||||
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
const hasMonth = (item: TextItem) =>
|
||||
MONTHS.some(
|
||||
(month) =>
|
||||
item.text.includes(month) || item.text.includes(month.slice(0, 4))
|
||||
);
|
||||
const SEASONS = ["Summer", "Fall", "Spring", "Winter"];
|
||||
const hasSeason = (item: TextItem) =>
|
||||
SEASONS.some((season) => item.text.includes(season));
|
||||
const hasPresent = (item: TextItem) => item.text.includes("Present");
|
||||
export const DATE_FEATURE_SETS: FeatureSet[] = [
|
||||
[hasYear, 1],
|
||||
[hasMonth, 1],
|
||||
[hasSeason, 1],
|
||||
[hasPresent, 1],
|
||||
[hasComma, -1],
|
||||
];
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
TextItems,
|
||||
TextScores,
|
||||
FeatureSet,
|
||||
} from "lib/parse-resume-from-pdf/types";
|
||||
|
||||
const computeFeatureScores = (
|
||||
textItems: TextItems,
|
||||
featureSets: FeatureSet[]
|
||||
): TextScores => {
|
||||
const textScores = textItems.map((item) => ({
|
||||
text: item.text,
|
||||
score: 0,
|
||||
match: false,
|
||||
}));
|
||||
|
||||
for (let i = 0; i < textItems.length; i++) {
|
||||
const textItem = textItems[i];
|
||||
|
||||
for (const featureSet of featureSets) {
|
||||
const [hasFeature, score, returnMatchingText] = featureSet;
|
||||
const result = hasFeature(textItem);
|
||||
if (result) {
|
||||
let text = textItem.text;
|
||||
if (returnMatchingText && typeof result === "object") {
|
||||
text = result[0];
|
||||
}
|
||||
|
||||
const textScore = textScores[i];
|
||||
if (textItem.text === text) {
|
||||
textScore.score += score;
|
||||
if (returnMatchingText) {
|
||||
textScore.match = true;
|
||||
}
|
||||
} else {
|
||||
textScores.push({ text, score, match: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return textScores;
|
||||
};
|
||||
|
||||
/**
|
||||
* Core util for the feature scoring system.
|
||||
*
|
||||
* It runs each text item through all feature sets and sums up the matching feature scores.
|
||||
* It then returns the text item with the highest computed feature score.
|
||||
*/
|
||||
export const getTextWithHighestFeatureScore = (
|
||||
textItems: TextItems,
|
||||
featureSets: FeatureSet[],
|
||||
returnEmptyStringIfHighestScoreIsNotPositive = true,
|
||||
returnConcatenatedStringForTextsWithSameHighestScore = false
|
||||
) => {
|
||||
const textScores = computeFeatureScores(textItems, featureSets);
|
||||
|
||||
let textsWithHighestFeatureScore: string[] = [];
|
||||
let highestScore = -Infinity;
|
||||
for (const { text, score } of textScores) {
|
||||
if (score >= highestScore) {
|
||||
if (score > highestScore) {
|
||||
textsWithHighestFeatureScore = [];
|
||||
}
|
||||
textsWithHighestFeatureScore.push(text);
|
||||
highestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
if (returnEmptyStringIfHighestScoreIsNotPositive && highestScore <= 0)
|
||||
return ["", textScores] as const;
|
||||
|
||||
// Note: If textItems is an empty array, textsWithHighestFeatureScore[0] is undefined, so we default it to empty string
|
||||
const text = !returnConcatenatedStringForTextsWithSameHighestScore
|
||||
? textsWithHighestFeatureScore[0] ?? ""
|
||||
: textsWithHighestFeatureScore.map((s) => s.trim()).join(" ");
|
||||
|
||||
return [text, textScores] as const;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ResumeSectionToLines } from "lib/parse-resume-from-pdf/types";
|
||||
|
||||
/**
|
||||
* Return section lines that contain any of the keywords.
|
||||
*/
|
||||
export const getSectionLinesByKeywords = (
|
||||
sections: ResumeSectionToLines,
|
||||
keywords: string[]
|
||||
) => {
|
||||
for (const sectionName in sections) {
|
||||
const hasKeyWord = keywords.some((keyword) =>
|
||||
sectionName.toLowerCase().includes(keyword)
|
||||
);
|
||||
if (hasKeyWord) {
|
||||
return sections[sectionName];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { BULLET_POINTS } from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/bullet-points";
|
||||
import { isBold } from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/common-features";
|
||||
import type { Lines, Line, Subsections } from "lib/parse-resume-from-pdf/types";
|
||||
|
||||
/**
|
||||
* Divide lines into subsections based on difference in line gap or bold text.
|
||||
*
|
||||
* For profile section, we can directly pass all the text items to the feature
|
||||
* scoring systems. But for other sections, such as education and work experience,
|
||||
* we have to first divide the section into subsections since there can be multiple
|
||||
* schools or work experiences in the section. The feature scoring system then
|
||||
* process each subsection to retrieve each's resume attributes and append the results.
|
||||
*/
|
||||
export const divideSectionIntoSubsections = (lines: Lines): Subsections => {
|
||||
// The main heuristic to determine a subsection is to check if its vertical line gap
|
||||
// is larger than the typical line gap * 1.4
|
||||
const isLineNewSubsectionByLineGap =
|
||||
createIsLineNewSubsectionByLineGap(lines);
|
||||
|
||||
let subsections = createSubsections(lines, isLineNewSubsectionByLineGap);
|
||||
|
||||
// Fallback heuristic if the main heuristic doesn't apply to check if the text item is bolded
|
||||
if (subsections.length === 1) {
|
||||
const isLineNewSubsectionByBold = (line: Line, prevLine: Line) => {
|
||||
if (
|
||||
!isBold(prevLine[0]) &&
|
||||
isBold(line[0]) &&
|
||||
// Ignore bullet points that sometimes being marked as bolded
|
||||
!BULLET_POINTS.includes(line[0].text)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
subsections = createSubsections(lines, isLineNewSubsectionByBold);
|
||||
}
|
||||
|
||||
return subsections;
|
||||
};
|
||||
|
||||
type IsLineNewSubsection = (line: Line, prevLine: Line) => boolean;
|
||||
|
||||
const createIsLineNewSubsectionByLineGap = (
|
||||
lines: Lines
|
||||
): IsLineNewSubsection => {
|
||||
// Extract the common typical line gap
|
||||
const lineGapToCount: { [lineGap: number]: number } = {};
|
||||
const linesY = lines.map((line) => line[0].y);
|
||||
let lineGapWithMostCount: number = 0;
|
||||
let maxCount = 0;
|
||||
for (let i = 1; i < linesY.length; i++) {
|
||||
const lineGap = Math.round(linesY[i - 1] - linesY[i]);
|
||||
if (!lineGapToCount[lineGap]) lineGapToCount[lineGap] = 0;
|
||||
lineGapToCount[lineGap] += 1;
|
||||
if (lineGapToCount[lineGap] > maxCount) {
|
||||
lineGapWithMostCount = lineGap;
|
||||
maxCount = lineGapToCount[lineGap];
|
||||
}
|
||||
}
|
||||
// Use common line gap to set a sub section threshold
|
||||
const subsectionLineGapThreshold = lineGapWithMostCount * 1.4;
|
||||
|
||||
const isLineNewSubsection = (line: Line, prevLine: Line) => {
|
||||
return Math.round(prevLine[0].y - line[0].y) > subsectionLineGapThreshold;
|
||||
};
|
||||
|
||||
return isLineNewSubsection;
|
||||
};
|
||||
|
||||
const createSubsections = (
|
||||
lines: Lines,
|
||||
isLineNewSubsection: IsLineNewSubsection
|
||||
): Subsections => {
|
||||
const subsections: Subsections = [];
|
||||
let subsection: Lines = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (i === 0) {
|
||||
subsection.push(line);
|
||||
continue;
|
||||
}
|
||||
if (isLineNewSubsection(line, lines[i - 1])) {
|
||||
subsections.push(subsection);
|
||||
subsection = [];
|
||||
}
|
||||
subsection.push(line);
|
||||
}
|
||||
if (subsection.length > 0) {
|
||||
subsections.push(subsection);
|
||||
}
|
||||
return subsections;
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { ResumeKey } from "lib/redux/types";
|
||||
import type {
|
||||
Line,
|
||||
Lines,
|
||||
ResumeSectionToLines,
|
||||
} from "lib/parse-resume-from-pdf/types";
|
||||
import {
|
||||
hasLetterAndIsAllUpperCase,
|
||||
hasOnlyLettersSpacesAmpersands,
|
||||
isBold,
|
||||
} from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/common-features";
|
||||
|
||||
export const PROFILE_SECTION: ResumeKey = "profile";
|
||||
|
||||
/**
|
||||
* Step 3. Group lines into sections
|
||||
*
|
||||
* Every section (except the profile section) starts with a section title that
|
||||
* takes up the entire line. This is a common pattern not just in resumes but
|
||||
* also in books and blogs. The resume parser uses this pattern to group lines
|
||||
* into the closest section title above these lines.
|
||||
*/
|
||||
export const groupLinesIntoSections = (lines: Lines) => {
|
||||
let sections: ResumeSectionToLines = {};
|
||||
let sectionName: string = PROFILE_SECTION;
|
||||
let sectionLines = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const text = line[0]?.text.trim();
|
||||
if (isSectionTitle(line, i)) {
|
||||
sections[sectionName] = [...sectionLines];
|
||||
sectionName = text;
|
||||
sectionLines = [];
|
||||
} else {
|
||||
sectionLines.push(line);
|
||||
}
|
||||
}
|
||||
if (sectionLines.length > 0) {
|
||||
sections[sectionName] = [...sectionLines];
|
||||
}
|
||||
return sections;
|
||||
};
|
||||
|
||||
const SECTION_TITLE_PRIMARY_KEYWORDS = [
|
||||
"experience",
|
||||
"education",
|
||||
"project",
|
||||
"skill",
|
||||
];
|
||||
const SECTION_TITLE_SECONDARY_KEYWORDS = [
|
||||
"job",
|
||||
"course",
|
||||
"extracurricular",
|
||||
"objective",
|
||||
"summary", // LinkedIn generated resume has a summary section
|
||||
"award",
|
||||
"honor",
|
||||
"project",
|
||||
];
|
||||
const SECTION_TITLE_KEYWORDS = [
|
||||
...SECTION_TITLE_PRIMARY_KEYWORDS,
|
||||
...SECTION_TITLE_SECONDARY_KEYWORDS,
|
||||
];
|
||||
|
||||
const isSectionTitle = (line: Line, lineNumber: number) => {
|
||||
const isFirstTwoLines = lineNumber < 2;
|
||||
const hasMoreThanOneItemInLine = line.length > 1;
|
||||
const hasNoItemInLine = line.length === 0;
|
||||
if (isFirstTwoLines || hasMoreThanOneItemInLine || hasNoItemInLine) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const textItem = line[0];
|
||||
|
||||
// The main heuristic to determine a section title is to check if the text is double emphasized
|
||||
// to be both bold and all uppercase, which is generally true for a well formatted resume
|
||||
if (isBold(textItem) && hasLetterAndIsAllUpperCase(textItem)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The following is a fallback heuristic to detect section title if it includes a keyword match
|
||||
// (This heuristics is not well tested and may not work well)
|
||||
const text = textItem.text.trim();
|
||||
const textHasAtMost2Words =
|
||||
text.split(" ").filter((s) => s !== "&").length <= 2;
|
||||
const startsWithCapitalLetter = /[A-Z]/.test(text.slice(0, 1));
|
||||
|
||||
if (
|
||||
textHasAtMost2Words &&
|
||||
hasOnlyLettersSpacesAmpersands(textItem) &&
|
||||
startsWithCapitalLetter &&
|
||||
SECTION_TITLE_KEYWORDS.some((keyword) =>
|
||||
text.toLowerCase().includes(keyword)
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { BULLET_POINTS } from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/bullet-points";
|
||||
import type { TextItems, Line, Lines } from "lib/parse-resume-from-pdf/types";
|
||||
|
||||
/**
|
||||
* Step 2: Group text items into lines. This returns an array where each position
|
||||
* contains text items in the same line of the pdf file.
|
||||
*/
|
||||
export const groupTextItemsIntoLines = (textItems: TextItems): Lines => {
|
||||
const lines: Lines = [];
|
||||
|
||||
// Group text items into lines based on hasEOL
|
||||
let line: Line = [];
|
||||
for (let item of textItems) {
|
||||
// If item is EOL, add current line to lines and start a new empty line
|
||||
if (item.hasEOL) {
|
||||
if (item.text.trim() !== "") {
|
||||
line.push({ ...item });
|
||||
}
|
||||
lines.push(line);
|
||||
line = [];
|
||||
}
|
||||
// Otherwise, add item to current line
|
||||
else if (item.text.trim() !== "") {
|
||||
line.push({ ...item });
|
||||
}
|
||||
}
|
||||
// Add last line if there is item in last line
|
||||
if (line.length > 0) {
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
// Many pdf docs are not well formatted, e.g. due to converting from other docs.
|
||||
// This creates many noises, where a single text item is divided into multiple
|
||||
// ones. This step is to merge adjacent text items if their distance is smaller
|
||||
// than a typical char width to filter out those noises.
|
||||
const typicalCharWidth = getTypicalCharWidth(lines.flat());
|
||||
for (let line of lines) {
|
||||
// Start from the end of the line to make things easier to merge and delete
|
||||
for (let i = line.length - 1; i > 0; i--) {
|
||||
const currentItem = line[i];
|
||||
const leftItem = line[i - 1];
|
||||
const leftItemXEnd = leftItem.x + leftItem.width;
|
||||
const distance = currentItem.x - leftItemXEnd;
|
||||
if (distance <= typicalCharWidth) {
|
||||
if (shouldAddSpaceBetweenText(leftItem.text, currentItem.text)) {
|
||||
leftItem.text += " ";
|
||||
}
|
||||
leftItem.text += currentItem.text;
|
||||
// Update leftItem width to include currentItem after merge before deleting current item
|
||||
const currentItemXEnd = currentItem.x + currentItem.width;
|
||||
leftItem.width = currentItemXEnd - leftItem.x;
|
||||
line.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
|
||||
// Sometimes a space is lost while merging adjacent text items. This accounts for some of those cases
|
||||
const shouldAddSpaceBetweenText = (leftText: string, rightText: string) => {
|
||||
const leftTextEnd = leftText[leftText.length - 1];
|
||||
const rightTextStart = rightText[0];
|
||||
const conditions = [
|
||||
[":", ",", "|", ".", ...BULLET_POINTS].includes(leftTextEnd) &&
|
||||
rightTextStart !== " ",
|
||||
leftTextEnd !== " " && ["|", ...BULLET_POINTS].includes(rightTextStart),
|
||||
];
|
||||
|
||||
return conditions.some((condition) => condition);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the width of a typical character. (Helper util for groupTextItemsIntoLines)
|
||||
*
|
||||
* A pdf file uses different characters, each with different width due to different
|
||||
* font family and font size. This util first extracts the most typically used font
|
||||
* family and font height, and compute the average character width using text items
|
||||
* that match the typical font family and height.
|
||||
*/
|
||||
const getTypicalCharWidth = (textItems: TextItems): number => {
|
||||
// Exclude empty space " " in calculations since its width isn't precise
|
||||
textItems = textItems.filter((item) => item.text.trim() !== "");
|
||||
|
||||
const heightToCount: { [height: number]: number } = {};
|
||||
let commonHeight = 0;
|
||||
let heightMaxCount = 0;
|
||||
|
||||
const fontNameToCount: { [fontName: string]: number } = {};
|
||||
let commonFontName = "";
|
||||
let fontNameMaxCount = 0;
|
||||
|
||||
for (let item of textItems) {
|
||||
const { text, height, fontName } = item;
|
||||
// Process height
|
||||
if (!heightToCount[height]) {
|
||||
heightToCount[height] = 0;
|
||||
}
|
||||
heightToCount[height]++;
|
||||
if (heightToCount[height] > heightMaxCount) {
|
||||
commonHeight = height;
|
||||
heightMaxCount = heightToCount[height];
|
||||
}
|
||||
|
||||
// Process font name
|
||||
if (!fontNameToCount[fontName]) {
|
||||
fontNameToCount[fontName] = 0;
|
||||
}
|
||||
fontNameToCount[fontName] += text.length;
|
||||
if (fontNameToCount[fontName] > fontNameMaxCount) {
|
||||
commonFontName = fontName;
|
||||
fontNameMaxCount = fontNameToCount[fontName];
|
||||
}
|
||||
}
|
||||
|
||||
// Find the text items that match common font family and height
|
||||
const commonTextItems = textItems.filter(
|
||||
(item) => item.fontName === commonFontName && item.height === commonHeight
|
||||
);
|
||||
// Aggregate total width and number of characters of all common text items
|
||||
const [totalWidth, numChars] = commonTextItems.reduce(
|
||||
(acc, cur) => {
|
||||
const [preWidth, prevChars] = acc;
|
||||
return [preWidth + cur.width, prevChars + cur.text.length];
|
||||
},
|
||||
[0, 0]
|
||||
);
|
||||
const typicalCharWidth = totalWidth / numChars;
|
||||
|
||||
return typicalCharWidth;
|
||||
};
|
||||
25
open-resume/src/app/lib/parse-resume-from-pdf/index.ts
Normal file
25
open-resume/src/app/lib/parse-resume-from-pdf/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { readPdf } from "lib/parse-resume-from-pdf/read-pdf";
|
||||
import { groupTextItemsIntoLines } from "lib/parse-resume-from-pdf/group-text-items-into-lines";
|
||||
import { groupLinesIntoSections } from "lib/parse-resume-from-pdf/group-lines-into-sections";
|
||||
import { extractResumeFromSections } from "lib/parse-resume-from-pdf/extract-resume-from-sections";
|
||||
|
||||
/**
|
||||
* Resume parser util that parses a resume from a resume pdf file
|
||||
*
|
||||
* Note: The parser algorithm only works for single column resume in English language
|
||||
*/
|
||||
export const parseResumeFromPdf = async (fileUrl: string) => {
|
||||
// Step 1. Read a pdf resume file into text items to prepare for processing
|
||||
const textItems = await readPdf(fileUrl);
|
||||
|
||||
// Step 2. Group text items into lines
|
||||
const lines = groupTextItemsIntoLines(textItems);
|
||||
|
||||
// Step 3. Group lines into sections
|
||||
const sections = groupLinesIntoSections(lines);
|
||||
|
||||
// Step 4. Extract resume from sections
|
||||
const resume = extractResumeFromSections(sections);
|
||||
|
||||
return resume;
|
||||
};
|
||||
89
open-resume/src/app/lib/parse-resume-from-pdf/read-pdf.ts
Normal file
89
open-resume/src/app/lib/parse-resume-from-pdf/read-pdf.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// Getting pdfjs to work is tricky. The following 3 lines would make it work
|
||||
// https://stackoverflow.com/a/63486898/7699841
|
||||
import * as pdfjs from "pdfjs-dist";
|
||||
// @ts-ignore
|
||||
import pdfjsWorker from "pdfjs-dist/build/pdf.worker.entry";
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorker;
|
||||
|
||||
import type { TextItem as PdfjsTextItem } from "pdfjs-dist/types/src/display/api";
|
||||
import type { TextItem, TextItems } from "lib/parse-resume-from-pdf/types";
|
||||
|
||||
/**
|
||||
* Step 1: Read pdf and output textItems by concatenating results from each page.
|
||||
*
|
||||
* To make processing easier, it returns a new TextItem type, which removes unused
|
||||
* attributes (dir, transform), adds x and y positions, and replaces loaded font
|
||||
* name with original font name.
|
||||
*
|
||||
* @example
|
||||
* const onFileChange = async (e) => {
|
||||
* const fileUrl = URL.createObjectURL(e.target.files[0]);
|
||||
* const textItems = await readPdf(fileUrl);
|
||||
* }
|
||||
*/
|
||||
export const readPdf = async (fileUrl: string): Promise<TextItems> => {
|
||||
const pdfFile = await pdfjs.getDocument(fileUrl).promise;
|
||||
let textItems: TextItems = [];
|
||||
|
||||
for (let i = 1; i <= pdfFile.numPages; i++) {
|
||||
// Parse each page into text content
|
||||
const page = await pdfFile.getPage(i);
|
||||
const textContent = await page.getTextContent();
|
||||
|
||||
// Wait for font data to be loaded
|
||||
await page.getOperatorList();
|
||||
const commonObjs = page.commonObjs;
|
||||
|
||||
// Convert Pdfjs TextItem type to new TextItem type
|
||||
const pageTextItems = textContent.items.map((item) => {
|
||||
const {
|
||||
str: text,
|
||||
dir, // Remove text direction
|
||||
transform,
|
||||
fontName: pdfFontName,
|
||||
...otherProps
|
||||
} = item as PdfjsTextItem;
|
||||
|
||||
// Extract x, y position of text item from transform.
|
||||
// As a side note, origin (0, 0) is bottom left.
|
||||
// Reference: https://github.com/mozilla/pdf.js/issues/5643#issuecomment-496648719
|
||||
const x = transform[4];
|
||||
const y = transform[5];
|
||||
|
||||
// Use commonObjs to convert font name to original name (e.g. "GVDLYI+Arial-BoldMT")
|
||||
// since non system font name by default is a loaded name, e.g. "g_d8_f1"
|
||||
// Reference: https://github.com/mozilla/pdf.js/pull/15659
|
||||
const fontObj = commonObjs.get(pdfFontName);
|
||||
const fontName = fontObj.name;
|
||||
|
||||
// pdfjs reads a "-" as "-‐" in the resume example. This is to revert it.
|
||||
// Note "-‐" is "-­‐" with a soft hyphen in between. It is not the same as "--"
|
||||
const newText = text.replace(/-‐/g, "-");
|
||||
|
||||
const newItem = {
|
||||
...otherProps,
|
||||
fontName,
|
||||
text: newText,
|
||||
x,
|
||||
y,
|
||||
};
|
||||
return newItem;
|
||||
});
|
||||
|
||||
// Some pdf's text items are not in order. This is most likely a result of creating it
|
||||
// from design softwares, e.g. canvas. The commented out method can sort pageTextItems
|
||||
// by y position to put them back in order. But it is not used since it might be more
|
||||
// helpful to let users know that the pdf is not in order.
|
||||
// pageTextItems.sort((a, b) => Math.round(b.y) - Math.round(a.y));
|
||||
|
||||
// Add text items of each page to total
|
||||
textItems.push(...pageTextItems);
|
||||
}
|
||||
|
||||
// Filter out empty space textItem noise
|
||||
const isEmptySpace = (textItem: TextItem) =>
|
||||
!textItem.hasEOL && textItem.text.trim() === "";
|
||||
textItems = textItems.filter((textItem) => !isEmptySpace(textItem));
|
||||
|
||||
return textItems;
|
||||
};
|
||||
37
open-resume/src/app/lib/parse-resume-from-pdf/types.ts
Normal file
37
open-resume/src/app/lib/parse-resume-from-pdf/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ResumeKey } from "lib/redux/types";
|
||||
|
||||
export interface TextItem {
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fontName: string;
|
||||
hasEOL: boolean;
|
||||
}
|
||||
export type TextItems = TextItem[];
|
||||
|
||||
export type Line = TextItem[];
|
||||
export type Lines = Line[];
|
||||
|
||||
export type ResumeSectionToLines = { [sectionName in ResumeKey]?: Lines } & {
|
||||
[otherSectionName: string]: Lines;
|
||||
};
|
||||
export type Subsections = Lines[];
|
||||
|
||||
type FeatureScore = -4 | -3 | -2 | -1 | 0 | 1 | 2 | 3 | 4;
|
||||
type ReturnMatchingTextOnly = boolean;
|
||||
export type FeatureSet =
|
||||
| [(item: TextItem) => boolean, FeatureScore]
|
||||
| [
|
||||
(item: TextItem) => RegExpMatchArray | null,
|
||||
FeatureScore,
|
||||
ReturnMatchingTextOnly
|
||||
];
|
||||
|
||||
export interface TextScore {
|
||||
text: string;
|
||||
score: number;
|
||||
match: boolean;
|
||||
}
|
||||
export type TextScores = TextScore[];
|
||||
59
open-resume/src/app/lib/redux/hooks.tsx
Normal file
59
open-resume/src/app/lib/redux/hooks.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
useDispatch,
|
||||
useSelector,
|
||||
type TypedUseSelectorHook,
|
||||
} from "react-redux";
|
||||
import { store, type RootState, type AppDispatch } from "lib/redux/store";
|
||||
import {
|
||||
loadStateFromLocalStorage,
|
||||
saveStateToLocalStorage,
|
||||
} from "lib/redux/local-storage";
|
||||
import { initialResumeState, setResume } from "lib/redux/resumeSlice";
|
||||
import {
|
||||
initialSettings,
|
||||
setSettings,
|
||||
type Settings,
|
||||
} from "lib/redux/settingsSlice";
|
||||
import { deepMerge } from "lib/deep-merge";
|
||||
import type { Resume } from "lib/redux/types";
|
||||
|
||||
export const useAppDispatch: () => AppDispatch = useDispatch;
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
|
||||
/**
|
||||
* Hook to save store to local storage on store change
|
||||
*/
|
||||
export const useSaveStateToLocalStorageOnChange = () => {
|
||||
useEffect(() => {
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
saveStateToLocalStorage(store.getState());
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const useSetInitialStore = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
useEffect(() => {
|
||||
const state = loadStateFromLocalStorage();
|
||||
if (!state) return;
|
||||
if (state.resume) {
|
||||
// We merge the initial state with the stored state to ensure
|
||||
// backward compatibility, since new fields might be added to
|
||||
// the initial state over time.
|
||||
const mergedResumeState = deepMerge(
|
||||
initialResumeState,
|
||||
state.resume
|
||||
) as Resume;
|
||||
dispatch(setResume(mergedResumeState));
|
||||
}
|
||||
if (state.settings) {
|
||||
const mergedSettingsState = deepMerge(
|
||||
initialSettings,
|
||||
state.settings
|
||||
) as Settings;
|
||||
dispatch(setSettings(mergedSettingsState));
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
26
open-resume/src/app/lib/redux/local-storage.ts
Normal file
26
open-resume/src/app/lib/redux/local-storage.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { RootState } from "lib/redux/store";
|
||||
|
||||
// Reference: https://dev.to/igorovic/simplest-way-to-persist-redux-state-to-localstorage-e67
|
||||
|
||||
const LOCAL_STORAGE_KEY = "open-resume-state";
|
||||
|
||||
export const loadStateFromLocalStorage = () => {
|
||||
try {
|
||||
const stringifiedState = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
if (!stringifiedState) return undefined;
|
||||
return JSON.parse(stringifiedState);
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const saveStateToLocalStorage = (state: RootState) => {
|
||||
try {
|
||||
const stringifiedState = JSON.stringify(state);
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, stringifiedState);
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
};
|
||||
|
||||
export const getHasUsedAppBefore = () => Boolean(loadStateFromLocalStorage());
|
||||
225
open-resume/src/app/lib/redux/resumeSlice.ts
Normal file
225
open-resume/src/app/lib/redux/resumeSlice.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
||||
import type { RootState } from "lib/redux/store";
|
||||
import type {
|
||||
FeaturedSkill,
|
||||
Resume,
|
||||
ResumeEducation,
|
||||
ResumeProfile,
|
||||
ResumeProject,
|
||||
ResumeSkills,
|
||||
ResumeWorkExperience,
|
||||
} from "lib/redux/types";
|
||||
import type { ShowForm } from "lib/redux/settingsSlice";
|
||||
|
||||
export const initialProfile: ResumeProfile = {
|
||||
name: "",
|
||||
summary: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
location: "",
|
||||
url: "",
|
||||
};
|
||||
|
||||
export const initialWorkExperience: ResumeWorkExperience = {
|
||||
company: "",
|
||||
jobTitle: "",
|
||||
date: "",
|
||||
descriptions: [],
|
||||
};
|
||||
|
||||
export const initialEducation: ResumeEducation = {
|
||||
school: "",
|
||||
degree: "",
|
||||
gpa: "",
|
||||
date: "",
|
||||
descriptions: [],
|
||||
};
|
||||
|
||||
export const initialProject: ResumeProject = {
|
||||
project: "",
|
||||
date: "",
|
||||
descriptions: [],
|
||||
};
|
||||
|
||||
export const initialFeaturedSkill: FeaturedSkill = { skill: "", rating: 4 };
|
||||
export const initialFeaturedSkills: FeaturedSkill[] = Array(6).fill({
|
||||
...initialFeaturedSkill,
|
||||
});
|
||||
export const initialSkills: ResumeSkills = {
|
||||
featuredSkills: initialFeaturedSkills,
|
||||
descriptions: [],
|
||||
};
|
||||
|
||||
export const initialCustom = {
|
||||
descriptions: [],
|
||||
};
|
||||
|
||||
export const initialResumeState: Resume = {
|
||||
profile: initialProfile,
|
||||
workExperiences: [initialWorkExperience],
|
||||
educations: [initialEducation],
|
||||
projects: [initialProject],
|
||||
skills: initialSkills,
|
||||
custom: initialCustom,
|
||||
};
|
||||
|
||||
// Keep the field & value type in sync with CreateHandleChangeArgsWithDescriptions (components\ResumeForm\types.ts)
|
||||
export type CreateChangeActionWithDescriptions<T> = {
|
||||
idx: number;
|
||||
} & (
|
||||
| {
|
||||
field: Exclude<keyof T, "descriptions">;
|
||||
value: string;
|
||||
}
|
||||
| { field: "descriptions"; value: string[] }
|
||||
);
|
||||
|
||||
export const resumeSlice = createSlice({
|
||||
name: "resume",
|
||||
initialState: initialResumeState,
|
||||
reducers: {
|
||||
changeProfile: (
|
||||
draft,
|
||||
action: PayloadAction<{ field: keyof ResumeProfile; value: string }>
|
||||
) => {
|
||||
const { field, value } = action.payload;
|
||||
draft.profile[field] = value;
|
||||
},
|
||||
changeWorkExperiences: (
|
||||
draft,
|
||||
action: PayloadAction<
|
||||
CreateChangeActionWithDescriptions<ResumeWorkExperience>
|
||||
>
|
||||
) => {
|
||||
const { idx, field, value } = action.payload;
|
||||
const workExperience = draft.workExperiences[idx];
|
||||
workExperience[field] = value as any;
|
||||
},
|
||||
changeEducations: (
|
||||
draft,
|
||||
action: PayloadAction<CreateChangeActionWithDescriptions<ResumeEducation>>
|
||||
) => {
|
||||
const { idx, field, value } = action.payload;
|
||||
const education = draft.educations[idx];
|
||||
education[field] = value as any;
|
||||
},
|
||||
changeProjects: (
|
||||
draft,
|
||||
action: PayloadAction<CreateChangeActionWithDescriptions<ResumeProject>>
|
||||
) => {
|
||||
const { idx, field, value } = action.payload;
|
||||
const project = draft.projects[idx];
|
||||
project[field] = value as any;
|
||||
},
|
||||
changeSkills: (
|
||||
draft,
|
||||
action: PayloadAction<
|
||||
| { field: "descriptions"; value: string[] }
|
||||
| {
|
||||
field: "featuredSkills";
|
||||
idx: number;
|
||||
skill: string;
|
||||
rating: number;
|
||||
}
|
||||
>
|
||||
) => {
|
||||
const { field } = action.payload;
|
||||
if (field === "descriptions") {
|
||||
const { value } = action.payload;
|
||||
draft.skills.descriptions = value;
|
||||
} else {
|
||||
const { idx, skill, rating } = action.payload;
|
||||
const featuredSkill = draft.skills.featuredSkills[idx];
|
||||
featuredSkill.skill = skill;
|
||||
featuredSkill.rating = rating;
|
||||
}
|
||||
},
|
||||
changeCustom: (
|
||||
draft,
|
||||
action: PayloadAction<{ field: "descriptions"; value: string[] }>
|
||||
) => {
|
||||
const { value } = action.payload;
|
||||
draft.custom.descriptions = value;
|
||||
},
|
||||
addSectionInForm: (draft, action: PayloadAction<{ form: ShowForm }>) => {
|
||||
const { form } = action.payload;
|
||||
switch (form) {
|
||||
case "workExperiences": {
|
||||
draft.workExperiences.push(structuredClone(initialWorkExperience));
|
||||
return draft;
|
||||
}
|
||||
case "educations": {
|
||||
draft.educations.push(structuredClone(initialEducation));
|
||||
return draft;
|
||||
}
|
||||
case "projects": {
|
||||
draft.projects.push(structuredClone(initialProject));
|
||||
return draft;
|
||||
}
|
||||
}
|
||||
},
|
||||
moveSectionInForm: (
|
||||
draft,
|
||||
action: PayloadAction<{
|
||||
form: ShowForm;
|
||||
idx: number;
|
||||
direction: "up" | "down";
|
||||
}>
|
||||
) => {
|
||||
const { form, idx, direction } = action.payload;
|
||||
if (form !== "skills" && form !== "custom") {
|
||||
if (
|
||||
(idx === 0 && direction === "up") ||
|
||||
(idx === draft[form].length - 1 && direction === "down")
|
||||
) {
|
||||
return draft;
|
||||
}
|
||||
|
||||
const section = draft[form][idx];
|
||||
if (direction === "up") {
|
||||
draft[form][idx] = draft[form][idx - 1];
|
||||
draft[form][idx - 1] = section;
|
||||
} else {
|
||||
draft[form][idx] = draft[form][idx + 1];
|
||||
draft[form][idx + 1] = section;
|
||||
}
|
||||
}
|
||||
},
|
||||
deleteSectionInFormByIdx: (
|
||||
draft,
|
||||
action: PayloadAction<{ form: ShowForm; idx: number }>
|
||||
) => {
|
||||
const { form, idx } = action.payload;
|
||||
if (form !== "skills" && form !== "custom") {
|
||||
draft[form].splice(idx, 1);
|
||||
}
|
||||
},
|
||||
setResume: (draft, action: PayloadAction<Resume>) => {
|
||||
return action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
changeProfile,
|
||||
changeWorkExperiences,
|
||||
changeEducations,
|
||||
changeProjects,
|
||||
changeSkills,
|
||||
changeCustom,
|
||||
addSectionInForm,
|
||||
moveSectionInForm,
|
||||
deleteSectionInFormByIdx,
|
||||
setResume,
|
||||
} = resumeSlice.actions;
|
||||
|
||||
export const selectResume = (state: RootState) => state.resume;
|
||||
export const selectProfile = (state: RootState) => state.resume.profile;
|
||||
export const selectWorkExperiences = (state: RootState) =>
|
||||
state.resume.workExperiences;
|
||||
export const selectEducations = (state: RootState) => state.resume.educations;
|
||||
export const selectProjects = (state: RootState) => state.resume.projects;
|
||||
export const selectSkills = (state: RootState) => state.resume.skills;
|
||||
export const selectCustom = (state: RootState) => state.resume.custom;
|
||||
|
||||
export default resumeSlice.reducer;
|
||||
161
open-resume/src/app/lib/redux/settingsSlice.ts
Normal file
161
open-resume/src/app/lib/redux/settingsSlice.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
||||
import type { RootState } from "lib/redux/store";
|
||||
|
||||
export interface Settings {
|
||||
themeColor: string;
|
||||
fontFamily: string;
|
||||
fontSize: string;
|
||||
documentSize: string;
|
||||
formToShow: {
|
||||
workExperiences: boolean;
|
||||
educations: boolean;
|
||||
projects: boolean;
|
||||
skills: boolean;
|
||||
custom: boolean;
|
||||
};
|
||||
formToHeading: {
|
||||
workExperiences: string;
|
||||
educations: string;
|
||||
projects: string;
|
||||
skills: string;
|
||||
custom: string;
|
||||
};
|
||||
formsOrder: ShowForm[];
|
||||
showBulletPoints: {
|
||||
educations: boolean;
|
||||
projects: boolean;
|
||||
skills: boolean;
|
||||
custom: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type ShowForm = keyof Settings["formToShow"];
|
||||
export type FormWithBulletPoints = keyof Settings["showBulletPoints"];
|
||||
export type GeneralSetting = Exclude<
|
||||
keyof Settings,
|
||||
"formToShow" | "formToHeading" | "formsOrder" | "showBulletPoints"
|
||||
>;
|
||||
|
||||
export const DEFAULT_THEME_COLOR = "#38bdf8"; // sky-400
|
||||
export const DEFAULT_FONT_FAMILY = "Roboto";
|
||||
export const DEFAULT_FONT_SIZE = "11"; // text-base https://tailwindcss.com/docs/font-size
|
||||
export const DEFAULT_FONT_COLOR = "#171717"; // text-neutral-800
|
||||
|
||||
export const initialSettings: Settings = {
|
||||
themeColor: DEFAULT_THEME_COLOR,
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
documentSize: "Letter",
|
||||
formToShow: {
|
||||
workExperiences: true,
|
||||
educations: true,
|
||||
projects: true,
|
||||
skills: true,
|
||||
custom: false,
|
||||
},
|
||||
formToHeading: {
|
||||
workExperiences: "WORK EXPERIENCE",
|
||||
educations: "EDUCATION",
|
||||
projects: "PROJECT",
|
||||
skills: "SKILLS",
|
||||
custom: "CUSTOM SECTION",
|
||||
},
|
||||
formsOrder: ["workExperiences", "educations", "projects", "skills", "custom"],
|
||||
showBulletPoints: {
|
||||
educations: true,
|
||||
projects: true,
|
||||
skills: true,
|
||||
custom: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const settingsSlice = createSlice({
|
||||
name: "settings",
|
||||
initialState: initialSettings,
|
||||
reducers: {
|
||||
changeSettings: (
|
||||
draft,
|
||||
action: PayloadAction<{ field: GeneralSetting; value: string }>
|
||||
) => {
|
||||
const { field, value } = action.payload;
|
||||
draft[field] = value;
|
||||
},
|
||||
changeShowForm: (
|
||||
draft,
|
||||
action: PayloadAction<{ field: ShowForm; value: boolean }>
|
||||
) => {
|
||||
const { field, value } = action.payload;
|
||||
draft.formToShow[field] = value;
|
||||
},
|
||||
changeFormHeading: (
|
||||
draft,
|
||||
action: PayloadAction<{ field: ShowForm; value: string }>
|
||||
) => {
|
||||
const { field, value } = action.payload;
|
||||
draft.formToHeading[field] = value;
|
||||
},
|
||||
changeFormOrder: (
|
||||
draft,
|
||||
action: PayloadAction<{ form: ShowForm; type: "up" | "down" }>
|
||||
) => {
|
||||
const { form, type } = action.payload;
|
||||
const lastIdx = draft.formsOrder.length - 1;
|
||||
const pos = draft.formsOrder.indexOf(form);
|
||||
const newPos = type === "up" ? pos - 1 : pos + 1;
|
||||
const swapFormOrder = (idx1: number, idx2: number) => {
|
||||
const temp = draft.formsOrder[idx1];
|
||||
draft.formsOrder[idx1] = draft.formsOrder[idx2];
|
||||
draft.formsOrder[idx2] = temp;
|
||||
};
|
||||
if (newPos >= 0 && newPos <= lastIdx) {
|
||||
swapFormOrder(pos, newPos);
|
||||
}
|
||||
},
|
||||
changeShowBulletPoints: (
|
||||
draft,
|
||||
action: PayloadAction<{
|
||||
field: FormWithBulletPoints;
|
||||
value: boolean;
|
||||
}>
|
||||
) => {
|
||||
const { field, value } = action.payload;
|
||||
draft["showBulletPoints"][field] = value;
|
||||
},
|
||||
setSettings: (draft, action: PayloadAction<Settings>) => {
|
||||
return action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
changeSettings,
|
||||
changeShowForm,
|
||||
changeFormHeading,
|
||||
changeFormOrder,
|
||||
changeShowBulletPoints,
|
||||
setSettings,
|
||||
} = settingsSlice.actions;
|
||||
|
||||
export const selectSettings = (state: RootState) => state.settings;
|
||||
export const selectThemeColor = (state: RootState) => state.settings.themeColor;
|
||||
|
||||
export const selectFormToShow = (state: RootState) => state.settings.formToShow;
|
||||
export const selectShowByForm = (form: ShowForm) => (state: RootState) =>
|
||||
state.settings.formToShow[form];
|
||||
|
||||
export const selectFormToHeading = (state: RootState) =>
|
||||
state.settings.formToHeading;
|
||||
export const selectHeadingByForm = (form: ShowForm) => (state: RootState) =>
|
||||
state.settings.formToHeading[form];
|
||||
|
||||
export const selectFormsOrder = (state: RootState) => state.settings.formsOrder;
|
||||
export const selectIsFirstForm = (form: ShowForm) => (state: RootState) =>
|
||||
state.settings.formsOrder[0] === form;
|
||||
export const selectIsLastForm = (form: ShowForm) => (state: RootState) =>
|
||||
state.settings.formsOrder[state.settings.formsOrder.length - 1] === form;
|
||||
|
||||
export const selectShowBulletPoints =
|
||||
(form: FormWithBulletPoints) => (state: RootState) =>
|
||||
state.settings.showBulletPoints[form];
|
||||
|
||||
export default settingsSlice.reducer;
|
||||
13
open-resume/src/app/lib/redux/store.ts
Normal file
13
open-resume/src/app/lib/redux/store.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import resumeReducer from "lib/redux/resumeSlice";
|
||||
import settingsReducer from "lib/redux/settingsSlice";
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
resume: resumeReducer,
|
||||
settings: settingsReducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
54
open-resume/src/app/lib/redux/types.ts
Normal file
54
open-resume/src/app/lib/redux/types.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export interface ResumeProfile {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
url: string;
|
||||
summary: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export interface ResumeWorkExperience {
|
||||
company: string;
|
||||
jobTitle: string;
|
||||
date: string;
|
||||
descriptions: string[];
|
||||
}
|
||||
|
||||
export interface ResumeEducation {
|
||||
school: string;
|
||||
degree: string;
|
||||
date: string;
|
||||
gpa: string;
|
||||
descriptions: string[];
|
||||
}
|
||||
|
||||
export interface ResumeProject {
|
||||
project: string;
|
||||
date: string;
|
||||
descriptions: string[];
|
||||
}
|
||||
|
||||
export interface FeaturedSkill {
|
||||
skill: string;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export interface ResumeSkills {
|
||||
featuredSkills: FeaturedSkill[];
|
||||
descriptions: string[];
|
||||
}
|
||||
|
||||
export interface ResumeCustom {
|
||||
descriptions: string[];
|
||||
}
|
||||
|
||||
export interface Resume {
|
||||
profile: ResumeProfile;
|
||||
workExperiences: ResumeWorkExperience[];
|
||||
educations: ResumeEducation[];
|
||||
projects: ResumeProject[];
|
||||
skills: ResumeSkills;
|
||||
custom: ResumeCustom;
|
||||
}
|
||||
|
||||
export type ResumeKey = keyof Resume;
|
||||
17
open-resume/src/app/page.tsx
Normal file
17
open-resume/src/app/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Hero } from "home/Hero";
|
||||
import { Steps } from "home/Steps";
|
||||
import { Features } from "home/Features";
|
||||
import { Testimonials } from "home/Testimonials";
|
||||
import { QuestionsAndAnswers } from "home/QuestionsAndAnswers";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="mx-auto max-w-screen-2xl bg-dot px-8 pb-32 text-gray-900 lg:px-12">
|
||||
<Hero />
|
||||
<Steps />
|
||||
<Features />
|
||||
<Testimonials />
|
||||
<QuestionsAndAnswers />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
22
open-resume/src/app/resume-builder/page.tsx
Normal file
22
open-resume/src/app/resume-builder/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
import { Provider } from "react-redux";
|
||||
import { store } from "lib/redux/store";
|
||||
import { ResumeForm } from "components/ResumeForm";
|
||||
import { Resume } from "components/Resume";
|
||||
|
||||
export default function Create() {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<main className="relative h-full w-full overflow-hidden bg-gray-50">
|
||||
<div className="grid grid-cols-3 md:grid-cols-6">
|
||||
<div className="col-span-3">
|
||||
<ResumeForm />
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<Resume />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
93
open-resume/src/app/resume-import/page.tsx
Normal file
93
open-resume/src/app/resume-import/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
import { getHasUsedAppBefore } from "lib/redux/local-storage";
|
||||
import { ResumeDropzone } from "components/ResumeDropzone";
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ImportResume() {
|
||||
const [hasUsedAppBefore, setHasUsedAppBefore] = useState(false);
|
||||
const [hasAddedResume, setHasAddedResume] = useState(false);
|
||||
const onFileUrlChange = (fileUrl: string) => {
|
||||
setHasAddedResume(Boolean(fileUrl));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setHasUsedAppBefore(getHasUsedAppBefore());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="mx-auto mt-14 max-w-3xl rounded-md border border-gray-200 px-10 py-10 text-center shadow-md">
|
||||
{!hasUsedAppBefore ? (
|
||||
<>
|
||||
<h1 className="text-lg font-semibold text-gray-900">
|
||||
Import data from an existing resume
|
||||
</h1>
|
||||
<ResumeDropzone
|
||||
onFileUrlChange={onFileUrlChange}
|
||||
className="mt-5"
|
||||
/>
|
||||
{!hasAddedResume && (
|
||||
<>
|
||||
<OrDivider />
|
||||
<SectionWithHeadingAndCreateButton
|
||||
heading="Don't have a resume yet?"
|
||||
buttonText="Create from scratch"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!hasAddedResume && (
|
||||
<>
|
||||
<SectionWithHeadingAndCreateButton
|
||||
heading="You have data saved in browser from prior session"
|
||||
buttonText="Continue where I left off"
|
||||
/>
|
||||
<OrDivider />
|
||||
</>
|
||||
)}
|
||||
<h1 className="font-semibold text-gray-900">
|
||||
Override data with a new resume
|
||||
</h1>
|
||||
<ResumeDropzone
|
||||
onFileUrlChange={onFileUrlChange}
|
||||
className="mt-5"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const OrDivider = () => (
|
||||
<div className="mx-[-2.5rem] flex items-center pb-6 pt-8" aria-hidden="true">
|
||||
<div className="flex-grow border-t border-gray-200" />
|
||||
<span className="mx-2 mt-[-2px] flex-shrink text-lg text-gray-400">or</span>
|
||||
<div className="flex-grow border-t border-gray-200" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const SectionWithHeadingAndCreateButton = ({
|
||||
heading,
|
||||
buttonText,
|
||||
}: {
|
||||
heading: string;
|
||||
buttonText: string;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<p className="font-semibold text-gray-900">{heading}</p>
|
||||
<div className="mt-5">
|
||||
<Link
|
||||
href="/resume-builder"
|
||||
className="outline-theme-blue rounded-full bg-sky-500 px-6 pb-2 pt-1.5 text-base font-semibold text-white"
|
||||
>
|
||||
{buttonText}
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,472 @@
|
||||
import { isBold } from "lib/parse-resume-from-pdf/extract-resume-from-sections/lib/common-features";
|
||||
import {
|
||||
Badge,
|
||||
Heading,
|
||||
Link,
|
||||
Paragraph,
|
||||
Table,
|
||||
} from "components/documentation";
|
||||
import type {
|
||||
Line,
|
||||
Lines,
|
||||
ResumeSectionToLines,
|
||||
TextItem,
|
||||
TextItems,
|
||||
TextScores,
|
||||
} from "lib/parse-resume-from-pdf/types";
|
||||
import { extractProfile } from "lib/parse-resume-from-pdf/extract-resume-from-sections/extract-profile";
|
||||
|
||||
export const ResumeParserAlgorithmArticle = ({
|
||||
textItems,
|
||||
lines,
|
||||
sections,
|
||||
}: {
|
||||
textItems: TextItems;
|
||||
lines: Lines;
|
||||
sections: ResumeSectionToLines;
|
||||
}) => {
|
||||
const getBadgeContent = (item: TextItem) => {
|
||||
const X1 = Math.round(item.x);
|
||||
const X2 = Math.round(item.x + item.width);
|
||||
const Y = Math.round(item.y);
|
||||
let content = `X₁=${X1} X₂=${X2} Y=${Y}`;
|
||||
if (X1 === X2) {
|
||||
content = `X=${X2} Y=${Y}`;
|
||||
}
|
||||
if (isBold(item)) {
|
||||
content = `${content} Bold`;
|
||||
}
|
||||
if (item.hasEOL) {
|
||||
content = `${content} NewLine`;
|
||||
}
|
||||
return content;
|
||||
};
|
||||
const step1TextItemsTable = [
|
||||
["#", "Text Content", "Metadata"],
|
||||
...textItems.map((item, idx) => [
|
||||
idx + 1,
|
||||
item.text,
|
||||
<Badge key={idx}>{getBadgeContent(item)}</Badge>,
|
||||
]),
|
||||
];
|
||||
|
||||
const step2LinesTable = [
|
||||
["Lines", "Line Content"],
|
||||
...lines.map((line, idx) => [
|
||||
idx + 1,
|
||||
line.map((item, idx) => (
|
||||
<span key={idx}>
|
||||
{item.text}
|
||||
{idx !== line.length - 1 && (
|
||||
<span className="select-none font-extrabold text-sky-400">
|
||||
{"|"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)),
|
||||
]),
|
||||
];
|
||||
|
||||
const { profile, profileScores } = extractProfile(sections);
|
||||
const Scores = ({ scores }: { scores: TextScores }) => {
|
||||
return (
|
||||
<>
|
||||
{scores
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map((item, idx) => (
|
||||
<span key={idx} className="break-all">
|
||||
<Badge>{item.score}</Badge> {item.text}
|
||||
<br />
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
const step4ProfileFeatureScoresTable = [
|
||||
[
|
||||
"Resume Attribute",
|
||||
"Text (Highest Feature Score)",
|
||||
"Feature Scores of Other Texts",
|
||||
],
|
||||
["Name", profile.name, <Scores key={"Name"} scores={profileScores.name} />],
|
||||
[
|
||||
"Email",
|
||||
profile.email,
|
||||
<Scores key={"Email"} scores={profileScores.email} />,
|
||||
],
|
||||
[
|
||||
"Phone",
|
||||
profile.phone,
|
||||
<Scores key={"Phone"} scores={profileScores.phone} />,
|
||||
],
|
||||
];
|
||||
|
||||
return (
|
||||
<article className="mt-10">
|
||||
<Heading className="text-primary !mt-0 border-t-2 pt-8">
|
||||
Resume Parser Algorithm Deep Dive
|
||||
</Heading>
|
||||
<Paragraph smallMarginTop={true}>
|
||||
For the technical curious, this section will dive into the OpenResume
|
||||
parser algorithm and walks through the 4 steps on how it works. (Note
|
||||
that the algorithm is designed to parse single column resume in English
|
||||
language)
|
||||
</Paragraph>
|
||||
{/* Step 1. Read the text items from a PDF file */}
|
||||
<Heading level={2}>Step 1. Read the text items from a PDF file</Heading>
|
||||
<Paragraph smallMarginTop={true}>
|
||||
A PDF file is a standardized file format defined by the{" "}
|
||||
<Link href="https://www.iso.org/standard/51502.html">
|
||||
ISO 32000 specification
|
||||
</Link>
|
||||
. When you open up a PDF file using a text editor, you'll notice that
|
||||
the raw content looks encoded and is difficult to read. To display it in
|
||||
a readable format, you would need a PDF reader to decode and view the
|
||||
file. Similarly, the resume parser first needs to decode the PDF file in
|
||||
order to extract its text content.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
While it is possible to write a custom PDF reader function following the
|
||||
ISO 32000 specification, it is much simpler to leverage an existing
|
||||
library. In this case, the resume parser uses Mozilla's open source{" "}
|
||||
<Link href="https://github.com/mozilla/pdf.js">pdf.js</Link> library to
|
||||
first extract all the text items in the file.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
The table below lists {textItems.length} text items that are extracted
|
||||
from the resume PDF added. A text item contains the text content and
|
||||
also some metadata about the content, e.g. its x, y positions in the
|
||||
document, whether the font is bolded, or whether it starts a new line.
|
||||
(Note that x,y position is relative to the bottom left corner of the
|
||||
page, which is the origin 0,0)
|
||||
</Paragraph>
|
||||
<div className="mt-4 max-h-72 overflow-y-scroll border scrollbar scrollbar-track-gray-100 scrollbar-thumb-gray-200 scrollbar-w-3">
|
||||
<Table
|
||||
table={step1TextItemsTable}
|
||||
className="!border-none"
|
||||
tdClassNames={["", "", "md:whitespace-nowrap"]}
|
||||
/>
|
||||
</div>
|
||||
{/* Step 2. Group text items into lines */}
|
||||
<Heading level={2}>Step 2. Group text items into lines</Heading>
|
||||
<Paragraph smallMarginTop={true}>
|
||||
The extracted text items aren't ready to use yet and have 2 main issues:
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<span className="mt-3 block font-semibold">
|
||||
Issue 1: They have some unwanted noises.
|
||||
</span>
|
||||
Some single text items can get broken into multiple ones, as you might
|
||||
observe on the table above, e.g. a phone number "(123) 456-7890" might
|
||||
be broken into 3 text items "(123) 456", "-" and "7890".
|
||||
</Paragraph>
|
||||
<Paragraph smallMarginTop={true}>
|
||||
<span className="font-semibold">Solution:</span> To tackle this issue,
|
||||
the resume parser connects adjacent text items into one text item if
|
||||
their distance is smaller than the average typical character width,
|
||||
where
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `<math display="block">
|
||||
<mrow>
|
||||
<mn>Distance </mn>
|
||||
<mo>=</mo>
|
||||
<mn>RightTextItemX₁</mn>
|
||||
<mo>-</mo>
|
||||
<mn>LeftTextItemX₂</mn>
|
||||
</mrow>
|
||||
</math>`,
|
||||
}}
|
||||
className="my-2 block text-left text-base"
|
||||
/>
|
||||
The average typical character width is calculated by dividing the sum of
|
||||
all text items' widths by the total number characters of the text items
|
||||
(Bolded texts and new line elements are excluded to not skew the
|
||||
results).
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<span className="mt-3 block font-semibold">
|
||||
Issue 2: They lack contexts and associations.
|
||||
</span>
|
||||
When we read a resume, we scan a resume line by line. Our brains can
|
||||
process each section via visual cues such as texts' boldness and
|
||||
proximity, where we can quickly associate texts closer together to be a
|
||||
related group. The extracted text items however currently don't have
|
||||
those contexts/associations and are just disjointed elements.
|
||||
</Paragraph>
|
||||
<Paragraph smallMarginTop={true}>
|
||||
<span className="font-semibold">Solution:</span> To tackle this issue,
|
||||
the resume parser reconstructs those contexts and associations similar
|
||||
to how our brain would read and process the resume. It first groups text
|
||||
items into lines since we read text line by line. It then groups lines
|
||||
into sections, which will be discussed in the next step.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
At the end of step 2, the resume parser extracts {lines.length} lines
|
||||
from the resume PDF added, as shown in the table below. The result is
|
||||
much more readable when displayed in lines. (Some lines might have
|
||||
multiple text items, which are separated by a blue vertical divider{" "}
|
||||
<span className="select-none font-extrabold text-sky-400">
|
||||
{"|"}
|
||||
</span>
|
||||
)
|
||||
</Paragraph>
|
||||
<div className="mt-4 max-h-96 overflow-y-scroll border scrollbar scrollbar-track-gray-100 scrollbar-thumb-gray-200 scrollbar-w-3">
|
||||
<Table table={step2LinesTable} className="!border-none" />
|
||||
</div>
|
||||
{/* Step 3. Group lines into sections */}
|
||||
<Heading level={2}>Step 3. Group lines into sections</Heading>
|
||||
<Paragraph smallMarginTop={true}>
|
||||
At step 2, the resume parser starts building contexts and associations
|
||||
to text items by first grouping them into lines. Step 3 continues the
|
||||
process to build additional associations by grouping lines into
|
||||
sections.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Note that every section (except the profile section) starts with a
|
||||
section title that takes up the entire line. This is a common pattern
|
||||
not just in resumes but also in books and blogs. The resume parser uses
|
||||
this pattern to group lines into the closest section title above these
|
||||
lines.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
The resume parser applies some heuristics to detect a section title. The
|
||||
main heuristic to determine a section title is to check if it fulfills
|
||||
all 3 following conditions: <br />
|
||||
1. It is the only text item in the line <br />
|
||||
2. It is <span className="font-bold">bolded</span> <br />
|
||||
3. Its letters are all UPPERCASE
|
||||
<br />
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
In simple words, if a text item is double emphasized to be both bolded
|
||||
and uppercase, it is most likely a section title in a resume. This is
|
||||
generally true for a well formatted resume. There can be exceptions, but
|
||||
it is likely not a good use of bolded and uppercase in those cases.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
The resume parser also has a fallback heuristic if the main heuristic
|
||||
doesn't apply. The fallback heuristic mainly performs a keyword matching
|
||||
against a list of common resume section title keywords.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
At the end of step 3, the resume parser identifies the sections from the
|
||||
resume and groups those lines with the associated section title, as
|
||||
shown in the table below. Note that{" "}
|
||||
<span className="font-bold">the section titles are bolded</span> and{" "}
|
||||
<span className="bg-teal-50">
|
||||
the lines associated with the section are highlighted with the same
|
||||
colors
|
||||
</span>
|
||||
.
|
||||
</Paragraph>
|
||||
<Step3SectionsTable sections={sections} />
|
||||
{/* Step 4. Extract resume from sections */}
|
||||
<Heading level={2}>Step 4. Extract resume from sections</Heading>
|
||||
<Paragraph smallMarginTop={true}>
|
||||
Step 4 is the last step of the resume parsing process and is also the
|
||||
core of the resume parser, where it extracts resume information from the
|
||||
sections.
|
||||
</Paragraph>
|
||||
<Heading level={3}>Feature Scoring System</Heading>
|
||||
<Paragraph smallMarginTop={true}>
|
||||
The gist of the extraction engine is a feature scoring system. Each
|
||||
resume attribute to be extracted has a custom feature sets, where each
|
||||
feature set consists of a feature matching function and a feature
|
||||
matching score if matched (feature matching score can be a positive or
|
||||
negative number). To compute the final feature score of a text item for
|
||||
a particular resume attribute, it would run the text item through all
|
||||
its feature sets and sum up the matching feature scores. This process is
|
||||
carried out for all text items within the section, and the text item
|
||||
with the highest computed feature score is identified as the extracted
|
||||
resume attribute.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
As a demonstration, the table below shows 3 resume attributes in the
|
||||
profile section of the resume PDF added.
|
||||
</Paragraph>
|
||||
<Table table={step4ProfileFeatureScoresTable} className="mt-4" />
|
||||
{(profileScores.name.find((item) => item.text === profile.name)?.score ||
|
||||
0) > 0 && (
|
||||
<Paragraph smallMarginTop={true}>
|
||||
In the resume PDF added, the resume attribute name is likely to be "
|
||||
{profile.name}" since its feature score is{" "}
|
||||
{profileScores.name.find((item) => item.text === profile.name)?.score}
|
||||
, which is the highest feature score out of all text items in the
|
||||
profile section. (Some text items' feature scores can be negative,
|
||||
indicating they are very unlikely to be the targeted attribute)
|
||||
</Paragraph>
|
||||
)}
|
||||
<Heading level={3}>Feature Sets</Heading>
|
||||
<Paragraph smallMarginTop={true}>
|
||||
Having explained the feature scoring system, we can dive more into how
|
||||
feature sets are constructed for a resume attribute. It follows 2
|
||||
principles: <br />
|
||||
1. A resume attribute's feature sets are designed relative to all other
|
||||
resume attributes within the same section. <br />
|
||||
2. A resume attribute's feature sets are manually crafted based on its
|
||||
characteristics and likelihood of each characteristic.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
The table below lists some of the feature sets for the resume attribute
|
||||
name. It contains feature function that matches the name attribute with
|
||||
positive feature score and also feature function that only matches other
|
||||
resume attributes in the section with negative feature score.
|
||||
</Paragraph>
|
||||
<Table
|
||||
table={step4NameFeatureSetsTable}
|
||||
title="Name Feature Sets"
|
||||
className="mt-4"
|
||||
/>
|
||||
<Heading level={3}>Core Feature Function</Heading>
|
||||
<Paragraph smallMarginTop={true}>
|
||||
Each resume attribute has multiple feature sets. They can be found in
|
||||
the source code under the extract-resume-from-sections folder and we
|
||||
won't list them all out here. Each resume attribute usually has a core
|
||||
feature function that greatly identifies them, so we will list out the
|
||||
core feature function below.
|
||||
</Paragraph>
|
||||
<Table table={step4CoreFeatureFunctionTable} className="mt-4" />
|
||||
<Heading level={3}>Special Case: Subsections</Heading>
|
||||
<Paragraph smallMarginTop={true}>
|
||||
The last thing that is worth mentioning is subsections. For profile
|
||||
section, we can directly pass all the text items to the feature scoring
|
||||
systems. But for other sections, such as education and work experience,
|
||||
we have to first divide the section into subsections since there can be
|
||||
multiple schools or work experiences in the section. The feature scoring
|
||||
system then process each subsection to retrieve each's resume attributes
|
||||
and append the results.
|
||||
</Paragraph>
|
||||
<Paragraph smallMarginTop={true}>
|
||||
The resume parser applies some heuristics to detect a subsection. The
|
||||
main heuristic to determine a subsection is to check if the vertical
|
||||
line gap between 2 lines is larger than the typical line gap * 1.4,
|
||||
since a well formatted resume usually creates a new empty line break
|
||||
before adding the next subsection. There is also a fallback heuristic if
|
||||
the main heuristic doesn't apply to check if the text item is bolded.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
And that is everything about the OpenResume parser algorithm :)
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Written by <Link href="https://github.com/xitanggg">Xitang</Link> on
|
||||
June 2023
|
||||
</Paragraph>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
const step4NameFeatureSetsTable = [
|
||||
["Feature Function", "Feature Matching Score"],
|
||||
["Contains only letters, spaces or periods", "+3"],
|
||||
["Is bolded", "+2"],
|
||||
["Contains all uppercase letters", "+2"],
|
||||
["Contains @", "-4 (match email)"],
|
||||
["Contains number", "-4 (match phone)"],
|
||||
["Contains ,", "-4 (match address)"],
|
||||
["Contains /", "-4 (match url)"],
|
||||
];
|
||||
|
||||
const step4CoreFeatureFunctionTable = [
|
||||
["Resume Attribute", "Core Feature Function", "Regex"],
|
||||
["Name", "Contains only letters, spaces or periods", "/^[a-zA-Z\\s\\.]+$/"],
|
||||
[
|
||||
"Email",
|
||||
<>
|
||||
Match email format xxx@xxx.xxx
|
||||
<br />
|
||||
xxx can be anything not space
|
||||
</>,
|
||||
"/\\S+@\\S+\\.\\S+/",
|
||||
],
|
||||
[
|
||||
"Phone",
|
||||
<>
|
||||
Match phone format (xxx)-xxx-xxxx <br /> () and - are optional
|
||||
</>,
|
||||
"/\\(?\\d{3}\\)?[\\s-]?\\d{3}[\\s-]?\\d{4}/",
|
||||
],
|
||||
[
|
||||
"Location",
|
||||
<>Match city and state format {"City, ST"}</>,
|
||||
"/[A-Z][a-zA-Z\\s]+, [A-Z]{2}/",
|
||||
],
|
||||
["Url", "Match url format xxx.xxx/xxx", "/\\S+\\.[a-z]+\\/\\S+/"],
|
||||
["School", "Contains a school keyword, e.g. College, University, School", ""],
|
||||
["Degree", "Contains a degree keyword, e.g. Associate, Bachelor, Master", ""],
|
||||
["GPA", "Match GPA format x.xx", "/[0-4]\\.\\d{1,2}/"],
|
||||
[
|
||||
"Date",
|
||||
"Contains date keyword related to year, month, seasons or the word Present",
|
||||
"Year: /(?:19|20)\\d{2}/",
|
||||
],
|
||||
[
|
||||
"Job Title",
|
||||
"Contains a job title keyword, e.g. Analyst, Engineer, Intern",
|
||||
"",
|
||||
],
|
||||
["Company", "Is bolded or doesn't match job title & date", ""],
|
||||
["Project", "Is bolded or doesn't match date", ""],
|
||||
];
|
||||
|
||||
const Step3SectionsTable = ({
|
||||
sections,
|
||||
}: {
|
||||
sections: ResumeSectionToLines;
|
||||
}) => {
|
||||
const table: React.ReactNode[][] = [["Lines", "Line Content"]];
|
||||
const trClassNames = [];
|
||||
let lineCounter = 0;
|
||||
const BACKGROUND_COLORS = [
|
||||
"bg-red-50",
|
||||
"bg-yellow-50",
|
||||
"bg-orange-50",
|
||||
"bg-green-50",
|
||||
"bg-blue-50",
|
||||
"bg-purple-50",
|
||||
] as const;
|
||||
const sectionsEntries = Object.entries(sections);
|
||||
|
||||
const Line = ({ line }: { line: Line }) => {
|
||||
return (
|
||||
<>
|
||||
{line.map((item, idx) => (
|
||||
<span key={idx}>
|
||||
{item.text}
|
||||
{idx !== line.length - 1 && (
|
||||
<span className="select-none font-extrabold text-sky-400">
|
||||
{"|"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
for (let i = 0; i < sectionsEntries.length; i++) {
|
||||
const sectionBackgroundColor = BACKGROUND_COLORS[i % 6];
|
||||
const [sectionTitle, lines] = sectionsEntries[i];
|
||||
table.push([
|
||||
sectionTitle === "profile" ? "" : lineCounter,
|
||||
sectionTitle === "profile" ? "PROFILE" : sectionTitle,
|
||||
]);
|
||||
trClassNames.push(`${sectionBackgroundColor} font-bold`);
|
||||
lineCounter += 1;
|
||||
for (let j = 0; j < lines.length; j++) {
|
||||
table.push([lineCounter, <Line key={lineCounter} line={lines[j]} />]);
|
||||
trClassNames.push(sectionBackgroundColor);
|
||||
lineCounter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 max-h-96 overflow-y-scroll border scrollbar scrollbar-track-gray-100 scrollbar-thumb-gray-200 scrollbar-w-3">
|
||||
<Table
|
||||
table={table}
|
||||
className="!border-none"
|
||||
trClassNames={trClassNames}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
127
open-resume/src/app/resume-parser/ResumeTable.tsx
Normal file
127
open-resume/src/app/resume-parser/ResumeTable.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Fragment } from "react";
|
||||
import type { Resume } from "lib/redux/types";
|
||||
import { initialEducation, initialWorkExperience } from "lib/redux/resumeSlice";
|
||||
import { deepClone } from "lib/deep-clone";
|
||||
import { cx } from "lib/cx";
|
||||
|
||||
const TableRowHeader = ({ children }: { children: React.ReactNode }) => (
|
||||
<tr className="divide-x bg-gray-50">
|
||||
<th className="px-3 py-2 font-semibold" scope="colgroup" colSpan={2}>
|
||||
{children}
|
||||
</th>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const TableRow = ({
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | string[];
|
||||
className?: string | false;
|
||||
}) => (
|
||||
<tr className={cx("divide-x", className)}>
|
||||
<th className="px-3 py-2 font-medium" scope="row">
|
||||
{label}
|
||||
</th>
|
||||
<td className="w-full px-3 py-2">
|
||||
{typeof value === "string"
|
||||
? value
|
||||
: value.map((x, idx) => (
|
||||
<Fragment key={idx}>
|
||||
• {x}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
export const ResumeTable = ({ resume }: { resume: Resume }) => {
|
||||
const educations =
|
||||
resume.educations.length === 0
|
||||
? [deepClone(initialEducation)]
|
||||
: resume.educations;
|
||||
const workExperiences =
|
||||
resume.workExperiences.length === 0
|
||||
? [deepClone(initialWorkExperience)]
|
||||
: resume.workExperiences;
|
||||
const skills = [...resume.skills.descriptions];
|
||||
const featuredSkills = resume.skills.featuredSkills
|
||||
.filter((item) => item.skill.trim())
|
||||
.map((item) => item.skill)
|
||||
.join(", ")
|
||||
.trim();
|
||||
if (featuredSkills) {
|
||||
skills.unshift(featuredSkills);
|
||||
}
|
||||
return (
|
||||
<table className="mt-2 w-full border text-sm text-gray-900">
|
||||
<tbody className="divide-y text-left align-top">
|
||||
<TableRowHeader>Profile</TableRowHeader>
|
||||
<TableRow label="Name" value={resume.profile.name} />
|
||||
<TableRow label="Email" value={resume.profile.email} />
|
||||
<TableRow label="Phone" value={resume.profile.phone} />
|
||||
<TableRow label="Location" value={resume.profile.location} />
|
||||
<TableRow label="Link" value={resume.profile.url} />
|
||||
<TableRow label="Summary" value={resume.profile.summary} />
|
||||
<TableRowHeader>Education</TableRowHeader>
|
||||
{educations.map((education, idx) => (
|
||||
<Fragment key={idx}>
|
||||
<TableRow label="School" value={education.school} />
|
||||
<TableRow label="Degree" value={education.degree} />
|
||||
<TableRow label="GPA" value={education.gpa} />
|
||||
<TableRow label="Date" value={education.date} />
|
||||
<TableRow
|
||||
label="Descriptions"
|
||||
value={education.descriptions}
|
||||
className={
|
||||
educations.length - 1 !== 0 &&
|
||||
idx !== educations.length - 1 &&
|
||||
"!border-b-4"
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
<TableRowHeader>Work Experience</TableRowHeader>
|
||||
{workExperiences.map((workExperience, idx) => (
|
||||
<Fragment key={idx}>
|
||||
<TableRow label="Company" value={workExperience.company} />
|
||||
<TableRow label="Job Title" value={workExperience.jobTitle} />
|
||||
<TableRow label="Date" value={workExperience.date} />
|
||||
<TableRow
|
||||
label="Descriptions"
|
||||
value={workExperience.descriptions}
|
||||
className={
|
||||
workExperiences.length - 1 !== 0 &&
|
||||
idx !== workExperiences.length - 1 &&
|
||||
"!border-b-4"
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
{resume.projects.length > 0 && (
|
||||
<TableRowHeader>Projects</TableRowHeader>
|
||||
)}
|
||||
{resume.projects.map((project, idx) => (
|
||||
<Fragment key={idx}>
|
||||
<TableRow label="Project" value={project.project} />
|
||||
<TableRow label="Date" value={project.date} />
|
||||
<TableRow
|
||||
label="Descriptions"
|
||||
value={project.descriptions}
|
||||
className={
|
||||
resume.projects.length - 1 !== 0 &&
|
||||
idx !== resume.projects.length - 1 &&
|
||||
"!border-b-4"
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
<TableRowHeader>Skills</TableRowHeader>
|
||||
<TableRow label="Descriptions" value={skills} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
132
open-resume/src/app/resume-parser/page.tsx
Normal file
132
open-resume/src/app/resume-parser/page.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { readPdf } from "lib/parse-resume-from-pdf/read-pdf";
|
||||
import type { TextItems } from "lib/parse-resume-from-pdf/types";
|
||||
import { groupTextItemsIntoLines } from "lib/parse-resume-from-pdf/group-text-items-into-lines";
|
||||
import { groupLinesIntoSections } from "lib/parse-resume-from-pdf/group-lines-into-sections";
|
||||
import { extractResumeFromSections } from "lib/parse-resume-from-pdf/extract-resume-from-sections";
|
||||
import { ResumeDropzone } from "components/ResumeDropzone";
|
||||
import { cx } from "lib/cx";
|
||||
import { Heading, Link, Paragraph } from "components/documentation";
|
||||
import { ResumeTable } from "resume-parser/ResumeTable";
|
||||
import { FlexboxSpacer } from "components/FlexboxSpacer";
|
||||
import { ResumeParserAlgorithmArticle } from "resume-parser/ResumeParserAlgorithmArticle";
|
||||
|
||||
const RESUME_EXAMPLES = [
|
||||
{
|
||||
fileUrl: "resume-example/laverne-resume.pdf",
|
||||
description: (
|
||||
<span>
|
||||
Borrowed from University of La Verne Career Center -{" "}
|
||||
<Link href="https://laverne.edu/careers/wp-content/uploads/sites/15/2010/12/Undergraduate-Student-Resume-Examples.pdf">
|
||||
Link
|
||||
</Link>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
fileUrl: "resume-example/openresume-resume.pdf",
|
||||
description: (
|
||||
<span>
|
||||
Created with OpenResume resume builder -{" "}
|
||||
<Link href="/resume-builder">Link</Link>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const defaultFileUrl = RESUME_EXAMPLES[0]["fileUrl"];
|
||||
export default function ResumeParser() {
|
||||
const [fileUrl, setFileUrl] = useState(defaultFileUrl);
|
||||
const [textItems, setTextItems] = useState<TextItems>([]);
|
||||
const lines = groupTextItemsIntoLines(textItems || []);
|
||||
const sections = groupLinesIntoSections(lines);
|
||||
const resume = extractResumeFromSections(sections);
|
||||
|
||||
useEffect(() => {
|
||||
async function test() {
|
||||
const textItems = await readPdf(fileUrl);
|
||||
setTextItems(textItems);
|
||||
}
|
||||
test();
|
||||
}, [fileUrl]);
|
||||
|
||||
return (
|
||||
<main className="h-full w-full overflow-hidden">
|
||||
<div className="grid md:grid-cols-6">
|
||||
<div className="flex justify-center px-2 md:col-span-3 md:h-[calc(100vh-var(--top-nav-bar-height))] md:justify-end">
|
||||
<section className="mt-5 grow px-4 md:max-w-[600px] md:px-0">
|
||||
<div className="aspect-h-[9.5] aspect-w-7">
|
||||
<iframe src={`${fileUrl}#navpanes=0`} className="h-full w-full" />
|
||||
</div>
|
||||
</section>
|
||||
<FlexboxSpacer maxWidth={45} className="hidden md:block" />
|
||||
</div>
|
||||
<div className="flex px-6 text-gray-900 md:col-span-3 md:h-[calc(100vh-var(--top-nav-bar-height))] md:overflow-y-scroll">
|
||||
<FlexboxSpacer maxWidth={45} className="hidden md:block" />
|
||||
<section className="max-w-[600px] grow">
|
||||
<Heading className="text-primary !mt-4">
|
||||
Resume Parser Playground
|
||||
</Heading>
|
||||
<Paragraph smallMarginTop={true}>
|
||||
This playground showcases the OpenResume resume parser and its
|
||||
ability to parse information from a resume PDF. Click around the
|
||||
PDF examples below to observe different parsing results.
|
||||
</Paragraph>
|
||||
<div className="mt-3 flex gap-3">
|
||||
{RESUME_EXAMPLES.map((example, idx) => (
|
||||
<article
|
||||
key={idx}
|
||||
className={cx(
|
||||
"flex-1 cursor-pointer rounded-md border-2 px-4 py-3 shadow-sm outline-none hover:bg-gray-50 focus:bg-gray-50",
|
||||
example.fileUrl === fileUrl
|
||||
? "border-blue-400"
|
||||
: "border-gray-300"
|
||||
)}
|
||||
onClick={() => setFileUrl(example.fileUrl)}
|
||||
onKeyDown={(e) => {
|
||||
if (["Enter", " "].includes(e.key))
|
||||
setFileUrl(example.fileUrl);
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
<h1 className="font-semibold">Resume Example {idx + 1}</h1>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
{example.description}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<Paragraph>
|
||||
You can also{" "}
|
||||
<span className="font-semibold">add your resume below</span> to
|
||||
access how well your resume would be parsed by similar Application
|
||||
Tracking Systems (ATS) used in job applications. The more
|
||||
information it can parse out, the better it indicates the resume
|
||||
is well formatted and easy to read. It is beneficial to have the
|
||||
name and email accurately parsed at the very least.
|
||||
</Paragraph>
|
||||
<div className="mt-3">
|
||||
<ResumeDropzone
|
||||
onFileUrlChange={(fileUrl) =>
|
||||
setFileUrl(fileUrl || defaultFileUrl)
|
||||
}
|
||||
playgroundView={true}
|
||||
/>
|
||||
</div>
|
||||
<Heading level={2} className="!mt-[1.2em]">
|
||||
Resume Parsing Results
|
||||
</Heading>
|
||||
<ResumeTable resume={resume} />
|
||||
<ResumeParserAlgorithmArticle
|
||||
textItems={textItems}
|
||||
lines={lines}
|
||||
sections={sections}
|
||||
/>
|
||||
<div className="pt-24" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user