Resume system overhaul :)
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m54s
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m54s
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
"shell": "nix-shell"
|
"shell": "nix-shell"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/mdx": "^4.3.0",
|
"@astrojs/mdx": "^4.3.1",
|
||||||
"@astrojs/node": "^9.3.0",
|
"@astrojs/node": "^9.3.0",
|
||||||
"@astrojs/preact": "^4.1.0",
|
"@astrojs/preact": "^4.1.0",
|
||||||
"@astrojs/rss": "^4.0.12",
|
"@astrojs/rss": "^4.0.12",
|
||||||
@@ -18,17 +18,17 @@
|
|||||||
"@preact/signals": "^2.2.1",
|
"@preact/signals": "^2.2.1",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"astro": "^5.11.0",
|
"astro": "^5.12.0",
|
||||||
"astro-icon": "^1.1.5",
|
"astro-icon": "^1.1.5",
|
||||||
"lucide-preact": "^0.525.0",
|
"lucide-preact": "^0.525.0",
|
||||||
"playwright": "^1.54.0",
|
"playwright": "^1.54.1",
|
||||||
"preact": "^10.26.9",
|
"preact": "^10.26.9",
|
||||||
"sharp": "^0.34.3",
|
"sharp": "^0.34.3",
|
||||||
"tailwindcss": "^4.1.11"
|
"tailwindcss": "^4.1.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/mdi": "^1.2.3",
|
"@iconify-json/mdi": "^1.2.3",
|
||||||
"@iconify-json/simple-icons": "^1.2.42",
|
"@iconify-json/simple-icons": "^1.2.43",
|
||||||
"daisyui": "^5.0.46"
|
"daisyui": "^5.0.46"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
|
447
pnpm-lock.yaml
generated
447
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,10 @@ name = "Atridad Lahiji"
|
|||||||
email = "me@atri.dad"
|
email = "me@atri.dad"
|
||||||
website = "https://atri.dad"
|
website = "https://atri.dad"
|
||||||
|
|
||||||
|
[layout]
|
||||||
|
left_column = ["experience", "volunteer"]
|
||||||
|
right_column = ["skills", "education", "awards"]
|
||||||
|
|
||||||
[[basics.profiles]]
|
[[basics.profiles]]
|
||||||
network = "LinkedIn"
|
network = "LinkedIn"
|
||||||
username = "atridadl"
|
username = "atridadl"
|
@@ -1,34 +1,38 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from "astro-icon/components";
|
import { Icon } from "astro-icon/components";
|
||||||
import type { Project } from '../types';
|
import type { Project } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { project } = Astro.props;
|
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
|
||||||
<div class="card-body p-6 break-words">
|
class="card bg-accent shadow-lg w-full sm:w-[calc(50%-1rem)] md:w-96 min-w-[280px] max-w-sm shrink"
|
||||||
<h2 class="card-title text-xl md:text-2xl font-bold justify-center text-center break-words text-base-100">
|
>
|
||||||
{project.name}
|
<div class="card-body p-6 break-words">
|
||||||
</h2>
|
<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">
|
<p class="text-center break-words my-4 text-base-100">
|
||||||
{project.description}
|
{project.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="card-actions justify-end mt-4">
|
<div class="card-actions justify-end mt-4">
|
||||||
<a
|
<a
|
||||||
href={project.link}
|
href={project.link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="btn btn-circle btn-sm bg-base-100 hover:bg-base-200 text-accent"
|
class="btn btn-circle btn-sm bg-base-100 hover:bg-base-200 text-accent"
|
||||||
aria-label={`Visit ${project.name}`}
|
aria-label={`Visit ${project.name}`}
|
||||||
>
|
>
|
||||||
<Icon name="mdi:link" class="text-lg" />
|
<Icon name="mdi:link" class="text-lg" />
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
318
src/components/ResumeSettingsModal.tsx
Normal file
318
src/components/ResumeSettingsModal.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,6 +1,6 @@
|
|||||||
import { useSignal } from "@preact/signals";
|
import { useSignal } from "@preact/signals";
|
||||||
import { useEffect } from "preact/hooks";
|
import { useEffect } from "preact/hooks";
|
||||||
import { ArrowUp } from 'lucide-preact';
|
import { ArrowUp } from "lucide-preact";
|
||||||
|
|
||||||
export default function ScrollUpButton() {
|
export default function ScrollUpButton() {
|
||||||
const isVisible = useSignal(false);
|
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
|
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
|
p-3 rounded-full shadow-lg transition-all duration-300 min-h-[44px] min-w-[44px] inline-flex items-center justify-center
|
||||||
${
|
${
|
||||||
isVisible.value
|
isVisible.value
|
||||||
? "opacity-70 translate-y-0"
|
? "opacity-100 translate-y-0"
|
||||||
: "opacity-0 translate-y-10 pointer-events-none"
|
: "opacity-0 translate-y-10 pointer-events-none"
|
||||||
}`}
|
}`}
|
||||||
aria-label="Scroll to top"
|
aria-label="Scroll to top"
|
||||||
>
|
>
|
||||||
<ArrowUp class="text-lg" />
|
<ArrowUp class="text-lg" />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -21,6 +21,7 @@ import {
|
|||||||
} from "lucide-preact";
|
} from "lucide-preact";
|
||||||
|
|
||||||
import logo from "../assets/logo_real.webp";
|
import logo from "../assets/logo_real.webp";
|
||||||
|
import resumeToml from "../assets/resume.toml?raw";
|
||||||
|
|
||||||
// Astro Icon references
|
// Astro Icon references
|
||||||
const EMAIL_ICON = "mdi:email";
|
const EMAIL_ICON = "mdi:email";
|
||||||
@@ -60,7 +61,7 @@ export const homepageSections: HomepageSections = {
|
|||||||
|
|
||||||
// Resume Configuration
|
// Resume Configuration
|
||||||
export const resumeConfig: ResumeConfig = {
|
export const resumeConfig: ResumeConfig = {
|
||||||
tomlFile: "/files/resume.toml",
|
tomlFile: resumeToml,
|
||||||
pdfFile: {
|
pdfFile: {
|
||||||
path: "/files/Atridad_Lahiji_Resume.pdf",
|
path: "/files/Atridad_Lahiji_Resume.pdf",
|
||||||
filename: "Atridad_Lahiji_Resume.pdf",
|
filename: "Atridad_Lahiji_Resume.pdf",
|
||||||
|
@@ -4,24 +4,32 @@ import { siteConfig } from "../../config/data";
|
|||||||
|
|
||||||
export const GET: APIRoute = async ({ request }) => {
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
// Check if resume TOML file is configured
|
// Check if resume TOML content is configured
|
||||||
if (!siteConfig.resume.tomlFile || !siteConfig.resume.tomlFile.trim()) {
|
if (!siteConfig.resume.tomlFile || !siteConfig.resume.tomlFile.trim()) {
|
||||||
return new Response("Resume not configured", { status: 404 });
|
return new Response("Resume not configured", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(request.url);
|
let tomlContent: string;
|
||||||
const baseUrl = `${url.protocol}//${url.host}`;
|
|
||||||
|
|
||||||
// Fetch the TOML file from the public directory
|
// Check if tomlFile is a path (starts with /) or raw content
|
||||||
const response = await fetch(`${baseUrl}${siteConfig.resume.tomlFile}`);
|
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) {
|
const response = await fetch(`${baseUrl}${siteConfig.resume.tomlFile}`);
|
||||||
throw new Error(
|
|
||||||
`Failed to fetch resume TOML: ${response.status} ${response.statusText}`,
|
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);
|
const resumeData = TOML.parse(tomlContent);
|
||||||
|
|
||||||
return new Response(JSON.stringify(resumeData), {
|
return new Response(JSON.stringify(resumeData), {
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from "playwright";
|
||||||
import { siteConfig } from "../../../config/data";
|
import { siteConfig } from "../../../config/data";
|
||||||
import * as TOML from "@iarna/toml";
|
import * as TOML from "@iarna/toml";
|
||||||
|
|
||||||
@@ -48,6 +48,10 @@ interface ResumeData {
|
|||||||
url: string;
|
url: string;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
layout?: {
|
||||||
|
left_column?: string[];
|
||||||
|
right_column?: string[];
|
||||||
|
};
|
||||||
summary: {
|
summary: {
|
||||||
content: string;
|
content: string;
|
||||||
};
|
};
|
||||||
@@ -84,7 +88,11 @@ interface ResumeData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Template helper functions
|
// Template helper functions
|
||||||
const createSection = (title: string, content: string, spacing = "space-y-3") => `
|
const createSection = (
|
||||||
|
title: string,
|
||||||
|
content: string,
|
||||||
|
spacing = "space-y-3",
|
||||||
|
) => `
|
||||||
<section>
|
<section>
|
||||||
<h2 class="text-sm font-semibold text-gray-900 mb-2 pb-1 border-b border-gray-300">
|
<h2 class="text-sm font-semibold text-gray-900 mb-2 pb-1 border-b border-gray-300">
|
||||||
${title}
|
${title}
|
||||||
@@ -128,7 +136,9 @@ const createSkillItem = (skill: any) => {
|
|||||||
|
|
||||||
const createEducationItem = (edu: any) => {
|
const createEducationItem = (edu: any) => {
|
||||||
const detailsList = edu.details
|
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 `
|
return `
|
||||||
@@ -188,32 +198,42 @@ const createHead = (name: string) => `
|
|||||||
</head>
|
</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">
|
<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>
|
<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">
|
<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.email ? `<div class="flex items-center gap-1">${emailIcon} ${basics.email}</div>` : ""}
|
||||||
${basics.profiles
|
${
|
||||||
?.map((profile: any) => {
|
basics.profiles
|
||||||
const icon = profileIcons[profile.network] || "";
|
?.map((profile: any) => {
|
||||||
const displayUrl = profile.url
|
const icon = profileIcons[profile.network] || "";
|
||||||
.replace(/^https?:\/\//, "")
|
const displayUrl = profile.url
|
||||||
.replace(/\/$/, "");
|
.replace(/^https?:\/\//, "")
|
||||||
return `<div class="flex items-center gap-1">${icon} ${displayUrl}</div>`;
|
.replace(/\/$/, "");
|
||||||
})
|
return `<div class="flex items-center gap-1">${icon} ${displayUrl}</div>`;
|
||||||
.join("") || ""
|
})
|
||||||
}
|
.join("") || ""
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const createSummarySection = (summary: any, resumeConfig: any) => {
|
const createSummarySection = (summary: any, resumeConfig: any) => {
|
||||||
if (!summary || !resumeConfig.sections.summary?.enabled) return "";
|
if (
|
||||||
|
!summary ||
|
||||||
|
!summary.content ||
|
||||||
|
resumeConfig.sections.summary?.enabled === false
|
||||||
|
)
|
||||||
|
return "";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<section class="mb-3">
|
<section class="mb-3">
|
||||||
<h2 class="text-sm font-semibold text-gray-900 mb-2 pb-1 border-b border-gray-300">
|
<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>
|
</h2>
|
||||||
<div class="text-xs text-gray-700 leading-tight">${summary.content}</div>
|
<div class="text-xs text-gray-700 leading-tight">${summary.content}</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -223,32 +243,32 @@ const createSummarySection = (summary: any, resumeConfig: any) => {
|
|||||||
const createColumnSections = (
|
const createColumnSections = (
|
||||||
sectionNames: string[],
|
sectionNames: string[],
|
||||||
sections: { [key: string]: string },
|
sections: { [key: string]: string },
|
||||||
resumeConfig: any
|
resumeConfig: any,
|
||||||
) => {
|
) => {
|
||||||
const sectionConfig = {
|
const sectionConfig = {
|
||||||
experience: {
|
experience: {
|
||||||
title: resumeConfig.sections.experience?.title || "Experience",
|
title: resumeConfig.sections.experience?.title || "Experience",
|
||||||
enabled: resumeConfig.sections.experience?.enabled,
|
enabled: resumeConfig.sections.experience?.enabled !== false,
|
||||||
spacing: "space-y-3",
|
spacing: "space-y-3",
|
||||||
},
|
},
|
||||||
skills: {
|
skills: {
|
||||||
title: resumeConfig.sections.skills?.title || "Skills",
|
title: resumeConfig.sections.skills?.title || "Skills",
|
||||||
enabled: resumeConfig.sections.skills?.enabled,
|
enabled: resumeConfig.sections.skills?.enabled !== false,
|
||||||
spacing: "space-y-1",
|
spacing: "space-y-1",
|
||||||
},
|
},
|
||||||
education: {
|
education: {
|
||||||
title: resumeConfig.sections.education?.title || "Education",
|
title: resumeConfig.sections.education?.title || "Education",
|
||||||
enabled: resumeConfig.sections.education?.enabled,
|
enabled: resumeConfig.sections.education?.enabled !== false,
|
||||||
spacing: "space-y-3",
|
spacing: "space-y-3",
|
||||||
},
|
},
|
||||||
volunteer: {
|
volunteer: {
|
||||||
title: resumeConfig.sections.volunteer?.title || "Volunteer Work",
|
title: resumeConfig.sections.volunteer?.title || "Volunteer Work",
|
||||||
enabled: resumeConfig.sections.volunteer?.enabled,
|
enabled: resumeConfig.sections.volunteer?.enabled !== false,
|
||||||
spacing: "space-y-2",
|
spacing: "space-y-2",
|
||||||
},
|
},
|
||||||
awards: {
|
awards: {
|
||||||
title: resumeConfig.sections.awards?.title || "Awards & Recognition",
|
title: resumeConfig.sections.awards?.title || "Awards & Recognition",
|
||||||
enabled: resumeConfig.sections.awards?.enabled,
|
enabled: resumeConfig.sections.awards?.enabled !== false,
|
||||||
spacing: "space-y-2",
|
spacing: "space-y-2",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -258,10 +278,13 @@ const createColumnSections = (
|
|||||||
const config = sectionConfig[sectionName as keyof typeof sectionConfig];
|
const config = sectionConfig[sectionName as keyof typeof sectionConfig];
|
||||||
const content = sections[sectionName];
|
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);
|
return createSection(config.title, content, config.spacing);
|
||||||
})
|
})
|
||||||
|
.filter((section) => section !== "")
|
||||||
.join("");
|
.join("");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -278,10 +301,20 @@ const fetchProfileIcons = async (profiles: any[]) => {
|
|||||||
|
|
||||||
const generateResumeHTML = async (data: ResumeData): Promise<string> => {
|
const generateResumeHTML = async (data: ResumeData): Promise<string> => {
|
||||||
const resumeConfig = siteConfig.resume;
|
const resumeConfig = siteConfig.resume;
|
||||||
const layout = resumeConfig.layout || {
|
// Use layout from TOML data, fallback to site config, then to default
|
||||||
leftColumn: ["experience", "volunteer", "awards"],
|
const layout = data.layout
|
||||||
rightColumn: ["skills", "education"],
|
? {
|
||||||
};
|
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
|
// Pre-fetch icons
|
||||||
const profileIcons = await fetchProfileIcons(data.basics.profiles);
|
const profileIcons = await fetchProfileIcons(data.basics.profiles);
|
||||||
@@ -289,11 +322,21 @@ const generateResumeHTML = async (data: ResumeData): Promise<string> => {
|
|||||||
|
|
||||||
// Generate section content
|
// Generate section content
|
||||||
const sections = {
|
const sections = {
|
||||||
experience: Array.isArray(data.experience) ? data.experience.map(createExperienceItem).join("") : "",
|
experience: Array.isArray(data.experience)
|
||||||
skills: Array.isArray(data.skills) ? data.skills.map(createSkillItem).join("") : "",
|
? data.experience.map(createExperienceItem).join("")
|
||||||
education: Array.isArray(data.education) ? data.education.map(createEducationItem).join("") : "",
|
: "",
|
||||||
volunteer: Array.isArray(data.volunteer) ? data.volunteer.map(createVolunteerItem).join("") : "",
|
skills: Array.isArray(data.skills)
|
||||||
awards: Array.isArray(data.awards) ? data.awards.map(createAwardItem).join("") : "",
|
? 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 `
|
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 }) => {
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
if (!siteConfig.resume.tomlFile || !siteConfig.resume.tomlFile.trim()) {
|
if (!siteConfig.resume.tomlFile || !siteConfig.resume.tomlFile.trim()) {
|
||||||
return new Response("Resume not configured", { status: 404 });
|
return new Response("Resume not configured", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(request.url);
|
let tomlContent: string;
|
||||||
const baseUrl = `${url.protocol}//${url.host}`;
|
|
||||||
|
|
||||||
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) {
|
const response = await fetch(`${baseUrl}${siteConfig.resume.tomlFile}`);
|
||||||
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 {
|
||||||
|
// It's raw TOML content
|
||||||
|
tomlContent = siteConfig.resume.tomlFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tomlContent = await response.text();
|
const pdfBuffer = await generatePDFFromToml(tomlContent);
|
||||||
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 new Response(pdfBuffer, {
|
return new Response(pdfBuffer, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -387,4 +446,47 @@ export const GET: APIRoute = async ({ request }) => {
|
|||||||
console.error("Error generating PDF:", error);
|
console.error("Error generating PDF:", error);
|
||||||
return new Response("Error generating PDF", { status: 500 });
|
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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
148
src/pages/api/resume/template.ts
Normal file
148
src/pages/api/resume/template.ts
Normal 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
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
@@ -3,6 +3,7 @@ import { Icon } from "astro-icon/components";
|
|||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
import ResumeSkills from "../components/ResumeSkills";
|
import ResumeSkills from "../components/ResumeSkills";
|
||||||
import ResumeDownloadButton from "../components/ResumeDownloadButton";
|
import ResumeDownloadButton from "../components/ResumeDownloadButton";
|
||||||
|
import ResumeSettingsModal from "../components/ResumeSettingsModal";
|
||||||
import { siteConfig } from "../config/data";
|
import { siteConfig } from "../config/data";
|
||||||
import "../styles/global.css";
|
import "../styles/global.css";
|
||||||
import * as TOML from "@iarna/toml";
|
import * as TOML from "@iarna/toml";
|
||||||
@@ -56,44 +57,48 @@ interface ResumeData {
|
|||||||
let resumeData: ResumeData | undefined = undefined;
|
let resumeData: ResumeData | undefined = undefined;
|
||||||
let fetchError: string | null = null;
|
let fetchError: string | null = null;
|
||||||
|
|
||||||
// Check if resume TOML file is configured before attempting to fetch
|
|
||||||
if (!siteConfig.resume.tomlFile || !siteConfig.resume.tomlFile.trim()) {
|
if (!siteConfig.resume.tomlFile || !siteConfig.resume.tomlFile.trim()) {
|
||||||
return Astro.redirect("/");
|
return Astro.redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the base URL
|
let tomlContent: string;
|
||||||
const baseUrl = Astro.url.origin;
|
|
||||||
|
|
||||||
// Fetch the TOML file from the public directory
|
if (siteConfig.resume.tomlFile.startsWith("/")) {
|
||||||
const response = await fetch(`${baseUrl}${siteConfig.resume.tomlFile}`);
|
const baseUrl = Astro.url.origin;
|
||||||
|
const response = await fetch(`${baseUrl}${siteConfig.resume.tomlFile}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to fetch resume: ${response.status} ${response.statusText}`,
|
`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;
|
resumeData = TOML.parse(tomlContent) as unknown as ResumeData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading resume data:", error);
|
console.error("Error loading resume data:", error);
|
||||||
// Return to home page when resume data cannot be loaded
|
|
||||||
return Astro.redirect("/");
|
return Astro.redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = resumeData;
|
const data = resumeData;
|
||||||
const resumeConfig = siteConfig.resume;
|
const resumeConfig = siteConfig.resume;
|
||||||
|
|
||||||
// At this point, data is guaranteed to exist since we redirect on error
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return Astro.redirect("/");
|
return Astro.redirect("/");
|
||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Resume">
|
<Layout title="Resume">
|
||||||
|
<ResumeSettingsModal client:load />
|
||||||
<div class="container mx-auto p-4 sm:p-6 lg:p-8 max-w-4xl w-full">
|
<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}
|
{data.basics.name}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
import TerminalComponent from "../components/Terminal.tsx";
|
import TerminalComponent from "../components/Terminal";
|
||||||
import "../styles/global.css";
|
import "../styles/global.css";
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@@ -51,7 +51,7 @@ export interface NavigationItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ResumeConfig {
|
export interface ResumeConfig {
|
||||||
tomlFile: string;
|
tomlFile: string; // Can be a file path or raw TOML content
|
||||||
pdfFile: {
|
pdfFile: {
|
||||||
path: string;
|
path: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
Reference in New Issue
Block a user