Update resume and make pdf template easier to modify
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m46s

This commit is contained in:
2025-06-30 14:47:19 -06:00
parent 1ffac7f118
commit 702062ab9a
5 changed files with 235 additions and 293 deletions

View File

@ -167,3 +167,14 @@ level = 5
organization = "Big Brother Big Sisters" organization = "Big Brother Big Sisters"
position = "Mentor" position = "Mentor"
date = "2021 2022" date = "2021 2022"
[[awards]]
title = "IT Innovation Award - Team"
organization = "University of Alberta IST"
date = "2020"
description = "The IT Innovation Award recognizes one team for their innovative use of hardware and/or software technology to successfully deploy a major IT project with significant impact to research, teaching, administration and/or the University experience."
[[awards]]
title = "IT Client Service Award - Team"
organization = "University of Alberta IST"
date = "2021"

View File

@ -67,8 +67,8 @@ export const resumeConfig: ResumeConfig = {
displayText: "Download Resume (PDF)", displayText: "Download Resume (PDF)",
}, },
layout: { layout: {
leftColumn: ["experience", "awards"], leftColumn: ["experience", "volunteer"],
rightColumn: ["skills", "education", "volunteer"], rightColumn: ["skills", "education", "awards"],
}, },
sections: { sections: {
enabled: [ enabled: [

View File

@ -14,7 +14,6 @@ async function getSimpleIcon(iconName: string): Promise<string> {
return ""; return "";
} }
const svgContent = await response.text(); const svgContent = await response.text();
// Add inline styles for proper sizing and color
return svgContent.replace( return svgContent.replace(
"<svg", "<svg",
'<svg style="width: 12px; height: 12px; display: inline-block; vertical-align: middle; fill: currentColor;"', '<svg style="width: 12px; height: 12px; display: inline-block; vertical-align: middle; fill: currentColor;"',
@ -84,307 +83,239 @@ interface ResumeData {
}[]; }[];
} }
// Helper function to generate sections for a column // Template helper functions
function generateColumnSections( const createSection = (title: string, content: string, spacing = "space-y-3") => `
sectionNames: string[] = [], <section>
sectionData: { [key: string]: any }, <h2 class="text-sm font-semibold text-gray-900 mb-2 pb-1 border-b border-gray-300">
): string { ${title}
</h2>
<div class="${spacing}">
${content}
</div>
</section>
`;
const createExperienceItem = (exp: any) => `
<div class="mb-3 pl-2 border-l-2 border-blue-600">
<h3 class="text-xs font-semibold text-gray-900 mb-1">${exp.position}</h3>
<div class="text-xs text-gray-600 mb-1">
<span class="font-medium">${exp.company}</span>
<span class="mx-1">•</span>
<span>${exp.date}</span>
<span class="mx-1">•</span>
<span>${exp.location}</span>
</div>
<ul class="text-xs text-gray-700 leading-tight ml-3 list-disc">
${exp.description.map((item: string) => `<li class="mb-1">${item}</li>`).join("")}
</ul>
</div>
`;
const createSkillItem = (skill: any) => {
const progressValue = skill.level * 20;
return `
<div class="mb-1">
<div class="flex justify-between items-center mb-0.5">
<span class="text-xs font-medium text-gray-900">${skill.name}</span>
<span class="text-xs text-gray-600">${skill.level}/5</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300" style="width: ${progressValue}%"></div>
</div>
</div>
`;
};
const createEducationItem = (edu: any) => {
const detailsList = edu.details
? edu.details.map((detail: string) => `<li class="mb-1">${detail}</li>`).join("")
: "";
return `
<div class="mb-3 pl-2 border-l-2 border-green-600">
<h3 class="text-xs font-semibold text-gray-900 mb-1">${edu.institution}</h3>
<div class="text-xs text-gray-600 mb-1">
<span class="font-medium">${edu.degree} in ${edu.field}</span>
<span class="mx-1">•</span>
<span>${edu.date}</span>
</div>
${detailsList ? `<ul class="text-xs text-gray-700 leading-tight ml-3 list-disc">${detailsList}</ul>` : ""}
</div>
`;
};
const createVolunteerItem = (vol: any) => `
<div class="mb-2 pl-2 border-l-2 border-purple-600">
<h3 class="text-xs font-semibold text-gray-900 mb-1">${vol.organization}</h3>
<div class="text-xs text-gray-600">
<span class="font-medium">${vol.position}</span>
<span class="mx-1">•</span>
<span>${vol.date}</span>
</div>
</div>
`;
const createAwardItem = (award: any) => `
<div class="mb-2 pl-2 border-l-2 border-yellow-600">
<h3 class="text-xs font-semibold text-gray-900 mb-1">${award.title}</h3>
<div class="text-xs text-gray-600 mb-1">
<span class="font-medium">${award.organization}</span>
<span class="mx-1">•</span>
<span>${award.date}</span>
</div>
${award.description ? `<div class="text-xs text-gray-700 leading-tight">${award.description}</div>` : ""}
</div>
`;
const createHead = (name: string) => `
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${name} - Resume</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@media print {
body {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
}
.resume-container {
max-width: 8.5in;
min-height: 11in;
}
</style>
</head>
`;
const createHeader = (basics: any, emailIcon: string, profileIcons: { [key: string]: string }) => `
<header class="text-center mb-3 pb-2 border-b-2 border-gray-300">
<h1 class="text-3xl font-bold text-gray-900 mb-1">${basics.name}</h1>
<div class="flex justify-center items-center flex-wrap gap-4 text-xs text-gray-600">
${basics.email ? `<div class="flex items-center gap-1">${emailIcon} ${basics.email}</div>` : ""}
${basics.profiles
?.map((profile: any) => {
const icon = profileIcons[profile.network] || "";
const displayUrl = profile.url
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
return `<div class="flex items-center gap-1">${icon} ${displayUrl}</div>`;
})
.join("") || ""
}
</div>
</header>
`;
const createSummarySection = (summary: any, resumeConfig: any) => {
if (!summary || !resumeConfig.sections.summary?.enabled) return "";
return `
<section class="mb-3">
<h2 class="text-sm font-semibold text-gray-900 mb-2 pb-1 border-b border-gray-300">
${resumeConfig.sections.summary.title || "Summary"}
</h2>
<div class="text-xs text-gray-700 leading-tight">${summary.content}</div>
</section>
`;
};
const createColumnSections = (
sectionNames: string[],
sections: { [key: string]: string },
resumeConfig: any
) => {
const sectionConfig = {
experience: {
title: resumeConfig.sections.experience?.title || "Experience",
enabled: resumeConfig.sections.experience?.enabled,
spacing: "space-y-3",
},
skills: {
title: resumeConfig.sections.skills?.title || "Skills",
enabled: resumeConfig.sections.skills?.enabled,
spacing: "space-y-1",
},
education: {
title: resumeConfig.sections.education?.title || "Education",
enabled: resumeConfig.sections.education?.enabled,
spacing: "space-y-3",
},
volunteer: {
title: resumeConfig.sections.volunteer?.title || "Volunteer Work",
enabled: resumeConfig.sections.volunteer?.enabled,
spacing: "space-y-2",
},
awards: {
title: resumeConfig.sections.awards?.title || "Awards & Recognition",
enabled: resumeConfig.sections.awards?.enabled,
spacing: "space-y-2",
},
};
return sectionNames return sectionNames
.map((sectionName) => { .map((sectionName) => {
const section = sectionData[sectionName]; const config = sectionConfig[sectionName as keyof typeof sectionConfig];
if ( const content = sections[sectionName];
!section ||
!section.data ||
!section.data.length ||
!section.enabled
) {
return "";
}
return ` if (!config || !content || !config.enabled) return "";
<section>
<h2 class="text-sm font-semibold text-gray-900 mb-2 pb-1 border-b border-gray-300"> return createSection(config.title, content, config.spacing);
${section.title}
</h2>
<div class="${section.spacing}">
${section.html}
</div>
</section>
`;
}) })
.join(""); .join("");
} };
const fetchProfileIcons = async (profiles: any[]) => {
const profileIcons: { [key: string]: string } = {};
if (profiles) {
for (const profile of profiles) {
const iconName = profile.network.toLowerCase();
profileIcons[profile.network] = await getSimpleIcon(iconName);
}
}
return profileIcons;
};
const generateResumeHTML = async (data: ResumeData): Promise<string> => { const generateResumeHTML = async (data: ResumeData): Promise<string> => {
const resumeConfig = siteConfig.resume; const resumeConfig = siteConfig.resume;
// Get layout configuration with defaults
const layout = resumeConfig.layout || { const layout = resumeConfig.layout || {
leftColumn: ["experience", "volunteer", "awards"], leftColumn: ["experience", "volunteer", "awards"],
rightColumn: ["skills", "education"], rightColumn: ["skills", "education"],
}; };
// Pre-fetch icons for profiles // Pre-fetch icons
const profileIcons: { [key: string]: string } = {}; const profileIcons = await fetchProfileIcons(data.basics.profiles);
if (data.basics.profiles) {
for (const profile of data.basics.profiles) {
const iconName = profile.network.toLowerCase();
profileIcons[profile.network] = await getSimpleIcon(iconName);
}
}
// Get email icon
const emailIcon = getMdiIcon("mdi:email"); const emailIcon = getMdiIcon("mdi:email");
const skillsHTML = // Generate section content
data.skills const sections = {
?.map((skill) => { experience: Array.isArray(data.experience) ? data.experience.map(createExperienceItem).join("") : "",
const progressValue = skill.level * 20; skills: Array.isArray(data.skills) ? data.skills.map(createSkillItem).join("") : "",
return ` education: Array.isArray(data.education) ? data.education.map(createEducationItem).join("") : "",
<div class="mb-1"> volunteer: Array.isArray(data.volunteer) ? data.volunteer.map(createVolunteerItem).join("") : "",
<div class="flex justify-between items-center mb-0.5"> awards: Array.isArray(data.awards) ? data.awards.map(createAwardItem).join("") : "",
<span class="text-xs font-medium text-gray-900">${skill.name}</span> };
<span class="text-xs text-gray-600">${skill.level}/5</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300" style="width: ${progressValue}%"></div>
</div>
</div>
`;
})
.join("") || "";
const experienceHTML =
data.experience
?.map((exp) => {
const descriptionList = exp.description
.map((item) => `<li class="mb-1">${item}</li>`)
.join("");
return `
<div class="mb-3 pl-2 border-l-2 border-blue-600">
<h3 class="text-xs font-semibold text-gray-900 mb-1">${exp.position}</h3>
<div class="text-xs text-gray-600 mb-1">
<span class="font-medium">${exp.company}</span>
<span class="mx-1">•</span>
<span>${exp.date}</span>
<span class="mx-1">•</span>
<span>${exp.location}</span>
</div>
<ul class="text-xs text-gray-700 leading-tight ml-3 list-disc">
${descriptionList}
</ul>
</div>
`;
})
.join("") || "";
const educationHTML =
data.education
?.map((edu) => {
const detailsList = edu.details
? edu.details
.map((detail) => `<li class="mb-1">${detail}</li>`)
.join("")
: "";
return `
<div class="mb-3 pl-2 border-l-2 border-green-600">
<h3 class="text-xs font-semibold text-gray-900 mb-1">${edu.institution}</h3>
<div class="text-xs text-gray-600 mb-1">
<span class="font-medium">${edu.degree} in ${edu.field}</span>
<span class="mx-1">•</span>
<span>${edu.date}</span>
</div>
${detailsList ? `<ul class="text-xs text-gray-700 leading-tight ml-3 list-disc">${detailsList}</ul>` : ""}
</div>
`;
})
.join("") || "";
const volunteerHTML =
data.volunteer
?.map((vol) => {
return `
<div class="mb-2 pl-2 border-l-2 border-purple-600">
<h3 class="text-xs font-semibold text-gray-900 mb-1">${vol.organization}</h3>
<div class="text-xs text-gray-600">
<span class="font-medium">${vol.position}</span>
<span class="mx-1">•</span>
<span>${vol.date}</span>
</div>
</div>
`;
})
.join("") || "";
const awardsHTML =
data.awards
?.map((award) => {
return `
<div class="mb-2 pl-2 border-l-2 border-yellow-600">
<h3 class="text-xs font-semibold text-gray-900 mb-1">${award.title}</h3>
<div class="text-xs text-gray-600 mb-1">
<span class="font-medium">${award.organization}</span>
<span class="mx-1">•</span>
<span>${award.date}</span>
</div>
${award.description ? `<div class="text-xs text-gray-700 leading-tight">${award.description}</div>` : ""}
</div>
`;
})
.join("") || "";
return ` return `
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> ${createHead(data.basics.name)}
<meta charset="UTF-8"> <body class="bg-white text-gray-900 text-xs leading-tight p-3">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <div class="resume-container mx-auto">
<title>${data.basics.name} - Resume</title> ${createHeader(data.basics, emailIcon, profileIcons)}
<script src="https://cdn.tailwindcss.com"></script> ${createSummarySection(data.summary, resumeConfig)}
<style>
@media print {
body {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
}
.resume-container {
max-width: 8.5in;
min-height: 11in;
}
</style>
</head>
<body class="bg-white text-gray-900 text-xs leading-tight p-3">
<div class="resume-container mx-auto">
<header class="text-center mb-3 pb-2 border-b-2 border-gray-300">
<h1 class="text-3xl font-bold text-gray-900 mb-1">${data.basics.name}</h1>
<div class="flex justify-center items-center flex-wrap gap-4 text-xs text-gray-600">
${data.basics.email ? `<div class="flex items-center gap-1">${emailIcon} ${data.basics.email}</div>` : ""}
${data.basics.profiles
?.map((profile) => {
const icon = profileIcons[profile.network] || "";
const displayUrl = profile.url
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
return `<div class="flex items-center gap-1">${icon} ${displayUrl}</div>`;
})
.join("") || ""
}
</div>
</header>
${data.summary && resumeConfig.sections.summary?.enabled
? `
<section class="mb-3">
<h2 class="text-sm font-semibold text-gray-900 mb-2 pb-1 border-b border-gray-300">
${resumeConfig.sections.summary.title || "Summary"}
</h2>
<div class="text-xs text-gray-700 leading-tight">${data.summary.content}</div>
</section>
`
: ""
}
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="space-y-4"> <div class="space-y-4">
${generateColumnSections(layout.leftColumn, { ${createColumnSections(layout.leftColumn ?? [], sections, resumeConfig)}
experience: { </div>
data: data.experience, <div class="space-y-4">
html: experienceHTML, ${createColumnSections(layout.rightColumn ?? [], sections, resumeConfig)}
title: </div>
resumeConfig.sections.experience?.title || "Experience",
enabled: resumeConfig.sections.experience?.enabled,
spacing: "space-y-3",
},
volunteer: {
data: data.volunteer,
html: volunteerHTML,
title:
resumeConfig.sections.volunteer?.title ||
"Volunteer Work",
enabled: resumeConfig.sections.volunteer?.enabled,
spacing: "space-y-2",
},
awards: {
data: data.awards,
html: awardsHTML,
title:
resumeConfig.sections.awards?.title ||
"Awards & Recognition",
enabled: resumeConfig.sections.awards?.enabled,
spacing: "space-y-2",
},
skills: {
data: data.skills,
html: skillsHTML,
title: resumeConfig.sections.skills?.title || "Skills",
enabled: resumeConfig.sections.skills?.enabled,
spacing: "space-y-1",
},
education: {
data: data.education,
html: educationHTML,
title:
resumeConfig.sections.education?.title || "Education",
enabled: resumeConfig.sections.education?.enabled,
spacing: "space-y-3",
},
})}
</div>
<div class="space-y-4">
${generateColumnSections(layout.rightColumn, {
experience: {
data: data.experience,
html: experienceHTML,
title:
resumeConfig.sections.experience?.title || "Experience",
enabled: resumeConfig.sections.experience?.enabled,
spacing: "space-y-3",
},
volunteer: {
data: data.volunteer,
html: volunteerHTML,
title:
resumeConfig.sections.volunteer?.title ||
"Volunteer Work",
enabled: resumeConfig.sections.volunteer?.enabled,
spacing: "space-y-2",
},
awards: {
data: data.awards,
html: awardsHTML,
title:
resumeConfig.sections.awards?.title ||
"Awards & Recognition",
enabled: resumeConfig.sections.awards?.enabled,
spacing: "space-y-2",
},
skills: {
data: data.skills,
html: skillsHTML,
title: resumeConfig.sections.skills?.title || "Skills",
enabled: resumeConfig.sections.skills?.enabled,
spacing: "space-y-1",
},
education: {
data: data.education,
html: educationHTML,
title:
resumeConfig.sections.education?.title || "Education",
enabled: resumeConfig.sections.education?.enabled,
spacing: "space-y-3",
},
})}
</div>
</div> </div>
</div> </div>
</body> </body>
</html> </html>
`; `;
}; };
export const GET: APIRoute = async ({ request }) => { export const GET: APIRoute = async ({ request }) => {

View File

@ -12,16 +12,16 @@ interface ResumeData {
name: string; name: string;
email: string; email: string;
website?: string; website?: string;
profiles: { profiles?: {
network: string; network: string;
username: string; username: string;
url: string; url: string;
}[]; }[];
}; };
summary: { summary?: {
content: string; content: string;
}; };
experience: { experience?: {
company: string; company: string;
position: string; position: string;
location: string; location: string;
@ -29,23 +29,23 @@ interface ResumeData {
description: string[]; description: string[];
url?: string; url?: string;
}[]; }[];
education: { education?: {
institution: string; institution: string;
degree: string; degree: string;
field: string; field: string;
date: string; date: string;
details?: string[]; details?: string[];
}[]; }[];
skills: { skills?: {
name: string; name: string;
level: number; level: number;
}[]; }[];
volunteer: { volunteer?: {
organization: string; organization: string;
position: string; position: string;
date: string; date: string;
}[]; }[];
awards: { awards?: {
title: string; title: string;
organization: string; organization: string;
date: string; date: string;
@ -111,7 +111,7 @@ if (!data) {
) )
} }
{ {
data.basics.profiles.map((profile) => { data.basics.profiles?.map((profile) => {
const iconName = `simple-icons:${profile.network.toLowerCase()}`; const iconName = `simple-icons:${profile.network.toLowerCase()}`;
return ( return (
<a <a