Made the pdf pull resume data (not dynamic yet)

This commit is contained in:
2024-07-28 02:31:06 -05:00
parent 2eb5022840
commit e9b0b07a77
11 changed files with 472 additions and 279 deletions

View File

@@ -2,7 +2,7 @@
import { useRouter } from "next/navigation";
import { LuDownload } from "react-icons/lu";
import { Preview } from "~/app/(app)/resume/[id]/preview";
import { Preview } from "~/components/pdf-preview";
import { ResumeForm } from "~/components/form/resume-form";
import { LoadingSpinner } from "~/components/loading-spinner";
import { Button } from "~/components/ui/button";

View File

@@ -1,275 +0,0 @@
"use client";
import { type PropsWithChildren } from "react";
import { Card } from "~/components/ui/card";
import { type ResumeFormSchema } from "~/lib/schemas/resume-form-schema";
import { cn } from "~/lib/utils";
import {
PDFViewer,
Page,
Text,
View,
Document,
StyleSheet,
Link,
Font,
} from "@react-pdf/renderer";
export const Preview = ({ resume }: { resume?: ResumeFormSchema }) => {
return (
<Card
className={cn(
"flex aspect-paper max-h-full transition-colors",
!resume && "animate-pulse bg-neutral-50",
)}
>
{!!resume && (
<PDFViewer showToolbar={false} className="flex-1 bg-white">
<Doc resume={resume} />
</PDFViewer>
)}
</Card>
);
};
// Disable hyphenated words
Font.registerHyphenationCallback((word) => [word]);
// Import font
Font.register({
family: "Inter",
fonts: [
{ src: `/fonts/Inter-Regular.ttf` },
{ src: `/fonts/Inter-Bold.ttf`, fontWeight: "bold" },
],
});
const s = StyleSheet.create({
page: {
fontFamily: "Inter",
fontSize: 12,
padding: "0.75in",
// paddingVertical: "0.75in",
// paddingHorizontal: "1in",
lineHeight: 1.3,
},
bold: { fontWeight: "bold" },
row: { display: "flex", flexDirection: "row" },
h1: { fontSize: 18, fontWeight: "bold" },
h2: {
textTransform: "uppercase",
borderBottom: 1,
paddingBottom: 4,
marginTop: 8,
},
link: { color: "#1155CC" },
experienceHeading: {
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
marginTop: 8,
},
listContainer: {
marginTop: 4,
display: "flex",
flexDirection: "column",
gap: 2,
},
});
const ExperienceHeading = ({
role,
company,
startDate,
endDate = "Present",
}: {
role: string;
company?: string;
startDate?: string;
endDate?: string;
}) => {
const formattedCompany = company ? ` – ${company}` : "";
const formattedEndDate = endDate ? ` – ${endDate}` : "";
return (
<View style={s.experienceHeading}>
<Text style={s.bold}>
{role}
{formattedCompany}
</Text>
{startDate && (
<Text style={s.bold}>
{startDate}
{formattedEndDate}
</Text>
)}
</View>
);
};
const UnorderedList = ({ children }: PropsWithChildren) => {
return <View style={s.listContainer}>{children}</View>;
};
const ListItem = ({
heading,
children,
}: PropsWithChildren<{ heading?: string }>) => {
return (
<View style={s.row}>
<Text style={{ marginRight: 8 }}>•</Text>
<Text style={{ flex: 1 }}>
{heading && <Text style={s.bold}>{heading}: </Text>}
<Text>{children}</Text>
</Text>
</View>
);
};
const Doc = ({ resume }: { resume?: ResumeFormSchema }) => {
return (
<Document>
<Page size="A4" style={s.page}>
{/* Personal Details */}
<View>
<Text style={s.h1}>Zeke | Software Engineer</Text>
<Text>zeke@gmail.com | 123-456-7890</Text>
<Text>
<Link src="https://github.com/zeke" style={s.link}>
github.com/zeke
</Link>
{" | "}
<Link src="https://linkedin.com/in/zeke" style={s.link}>
linkedin.com/in/zeke
</Link>
{" | "}
<Link src="https://zeke.dev" style={s.link}>
zeke.dev
</Link>
</Text>
</View>
{/* Experience */}
<View>
<Text style={s.h2}>Professional Experience</Text>
<ExperienceHeading
role="Software Developer"
company="Company 2"
startDate="Nov 2022"
/>
<UnorderedList>
<ListItem>
Contributed to the development of the official Tomb Raider
website, building out several pages and core functionalities in
collaboration with ThreeSixtyEight.
</ListItem>
<ListItem>
Developed an automated testing suite with GitHub Actions to
prevent regression during a major migration from two native mobile
apps to a single Flutter app.
</ListItem>
<ListItem>
Conducted code reviews, mentored interns, and kept the team up to
date with the latest technologies and best practices.
</ListItem>
<ListItem>
Demonstrated multidisciplinary expertise in frontend development,
backend logic, quality assurance, and UI/UX design integration.
</ListItem>
<ListItem>
Boosted the company website’s Google Lighthouse score from 68 to
96 by improving overall site performance, screen-reader and
keyboard navigation accessibility, and SEO.
</ListItem>
<ListItem>
Graduated from intern to full-time employee in under 6 months,
reflecting proficiency and valuable contributions to the team and
company goals.
</ListItem>
<ListItem>
Consistently maintained high standards of code quality and project
delivery, earning a reputation for reliability and excellence.
</ListItem>
</UnorderedList>
<ExperienceHeading
role="Software Development Intern"
company="Company 1"
startDate="June 2022"
endDate="Nov 2022"
/>
<UnorderedList>
<ListItem>
Obtained secret-level clearance via rigorous Tier 3 OPM
investigation.
</ListItem>
<ListItem>
Modernized ArcMap plugin, transitioning 40-60% of the legacy VB
codebase to C#, MVVM, and WPF in ArcGIS Pro.
</ListItem>
<ListItem>
Reduced execution times from several hours to 1-2 minutes by
parallelizing expensive procedures
</ListItem>
<ListItem>
Created a comprehensive migration roadmap, offering ongoing
guidance for project completion.
</ListItem>
</UnorderedList>
</View>
{/* Skills */}
<View>
<Text style={s.h2}>Technical Skills</Text>
<UnorderedList>
<ListItem heading="Languages">
TypeScript, JavaScript, Python, Dart, C#, Java, and HTML/CSS
</ListItem>
<ListItem heading="Frameworks">
React.js, Next.js, Flutter, and React Native
</ListItem>
<ListItem heading="Backend">
Express.js, GitHub Actions, MySQL, PostgreSQL, and Firebase
</ListItem>
<ListItem heading="Testing">
Mocha, Chai, Cypress, and Mockito
</ListItem>
<ListItem heading="Other">
Data science, Agile/Scrum, Figma, Unity, Blender, GIMP, Inkscape,
and LaTeX
</ListItem>
</UnorderedList>
</View>
{/* Education */}
<View>
<Text style={s.h2}>Education</Text>
<UnorderedList>
<ListItem heading="Degrees">
B.S, Computer Science and B.S, Mathematics – Example State
University
</ListItem>
<ListItem heading="Courses">
Advanced data structures and Algorithms, Object-Oriented Design,
Computer Architecture and Design, Software Systems Development,
Machine Learning, and Competitive Programming
</ListItem>
<ListItem heading="Certifications">
Secret-level security clearance (Tier 3 Investigation), DELF B2
French proficiency
</ListItem>
<ListItem heading="Awards">
Peg and Tom Madden Mathematics Research Scholarship and
President’s Honor Roll
</ListItem>
</UnorderedList>
</View>
</Page>
</Document>
);
};

View File

@@ -26,9 +26,6 @@ import { AutoAnimate } from "~/components/wrappers/auto-animate";
import { useCVAutosave } from "~/hooks/cv/useCVAutosave";
import { getRelativeTime } from "~/lib/utils/dates";
// TODO: Add grab handles to list fields
// TODO: Add links to Projects
export const ResumeForm = ({
initialValues,
}: {

View File

@@ -0,0 +1,128 @@
import { Page, Text, View, Document, Link, Font } from "@react-pdf/renderer";
import { ReactNode } from "react";
import { styles } from "~/components/pdf-preview/styles";
import {
ExperienceHeading,
join,
ListItem,
ProjectHeading,
UnorderedList,
} from "~/components/pdf-preview/utils";
import { useFormatResume } from "~/hooks/resume/useFormatResume";
import { noPrefix } from "~/hooks/resume/useFormatResume/utils";
import { type ResumeFormSchema } from "~/lib/schemas/resume-form-schema";
import { intersperse } from "~/lib/utils/lists";
// Disable hyphenated words
Font.registerHyphenationCallback((word) => [word]);
// Import font
Font.register({
family: "Inter",
fonts: [
{ src: `/fonts/Inter-Regular.ttf` },
{ src: `/fonts/Inter-Bold.ttf`, fontWeight: "bold" },
],
});
export const Doc = ({ resume }: { resume?: ResumeFormSchema }) => {
const { experiences, projects, skills, education } = useFormatResume({
resume,
});
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Personal Details */}
<View>
<Text style={styles.h1}>
{join([
resume?.name,
// TODO: resume?.role
])}
</Text>
<Text>{join([resume?.email, resume?.phone])}</Text>
<Text>
{resume &&
intersperse<ReactNode>(
resume?.urls.map(({ value }) => (
<Link key={value} src={value} style={styles.link}>
{value.replace(/^https?:\/\//, "")}
</Link>
)),
" | ",
)}
</Text>
</View>
{/* Experiences */}
{experiences.length > 0 && (
<View>
<Text style={styles.h2}>Professional Experience</Text>
{experiences.map(({ description, ...headingProps }) => (
<>
<ExperienceHeading {...headingProps} />
<UnorderedList>
{description.map((task) => (
<ListItem key={task}>{task}</ListItem>
))}
</UnorderedList>
</>
))}
</View>
)}
{/* Projects */}
{projects.length > 0 && (
<View>
<Text style={styles.h2}>Projects</Text>
{projects.map(({ description, ...headingProps }) => (
<>
<ProjectHeading {...headingProps} />
<UnorderedList>
{description.map((task) => (
<ListItem key={task}>{task}</ListItem>
))}
</UnorderedList>
</>
))}
</View>
)}
{/* Skills */}
{skills.length > 0 && (
<View>
<Text style={styles.h2}>Technical Skills</Text>
<UnorderedList>
{skills.map(({ prefix, skills }) => (
<ListItem
key={prefix}
heading={prefix !== noPrefix ? prefix : undefined}
>
{skills}
</ListItem>
))}
</UnorderedList>
</View>
)}
{/* Education */}
{education.length > 0 && (
<View>
<Text style={styles.h2}>Education</Text>
<UnorderedList>
{education.map(
({ heading, details }) =>
details && (
<ListItem key={heading} heading={heading}>
{details}
</ListItem>
),
)}
</UnorderedList>
</View>
)}
</Page>
</Document>
);
};

View File

@@ -0,0 +1,24 @@
"use client";
import { Card } from "~/components/ui/card";
import { type ResumeFormSchema } from "~/lib/schemas/resume-form-schema";
import { cn } from "~/lib/utils";
import { PDFViewer } from "@react-pdf/renderer";
import { Doc } from "~/components/pdf-preview/doc";
export const Preview = ({ resume }: { resume?: ResumeFormSchema }) => {
return (
<Card
className={cn(
"flex aspect-paper max-h-full transition-colors",
!resume && "animate-pulse bg-neutral-50",
)}
>
{!!resume && (
<PDFViewer showToolbar={false} className="flex-1 bg-white">
<Doc resume={resume} />
</PDFViewer>
)}
</Card>
);
};

View File

@@ -0,0 +1,38 @@
import { StyleSheet } from "@react-pdf/renderer";
export const styles = StyleSheet.create({
page: {
fontFamily: "Inter",
fontSize: 11,
padding: "0.75in",
// paddingVertical: "0.75in",
// paddingHorizontal: "1in",
lineHeight: 1.3,
},
bold: { fontWeight: "bold" },
row: { display: "flex", flexDirection: "row" },
h1: { fontSize: 18, fontWeight: "bold" },
h2: {
textTransform: "uppercase",
borderBottom: 1,
paddingBottom: 4,
marginTop: 8,
marginBottom: 4, // 4 here and 4 from items underneath
},
link: { color: "#1155CC" },
experienceHeading: {
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
marginTop: 4,
},
listContainer: {
marginTop: 4,
display: "flex",
flexDirection: "column",
gap: 2,
},
});

View File

@@ -0,0 +1,49 @@
import { type PropsWithChildren } from "react";
import { Text, View } from "@react-pdf/renderer";
import { styles } from "~/components/pdf-preview/styles";
import type { Experience, Project } from "~/hooks/resume/useFormatResume/types";
export const join = (list: (string | undefined)[], separator = " | ") =>
list.filter((value) => value !== undefined).join(separator);
export const ExperienceHeading = ({
role,
company,
date,
}: Omit<Experience, "description">) => {
const formattedCompany = company ? ` – ${company}` : "";
return (
<View style={styles.experienceHeading}>
<Text style={styles.bold}>
{role}
{formattedCompany}
</Text>
{date && <Text style={styles.bold}>{date}</Text>}
</View>
);
};
export const ProjectHeading = ({ title }: Omit<Project, "description">) => (
<ExperienceHeading role={title} />
);
export const UnorderedList = ({ children }: PropsWithChildren) => {
return <View style={styles.listContainer}>{children}</View>;
};
export const ListItem = ({
heading,
children,
}: PropsWithChildren<{ heading?: string }>) => {
return (
<View style={styles.row}>
<Text style={{ marginRight: 8 }}>•</Text>
<Text style={{ flex: 1 }}>
{heading && <Text style={styles.bold}>{heading}: </Text>}
<Text>{children}</Text>
</Text>
</View>
);
};

View File

@@ -0,0 +1,142 @@
import { useMemo } from "react";
import type {
Experience,
Project,
Skill,
Education,
FormattedResume,
} from "~/hooks/resume/useFormatResume/types";
import {
formatDateRange,
formatList,
noPrefix,
} from "~/hooks/resume/useFormatResume/utils";
import { type ResumeFormSchema } from "~/lib/schemas/resume-form-schema";
export const useFormatResume = ({
resume,
}: {
resume?: ResumeFormSchema;
}): FormattedResume => {
const experiences: Experience[] = useMemo(() => {
if (!resume) return [];
return resume.experience.map((exp) => {
return {
role: exp.role ?? "",
company: exp.employer,
date: formatDateRange(exp),
description: exp.description.map(({ value }) => value),
};
});
}, [resume?.experience]);
const projects: Project[] = useMemo(() => {
if (!resume) return [];
return resume.projects
.filter(({ title }) => title !== undefined)
.map(({ title, description }) => ({
title: title!,
description: description.map(({ value }) => value),
}));
}, [resume?.projects]);
const skills: Skill[] = useMemo(() => {
if (!resume) return [];
const rawSkills = resume.skills.map(({ value }) => value);
const skillMap: Record<string, string[]> = {};
for (const skill of rawSkills) {
// Find the first non-escaped ':'
let splitIndex = -1;
for (let i = 0; i < skill.length; i++) {
if (skill[i] === ":" && (i === 0 || skill[i - 1] !== "\\")) {
splitIndex = i;
break;
}
}
let prefix: string;
let skillName: string;
if (splitIndex !== -1) {
// Prefix was found
prefix = skill.substring(0, splitIndex).replace("\\:", ":").trim();
skillName = skill
.substring(splitIndex + 1)
.replace("\\:", ":")
.trim();
} else {
// No prefix was found
prefix = noPrefix;
skillName = skill;
}
// Add to map
if (skillMap[prefix]) {
skillMap[prefix]?.push(skillName);
} else {
skillMap[prefix] = [skillName];
}
}
const prefixList = Object.keys(skillMap).map((prefix) => ({
prefix,
skills: formatList(skillMap[prefix]!),
}));
// If there is an un-prefixed set in list that has at least one prefix,
// Then replace noPrefix with 'Other'
if (prefixList.length > 1) {
for (const prefixItem of prefixList) {
if (prefixItem.prefix === noPrefix) {
prefixItem.prefix = "Other";
break;
}
}
}
return prefixList;
}, [resume?.skills]);
const education: Education[] = useMemo(() => {
if (!resume) return [];
return [
{
heading: "Degree",
details: resume.education
.map(({ degree, school, gpa }) => {
if (!degree) return undefined;
let formatted = degree;
if (school !== undefined) formatted += ` – ${school}`;
if (gpa !== undefined) formatted += ` (GPA: ${gpa})`;
return formatted;
})
.filter((detail) => detail !== undefined)
.join("; "),
},
{
heading: "Courses",
details: formatList([]), // TODO:
},
{
heading: "Certifications",
details: formatList(resume.certificates.map(({ value }) => value)),
},
{
heading: "Awards",
details: formatList(resume.awards.map(({ value }) => value)),
},
];
}, [resume?.education]);
return {
experiences,
projects,
skills,
education,
};
};

View File

@@ -0,0 +1,29 @@
export interface Experience {
role: string;
company?: string;
date?: string;
description: string[];
}
export interface Skill {
prefix?: string;
skills: string;
}
export interface Project {
title: string;
description: string[];
}
// Might break up into certs + awards
export interface Education {
heading: string;
details: string;
}
export interface FormattedResume {
experiences: Experience[];
skills: Skill[];
projects: Project[];
education: Education[];
}

View File

@@ -0,0 +1,44 @@
export const noPrefix = "noPrefix";
/**
* Takes in a list of strings and formats them as follows
* - 1-item lists are just returned as a string
* - 2-item lists are formatted as "A and B"
* - everything else is formatted as "A, B, and C"
*/
export const formatList = (list: string[]): string => {
if (list.length === 0) return "";
if (list.length === 1) return list[0] ?? "";
if (list.length === 2) return list.join(" and ");
const [last, ...initial] = list.reverse();
return `${initial.reverse().join(", ")}, and ${last}`;
};
export const formatDate = (dateStr?: string) => {
if (!dateStr) return "Present";
const date = new Date(dateStr);
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
};
return new Intl.DateTimeFormat("en-US", options).format(date);
};
export const formatDateRange = ({
startDate,
endDate,
isOngoing,
}: {
startDate?: string;
endDate?: string;
isOngoing: boolean;
}): string | undefined => {
if (!startDate) return "";
const start = formatDate(startDate);
const end = isOngoing ? "Present" : formatDate(endDate);
return `${start} – ${end}`;
};

17
src/lib/utils/lists.ts Normal file
View File

@@ -0,0 +1,17 @@
/**
* Inserts an element between each pair of items in the original array
* @param array
* @param element The element to intersperse between the items of the array
*/
export const intersperse = <T>(array: T[], element: T): T[] => {
const result: T[] = [];
array.forEach((item, index) => {
if (index > 0) {
result.push(element);
}
result.push(item);
});
return result;
};