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

View File

@@ -10,7 +10,7 @@
"shell": "nix-shell"
},
"dependencies": {
"@astrojs/mdx": "^4.3.0",
"@astrojs/mdx": "^4.3.1",
"@astrojs/node": "^9.3.0",
"@astrojs/preact": "^4.1.0",
"@astrojs/rss": "^4.0.12",
@@ -18,17 +18,17 @@
"@preact/signals": "^2.2.1",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11",
"astro": "^5.11.0",
"astro": "^5.12.0",
"astro-icon": "^1.1.5",
"lucide-preact": "^0.525.0",
"playwright": "^1.54.0",
"playwright": "^1.54.1",
"preact": "^10.26.9",
"sharp": "^0.34.3",
"tailwindcss": "^4.1.11"
},
"devDependencies": {
"@iconify-json/mdi": "^1.2.3",
"@iconify-json/simple-icons": "^1.2.42",
"@iconify-json/simple-icons": "^1.2.43",
"daisyui": "^5.0.46"
},
"pnpm": {

447
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,10 @@ 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"

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;