Made the pdf pull resume data (not dynamic yet)
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
128
src/components/pdf-preview/doc.tsx
Normal file
128
src/components/pdf-preview/doc.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
24
src/components/pdf-preview/index.tsx
Normal file
24
src/components/pdf-preview/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
38
src/components/pdf-preview/styles.tsx
Normal file
38
src/components/pdf-preview/styles.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
49
src/components/pdf-preview/utils.tsx
Normal file
49
src/components/pdf-preview/utils.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
142
src/hooks/resume/useFormatResume/index.ts
Normal file
142
src/hooks/resume/useFormatResume/index.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
29
src/hooks/resume/useFormatResume/types.ts
Normal file
29
src/hooks/resume/useFormatResume/types.ts
Normal 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[];
|
||||
}
|
||||
44
src/hooks/resume/useFormatResume/utils.ts
Normal file
44
src/hooks/resume/useFormatResume/utils.ts
Normal 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
17
src/lib/utils/lists.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user