Resume system overhaul :)
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m54s

This commit is contained in:
2025-07-18 12:14:23 -06:00
parent 26c0862b3e
commit 528060b85a
13 changed files with 951 additions and 360 deletions

199
src/assets/resume.toml Normal file
View File

@@ -0,0 +1,199 @@
[basics]
name = "Atridad Lahiji"
email = "me@atri.dad"
website = "https://atri.dad"
[layout]
left_column = ["experience", "volunteer"]
right_column = ["skills", "education", "awards"]
[[basics.profiles]]
network = "LinkedIn"
username = "atridadl"
url = "https://www.linkedin.com/in/atridadl/"
[[basics.profiles]]
network = "Gitea"
username = "atridad"
url = "https://git.atri.dad/atridad"
[summary]
content = "I am a full-stack web developer and researcher with a background maintaining and developing for large-scale enterprise software systems."
[[experience]]
company = "Atash Consulting"
position = "Owner/Developer"
location = "Edmonton, Alberta"
date = "June 2019 Present"
description = [
"Builds mobile and web applications for small-medium sized businesses",
"Provides consulting on application development, system architecture, DevOps, etc",
"Hosting websites for small-medium sized businesses",
]
url = "https://atash.dev"
[[experience]]
company = "University of Saskatchewan CEPHIL Lab"
position = "Technical Lead"
location = "Saskatoon, Saskatchewan"
date = "November 2023 Present"
description = [
"Technical lead and supervisor to a developer intern",
"Developing mobile and web applications",
"Coordinating with other grant researchers to deliver a minimum viable product",
"Gathering requirements from stakeholders to craft a product timeline",
]
url = "https://cephil.ca/"
[[experience]]
company = "Alberta Motor Association"
position = "Software Developer II"
location = "Edmonton, Alberta"
date = "August 2021 November 2023"
description = [
"Developed and maintained internal enterprise-level business applications leveraging Amazon Web Services (AWS)",
"Used React and Create React App (CRA) for standalone applications and micro-front-ends",
"Developed an in-house payment gateway for all AMA services that integrates with Stripe",
"Managed financial reporting for the finance team",
"Provided tier 3 support for internal services",
"Participated in a bi-monthly 24/7 on-call rotation",
"Mentored students in the organization's Developer in Training program",
]
url = "https://ama.ab.ca/"
[[experience]]
company = "University of Alberta IST"
position = "Software Developer"
location = "Edmonton, Alberta"
date = "October 2019 August 2021"
description = [
"Secondment from previous role",
"Front-end development of web applications using Vue.js",
"Leveraged Amazon Web Services to adopt a serverless architecture",
"Maintained a secure exam application developed in-house",
"Monitored and maintained an exam scheduling system hosted on-premises",
]
url = "https://www.ualberta.ca/en/information-services-and-technology/index.html"
[[experience]]
company = "University of Alberta IST"
position = "Support Analyst"
location = "Edmonton, Alberta"
date = "July 2017 October 2019"
description = [
"Provided support for our Moodle installation to students, faculty, and staff",
"Front-end development of web applications using Vue.js",
]
url = "https://www.ualberta.ca/en/information-services-and-technology/index.html"
[[education]]
institution = "University of Saskatchewan"
degree = "Masters"
field = "Computer Science"
date = "2024 Present"
details = [
"Supervisor: Dr. Nathaniel Osgood",
"CMPT 838: Computer Security",
"CMPT 815: Computer Systems and Performance Evaluation",
]
[[education]]
institution = "University of Saskatchewan"
degree = "Bachelors (3 Year)"
field = "Computer Science"
date = "2016 2019"
[[education]]
institution = "University of Saskatchewan"
degree = "Bachelors"
field = "Computer Engineering"
date = "2012 2017"
[[skills]]
name = "HTML + CSS + JavaScript"
level = 5
[[skills]]
name = "TypeScript"
level = 5
[[skills]]
name = "C# (.NET)"
level = 3
[[skills]]
name = "Swift"
level = 3
[[skills]]
name = "Kotlin"
level = 3
[[skills]]
name = "SQL (PostgreSQL, MySQL, SQLite)"
level = 4
[[skills]]
name = "Vitest, Jest, and Playwright"
level = 4
[[skills]]
name = "Github Actions"
level = 4
[[skills]]
name = "Amazon Web Services (AWS)"
level = 4
[[skills]]
name = "Infrastructure as Code (IaC)"
level = 5
[[skills]]
name = "Docker"
level = 5
[[skills]]
name = "System Administration (Linux)"
level = 4
[[skills]]
name = "Project Leadership"
level = 3
[[skills]]
name = "Project Magagement"
level = 3
[[skills]]
name = "Time Management"
level = 4
[[skills]]
name = "Problem Solving"
level = 5
[[skills]]
name = "Attention to Detail"
level = 5
[[skills]]
name = "Python"
level = 4
[[volunteer]]
organization = "Big Brother Big Sisters"
position = "Mentor"
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

@@ -1,34 +1,38 @@
---
import { Icon } from "astro-icon/components";
import type { Project } from '../types';
import type { Project } from "../types";
interface Props {
project: Project;
project: Project;
}
const { project } = Astro.props;
---
<div class="card bg-accent shadow-lg w-full sm:w-[calc(50%-1rem)] md:w-96 min-w-[280px] max-w-sm shrink">
<div class="card-body p-6 break-words">
<h2 class="card-title text-xl md:text-2xl font-bold justify-center text-center break-words text-base-100">
{project.name}
</h2>
<div
class="card bg-accent shadow-lg w-full sm:w-[calc(50%-1rem)] md:w-96 min-w-[280px] max-w-sm shrink"
>
<div class="card-body p-6 break-words">
<h2
class="card-title text-xl md:text-2xl font-bold justify-center text-center break-words text-base-100"
>
{project.name}
</h2>
<p class="text-center break-words my-4 text-base-100">
{project.description}
</p>
<p class="text-center break-words my-4 text-base-100">
{project.description}
</p>
<div class="card-actions justify-end mt-4">
<a
href={project.link}
target="_blank"
rel="noopener noreferrer"
class="btn btn-circle btn-sm bg-base-100 hover:bg-base-200 text-accent"
aria-label={`Visit ${project.name}`}
>
<Icon name="mdi:link" class="text-lg" />
</a>
<div class="card-actions justify-end mt-4">
<a
href={project.link}
target="_blank"
rel="noopener noreferrer"
class="btn btn-circle btn-sm bg-base-100 hover:bg-base-200 text-accent"
aria-label={`Visit ${project.name}`}
>
<Icon name="mdi:link" class="text-lg" />
</a>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,318 @@
import { useState } from "preact/hooks";
import { useSignal } from "@preact/signals";
import { Settings } from "lucide-preact";
interface ResumeSettingsModalProps {
className?: string;
}
export default function ResumeSettingsModal({
className = "",
}: ResumeSettingsModalProps) {
const [tomlContent, setTomlContent] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<"upload" | "edit">("upload");
const dragActive = useSignal(false);
const modalOpen = useSignal(false);
const openModal = () => {
modalOpen.value = true;
};
const closeModal = () => {
modalOpen.value = false;
setError(null);
setTomlContent("");
setActiveTab("upload");
};
const handleFileUpload = (file: File) => {
if (!file.name.endsWith(".toml")) {
setError("Please upload a .toml file");
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
setTomlContent(content);
setError(null);
setActiveTab("edit");
};
reader.onerror = () => {
setError("Error reading file");
};
reader.readAsText(file);
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
dragActive.value = false;
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
handleFileUpload(files[0]);
}
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
dragActive.value = true;
};
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
dragActive.value = false;
};
const handleFileInput = (e: Event) => {
const target = e.target as HTMLInputElement;
const files = target.files;
if (files && files.length > 0) {
handleFileUpload(files[0]);
}
};
const downloadTemplate = async () => {
try {
const response = await fetch("/api/resume/template");
if (!response.ok) {
throw new Error("Failed to download template");
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "resume-template.toml";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err) {
setError("Failed to download template");
}
};
const generatePDF = async () => {
if (!tomlContent.trim()) {
setError("Please provide TOML content");
return;
}
setIsGenerating(true);
setError(null);
try {
const response = await fetch("/api/resume/pdf", {
method: "POST",
headers: {
"Content-Type": "text/plain",
},
body: tomlContent,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
errorText || `Failed to generate PDF: ${response.status}`,
);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "resume.pdf";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err) {
console.error("Error generating PDF:", err);
setError(err instanceof Error ? err.message : "Failed to generate PDF");
} finally {
setIsGenerating(false);
}
};
const loadTemplate = async () => {
try {
const response = await fetch("/api/resume/template");
if (!response.ok) {
throw new Error("Failed to load template");
}
const template = await response.text();
setTomlContent(template);
setActiveTab("edit");
setError(null);
} catch (err) {
setError("Failed to load template");
}
};
return (
<>
{/* Floating Settings Button */}
<button
onClick={openModal}
class={`fixed top-4 right-4 z-20 btn btn-circle btn-secondary hover:bg-primary shadow-lg opacity-100 translate-y-0 min-h-[44px] min-w-[44px] ${className}`}
aria-label="Resume Settings"
>
<Settings class="text-lg" />
</button>
{/* Modal */}
<div class={`modal ${modalOpen.value ? "modal-open" : ""}`}>
<div class="modal-box w-11/12 max-w-5xl h-[90vh] flex flex-col">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">Resume Generator</h3>
<button
onClick={closeModal}
class="btn btn-sm btn-circle btn-ghost"
>
</button>
</div>
<div class="flex-1 overflow-hidden flex flex-col">
<p class="text-base-content/70 mb-4">
Create a custom PDF resume from a TOML file. Download the
template, edit it with your information, and generate your resume.
</p>
{/* Action Buttons */}
<div class="flex flex-wrap gap-2 mb-6">
<button onClick={downloadTemplate} class="btn btn-primary btn-sm">
Download Template
</button>
<button onClick={loadTemplate} class="btn btn-secondary btn-sm">
Load Template in Editor
</button>
</div>
{/* Tabs */}
<div class="flex justify-center mb-4">
<div
role="tablist"
class="inline-flex bg-base-300 border border-base-content/20 rounded-full p-1"
>
<button
role="tab"
class={`px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 ${
activeTab === "upload"
? "bg-primary text-primary-content shadow-sm"
: "text-base-content/70 hover:text-base-content hover:bg-base-200"
}`}
onClick={() => setActiveTab("upload")}
>
Upload File
</button>
<button
role="tab"
class={`px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 ${
activeTab === "edit"
? "bg-primary text-primary-content shadow-sm"
: "text-base-content/70 hover:text-base-content hover:bg-base-200"
}`}
onClick={() => setActiveTab("edit")}
>
Edit TOML
</button>
</div>
</div>
{/* Content Area */}
<div class="flex-1 overflow-hidden">
{/* Upload Tab */}
{activeTab === "upload" && (
<div class="h-full">
<div
class={`border-2 border-dashed rounded-lg p-6 text-center transition-colors h-full flex items-center justify-center ${
dragActive.value
? "border-primary bg-primary/10"
: "border-base-300 hover:border-primary/50"
}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
<div class="space-y-4">
<div class="text-4xl">📄</div>
<div>
<p class="text-lg font-medium">
Drop your TOML file here
</p>
<p class="text-base-content/70">or click to browse</p>
</div>
<input
type="file"
accept=".toml"
onChange={handleFileInput}
class="file-input file-input-primary file-input-sm w-full max-w-xs"
/>
</div>
</div>
</div>
)}
{/* Edit Tab */}
{activeTab === "edit" && (
<div class="h-full flex flex-col space-y-2">
<div class="label">
<span class="label-text font-bold">TOML Content</span>
<span class="label-text-alt">
Edit your resume data below
</span>
</div>
<textarea
class="textarea textarea-bordered flex-1 font-mono text-xs resize-none w-full min-h-0"
placeholder="Paste your TOML content here or load the template..."
value={tomlContent}
onInput={(e) =>
setTomlContent((e.target as HTMLTextAreaElement).value)
}
/>
</div>
)}
</div>
{/* Error Display */}
{error && (
<div class="alert alert-error mt-4">
<span class="text-sm">{error}</span>
</div>
)}
{/* Generate Button */}
{tomlContent.trim() && (
<div class="mt-4">
<button
onClick={generatePDF}
disabled={isGenerating}
class="btn btn-primary btn-sm w-full"
>
{isGenerating ? (
<>
<span class="loading loading-spinner loading-xs"></span>
Generating PDF...
</>
) : (
"Generate Custom Resume PDF"
)}
</button>
</div>
)}
</div>
</div>
<div class="modal-backdrop backdrop-blur-sm" onClick={closeModal}></div>
</div>
</>
);
}

View File

@@ -1,6 +1,6 @@
import { useSignal } from "@preact/signals";
import { useEffect } from "preact/hooks";
import { ArrowUp } from 'lucide-preact';
import { ArrowUp } from "lucide-preact";
export default function ScrollUpButton() {
const isVisible = useSignal(false);
@@ -33,13 +33,13 @@ export default function ScrollUpButton() {
class={`fixed bottom-20 right-4 z-20 bg-secondary hover:bg-primary
p-3 rounded-full shadow-lg transition-all duration-300 min-h-[44px] min-w-[44px] inline-flex items-center justify-center
${
isVisible.value
? "opacity-70 translate-y-0"
: "opacity-0 translate-y-10 pointer-events-none"
}`}
isVisible.value
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-10 pointer-events-none"
}`}
aria-label="Scroll to top"
>
<ArrowUp class="text-lg" />
</button>
);
}
}

View File

@@ -21,6 +21,7 @@ import {
} from "lucide-preact";
import logo from "../assets/logo_real.webp";
import resumeToml from "../assets/resume.toml?raw";
// Astro Icon references
const EMAIL_ICON = "mdi:email";
@@ -60,7 +61,7 @@ export const homepageSections: HomepageSections = {
// Resume Configuration
export const resumeConfig: ResumeConfig = {
tomlFile: "/files/resume.toml",
tomlFile: resumeToml,
pdfFile: {
path: "/files/Atridad_Lahiji_Resume.pdf",
filename: "Atridad_Lahiji_Resume.pdf",

View File

@@ -4,24 +4,32 @@ import { siteConfig } from "../../config/data";
export const GET: APIRoute = async ({ request }) => {
try {
// Check if resume TOML file is configured
// Check if resume TOML content is configured
if (!siteConfig.resume.tomlFile || !siteConfig.resume.tomlFile.trim()) {
return new Response("Resume not configured", { status: 404 });
}
const url = new URL(request.url);
const baseUrl = `${url.protocol}//${url.host}`;
let tomlContent: string;
// Fetch the TOML file from the public directory
const response = await fetch(`${baseUrl}${siteConfig.resume.tomlFile}`);
// Check if tomlFile is a path (starts with /) or raw content
if (siteConfig.resume.tomlFile.startsWith("/")) {
// It's a file path - fetch it
const url = new URL(request.url);
const baseUrl = `${url.protocol}//${url.host}`;
if (!response.ok) {
throw new Error(
`Failed to fetch resume TOML: ${response.status} ${response.statusText}`,
);
const response = await fetch(`${baseUrl}${siteConfig.resume.tomlFile}`);
if (!response.ok) {
throw new Error(
`Failed to fetch resume TOML: ${response.status} ${response.statusText}`,
);
}
tomlContent = await response.text();
} else {
// It's raw TOML content
tomlContent = siteConfig.resume.tomlFile;
}
const tomlContent = await response.text();
const resumeData = TOML.parse(tomlContent);
return new Response(JSON.stringify(resumeData), {

View File

@@ -1,5 +1,5 @@
import type { APIRoute } from "astro";
import { chromium } from 'playwright';
import { chromium } from "playwright";
import { siteConfig } from "../../../config/data";
import * as TOML from "@iarna/toml";
@@ -48,6 +48,10 @@ interface ResumeData {
url: string;
}[];
};
layout?: {
left_column?: string[];
right_column?: string[];
};
summary: {
content: string;
};
@@ -84,7 +88,11 @@ interface ResumeData {
}
// Template helper functions
const createSection = (title: string, content: string, spacing = "space-y-3") => `
const createSection = (
title: string,
content: string,
spacing = "space-y-3",
) => `
<section>
<h2 class="text-sm font-semibold text-gray-900 mb-2 pb-1 border-b border-gray-300">
${title}
@@ -128,7 +136,9 @@ const createSkillItem = (skill: any) => {
const createEducationItem = (edu: any) => {
const detailsList = edu.details
? edu.details.map((detail: string) => `<li class="mb-1">${detail}</li>`).join("")
? edu.details
.map((detail: string) => `<li class="mb-1">${detail}</li>`)
.join("")
: "";
return `
@@ -188,32 +198,42 @@ const createHead = (name: string) => `
</head>
`;
const createHeader = (basics: any, emailIcon: string, profileIcons: { [key: string]: string }) => `
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("") || ""
}
${
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 "";
if (
!summary ||
!summary.content ||
resumeConfig.sections.summary?.enabled === false
)
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"}
${resumeConfig.sections.summary?.title || "Summary"}
</h2>
<div class="text-xs text-gray-700 leading-tight">${summary.content}</div>
</section>
@@ -223,32 +243,32 @@ const createSummarySection = (summary: any, resumeConfig: any) => {
const createColumnSections = (
sectionNames: string[],
sections: { [key: string]: string },
resumeConfig: any
resumeConfig: any,
) => {
const sectionConfig = {
experience: {
title: resumeConfig.sections.experience?.title || "Experience",
enabled: resumeConfig.sections.experience?.enabled,
enabled: resumeConfig.sections.experience?.enabled !== false,
spacing: "space-y-3",
},
skills: {
title: resumeConfig.sections.skills?.title || "Skills",
enabled: resumeConfig.sections.skills?.enabled,
enabled: resumeConfig.sections.skills?.enabled !== false,
spacing: "space-y-1",
},
education: {
title: resumeConfig.sections.education?.title || "Education",
enabled: resumeConfig.sections.education?.enabled,
enabled: resumeConfig.sections.education?.enabled !== false,
spacing: "space-y-3",
},
volunteer: {
title: resumeConfig.sections.volunteer?.title || "Volunteer Work",
enabled: resumeConfig.sections.volunteer?.enabled,
enabled: resumeConfig.sections.volunteer?.enabled !== false,
spacing: "space-y-2",
},
awards: {
title: resumeConfig.sections.awards?.title || "Awards & Recognition",
enabled: resumeConfig.sections.awards?.enabled,
enabled: resumeConfig.sections.awards?.enabled !== false,
spacing: "space-y-2",
},
};
@@ -258,10 +278,13 @@ const createColumnSections = (
const config = sectionConfig[sectionName as keyof typeof sectionConfig];
const content = sections[sectionName];
if (!config || !content || !config.enabled) return "";
// Skip if section doesn't exist in config, has no content, or is disabled
if (!config || !content || content.trim() === "" || !config.enabled)
return "";
return createSection(config.title, content, config.spacing);
})
.filter((section) => section !== "")
.join("");
};
@@ -278,10 +301,20 @@ const fetchProfileIcons = async (profiles: any[]) => {
const generateResumeHTML = async (data: ResumeData): Promise<string> => {
const resumeConfig = siteConfig.resume;
const layout = resumeConfig.layout || {
leftColumn: ["experience", "volunteer", "awards"],
rightColumn: ["skills", "education"],
};
// Use layout from TOML data, fallback to site config, then to default
const layout = data.layout
? {
leftColumn: data.layout.left_column || [
"experience",
"volunteer",
"awards",
],
rightColumn: data.layout.right_column || ["skills", "education"],
}
: resumeConfig.layout || {
leftColumn: ["experience", "volunteer", "awards"],
rightColumn: ["skills", "education"],
};
// Pre-fetch icons
const profileIcons = await fetchProfileIcons(data.basics.profiles);
@@ -289,11 +322,21 @@ const generateResumeHTML = async (data: ResumeData): Promise<string> => {
// Generate section content
const sections = {
experience: Array.isArray(data.experience) ? data.experience.map(createExperienceItem).join("") : "",
skills: Array.isArray(data.skills) ? data.skills.map(createSkillItem).join("") : "",
education: Array.isArray(data.education) ? data.education.map(createEducationItem).join("") : "",
volunteer: Array.isArray(data.volunteer) ? data.volunteer.map(createVolunteerItem).join("") : "",
awards: Array.isArray(data.awards) ? data.awards.map(createAwardItem).join("") : "",
experience: Array.isArray(data.experience)
? data.experience.map(createExperienceItem).join("")
: "",
skills: Array.isArray(data.skills)
? data.skills.map(createSkillItem).join("")
: "",
education: Array.isArray(data.education)
? data.education.map(createEducationItem).join("")
: "",
volunteer: Array.isArray(data.volunteer)
? data.volunteer.map(createVolunteerItem).join("")
: "",
awards: Array.isArray(data.awards)
? data.awards.map(createAwardItem).join("")
: "",
};
return `
@@ -318,61 +361,77 @@ const generateResumeHTML = async (data: ResumeData): Promise<string> => {
`;
};
async function generatePDFFromToml(tomlContent: string): Promise<Uint8Array> {
const resumeData: ResumeData = TOML.parse(
tomlContent,
) as unknown as ResumeData;
const htmlContent = await generateResumeHTML(resumeData);
const browser = await chromium.launch({
headless: true,
executablePath:
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ||
(process.env.NODE_ENV === "production"
? "/usr/bin/chromium-browser"
: undefined),
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-web-security",
"--disable-features=VizDisplayCompositor",
],
});
const page = await browser.newPage();
await page.setContent(htmlContent, { waitUntil: "networkidle" });
const pdfBuffer = await page.pdf({
format: "A4",
margin: {
top: "0.2in",
bottom: "0.2in",
left: "0.2in",
right: "0.2in",
},
printBackground: true,
scale: 0.9,
});
await browser.close();
return pdfBuffer;
}
export const GET: APIRoute = async ({ request }) => {
try {
if (!siteConfig.resume.tomlFile || !siteConfig.resume.tomlFile.trim()) {
return new Response("Resume not configured", { status: 404 });
}
const url = new URL(request.url);
const baseUrl = `${url.protocol}//${url.host}`;
let tomlContent: string;
const response = await fetch(`${baseUrl}${siteConfig.resume.tomlFile}`);
// Check if tomlFile is a path (starts with /) or raw content
if (siteConfig.resume.tomlFile.startsWith("/")) {
// It's a file path - fetch it
const url = new URL(request.url);
const baseUrl = `${url.protocol}//${url.host}`;
if (!response.ok) {
throw new Error(
`Failed to fetch resume: ${response.status} ${response.statusText}`,
);
const response = await fetch(`${baseUrl}${siteConfig.resume.tomlFile}`);
if (!response.ok) {
throw new Error(
`Failed to fetch resume: ${response.status} ${response.statusText}`,
);
}
tomlContent = await response.text();
} else {
// It's raw TOML content
tomlContent = siteConfig.resume.tomlFile;
}
const tomlContent = await response.text();
const resumeData: ResumeData = TOML.parse(
tomlContent,
) as unknown as ResumeData;
const htmlContent = await generateResumeHTML(resumeData);
const browser = await chromium.launch({
headless: true,
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ||
(process.env.NODE_ENV === "production" ? "/usr/bin/chromium-browser" : undefined),
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-web-security',
'--disable-features=VizDisplayCompositor'
]
});
const page = await browser.newPage();
await page.setContent(htmlContent, { waitUntil: 'networkidle' });
const pdfBuffer = await page.pdf({
format: 'A4',
margin: {
top: '0.2in',
bottom: '0.2in',
left: '0.2in',
right: '0.2in',
},
printBackground: true,
scale: 0.9,
});
await browser.close();
const pdfBuffer = await generatePDFFromToml(tomlContent);
return new Response(pdfBuffer, {
headers: {
@@ -387,4 +446,47 @@ export const GET: APIRoute = async ({ request }) => {
console.error("Error generating PDF:", error);
return new Response("Error generating PDF", { status: 500 });
}
};
};
export const POST: APIRoute = async ({ request }) => {
try {
const tomlContent = await request.text();
if (!tomlContent.trim()) {
return new Response("TOML content is required", { status: 400 });
}
// Validate TOML content
let resumeData: ResumeData;
try {
resumeData = TOML.parse(tomlContent) as unknown as ResumeData;
} catch (parseError) {
return new Response(
`Invalid TOML format: ${parseError instanceof Error ? parseError.message : "Unknown error"}`,
{ status: 400 },
);
}
// Basic validation
if (!resumeData.basics?.name) {
return new Response("Resume must include basics.name", { status: 400 });
}
const pdfBuffer = await generatePDFFromToml(tomlContent);
const filename = `${resumeData.basics.name.replace(/[^a-zA-Z0-9]/g, "_")}_Resume.pdf`;
return new Response(pdfBuffer, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${filename}"`,
"Cache-Control": "no-cache, no-store, must-revalidate",
Pragma: "no-cache",
Expires: "0",
},
});
} catch (error) {
console.error("Error generating PDF:", error);
return new Response("Error generating PDF", { status: 500 });
}
};

View File

@@ -0,0 +1,148 @@
import type { APIRoute } from "astro";
export const GET: APIRoute = async () => {
const templateToml = `# Resume Template - Edit this file with your information
# Save as .toml file and upload to the resume generator
[basics]
name = "Your Full Name"
email = "your.email@example.com"
website = "https://yourwebsite.com"
# Add your social media profiles
[[basics.profiles]]
network = "GitHub"
username = "yourusername"
url = "https://github.com/yourusername"
[[basics.profiles]]
network = "LinkedIn"
username = "yourname"
url = "https://linkedin.com/in/yourname"
[[basics.profiles]]
network = "Bluesky"
username = "yourusername"
url = "https://bluesky.app/profile/yourusername"
# Layout Configuration - Customize which sections appear in each column
[layout]
left_column = ["experience", "volunteer"]
right_column = ["skills", "education", "awards"]
[summary]
content = """
Lorem ipsum dolor sit amet, consectetur adipiscing elit,
sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
"""
# Professional Experience
[[experience]]
company = "Company Name"
position = "Your Job Title"
location = "City, Province/Country"
date = "Jan 2020 - Present"
url = "https://company.com" # Optional
description = [
"Describe a key achievement or responsibility",
"Quantify your impact with numbers when possible",
"Use action verbs to describe what you accomplished",
"Add more bullet points as needed",
]
[[experience]]
company = "Previous Company"
position = "Previous Job Title"
location = "City, Province/Country"
date = "Jun 2018 - Dec 2019"
description = [
"Another achievement from your previous role",
"Focus on results and impact",
"Keep descriptions concise but informative",
]
# Education
[[education]]
institution = "University Name"
degree = "Bachelor of Science"
field = "Computer Science"
date = "2014 - 2018"
details = [
"Relevant coursework: Data Structures, Algorithms, Software Engineering",
]
[[education]]
institution = "Another Institution"
degree = "Certificate"
field = "Web Development"
date = "2019"
# Skills (rate yourself 1-5)
[[skills]]
name = "JavaScript"
level = 4
[[skills]]
name = "Python"
level = 5
[[skills]]
name = "React"
level = 4
[[skills]]
name = "Node.js"
level = 3
[[skills]]
name = "SQL"
level = 4
[[skills]]
name = "Git"
level = 4
[[skills]]
name = "Docker"
level = 3
[[skills]]
name = "AWS"
level = 2
# Volunteer Work (optional section)
[[volunteer]]
organization = "Local Tech Meetup"
position = "Event Organizer"
date = "2020 - Present"
[[volunteer]]
organization = "Code for Good"
position = "Volunteer Developer"
date = "2019 - 2020"
# Awards and Recognition (optional section)
[[awards]]
title = "Employee of the Month"
organization = "Current Company"
date = "March 2023"
description = "Recognized for outstanding contribution to the team project"
[[awards]]
title = "Hackathon Winner"
organization = "Local Tech Conference"
date = "October 2022"
description = "First place!"
`;
return new Response(templateToml, {
headers: {
"Content-Type": "text/plain",
"Content-Disposition": 'attachment; filename="resume-template.toml"',
"Cache-Control": "public, max-age=86400", // Cache for 1 day
},
});
};

View File

@@ -3,6 +3,7 @@ import { Icon } from "astro-icon/components";
import Layout from "../layouts/Layout.astro";
import ResumeSkills from "../components/ResumeSkills";
import ResumeDownloadButton from "../components/ResumeDownloadButton";
import ResumeSettingsModal from "../components/ResumeSettingsModal";
import { siteConfig } from "../config/data";
import "../styles/global.css";
import * as TOML from "@iarna/toml";
@@ -56,44 +57,48 @@ interface ResumeData {
let resumeData: ResumeData | undefined = undefined;
let fetchError: string | null = null;
// Check if resume TOML file is configured before attempting to fetch
if (!siteConfig.resume.tomlFile || !siteConfig.resume.tomlFile.trim()) {
return Astro.redirect("/");
}
try {
// Get the base URL
const baseUrl = Astro.url.origin;
let tomlContent: string;
// Fetch the TOML file from the public directory
const response = await fetch(`${baseUrl}${siteConfig.resume.tomlFile}`);
if (siteConfig.resume.tomlFile.startsWith("/")) {
const baseUrl = Astro.url.origin;
const response = await fetch(`${baseUrl}${siteConfig.resume.tomlFile}`);
if (!response.ok) {
throw new Error(
`Failed to fetch resume: ${response.status} ${response.statusText}`,
);
if (!response.ok) {
throw new Error(
`Failed to fetch resume: ${response.status} ${response.statusText}`,
);
}
tomlContent = await response.text();
} else {
tomlContent = siteConfig.resume.tomlFile;
}
const tomlContent = await response.text();
resumeData = TOML.parse(tomlContent) as unknown as ResumeData;
} catch (error) {
console.error("Error loading resume data:", error);
// Return to home page when resume data cannot be loaded
return Astro.redirect("/");
}
const data = resumeData;
const resumeConfig = siteConfig.resume;
// At this point, data is guaranteed to exist since we redirect on error
if (!data) {
return Astro.redirect("/");
}
---
<Layout title="Resume">
<ResumeSettingsModal client:load />
<div class="container mx-auto p-4 sm:p-6 lg:p-8 max-w-4xl w-full">
<h1 class="text-3xl sm:text-4xl font-bold text-primary mb-4 sm:mb-6 text-center">
<h1
class="text-3xl sm:text-4xl font-bold text-primary mb-4 sm:mb-6 text-center"
>
{data.basics.name}
</h1>

View File

@@ -1,6 +1,6 @@
---
import Layout from "../layouts/Layout.astro";
import TerminalComponent from "../components/Terminal.tsx";
import TerminalComponent from "../components/Terminal";
import "../styles/global.css";
---

View File

@@ -51,7 +51,7 @@ export interface NavigationItem {
}
export interface ResumeConfig {
tomlFile: string;
tomlFile: string; // Can be a file path or raw TOML content
pdfFile: {
path: string;
filename: string;