Simplified the PDF gen quite a bit :)
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m37s
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m37s
This commit is contained in:
14
Dockerfile
14
Dockerfile
@@ -54,20 +54,6 @@ RUN pnpm run build
|
|||||||
|
|
||||||
FROM node:24-alpine AS runtime
|
FROM node:24-alpine AS runtime
|
||||||
|
|
||||||
# Install runtime dependencies for Playwright
|
|
||||||
RUN apk add --no-cache \
|
|
||||||
chromium \
|
|
||||||
nss \
|
|
||||||
freetype \
|
|
||||||
freetype-dev \
|
|
||||||
harfbuzz \
|
|
||||||
ca-certificates \
|
|
||||||
ttf-freefont
|
|
||||||
|
|
||||||
# Tell Playwright to use the installed Chromium
|
|
||||||
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/bin
|
|
||||||
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy built application
|
# Copy built application
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ My personal website built with Astro and Preact!
|
|||||||
- **Blog Posts**
|
- **Blog Posts**
|
||||||
- **Projects**
|
- **Projects**
|
||||||
- **Talks**
|
- **Talks**
|
||||||
- **Terminal View**
|
|
||||||
|
|
||||||
** Nix with flakes enabled is required for local development! Install it on your OS of choice OR use NixOS!
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -18,10 +15,6 @@ My personal website built with Astro and Preact!
|
|||||||
# Install dependencies
|
# Install dependencies
|
||||||
pnpm i
|
pnpm i
|
||||||
|
|
||||||
# Start development server
|
|
||||||
pnpm nix # Build with flakes
|
|
||||||
pnpm dev
|
|
||||||
|
|
||||||
# Build for production
|
# Build for production
|
||||||
pnpm build
|
pnpm build
|
||||||
```
|
```
|
||||||
|
|||||||
61
flake.lock
generated
61
flake.lock
generated
@@ -1,61 +0,0 @@
|
|||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"flake-utils": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1731533236,
|
|
||||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1753250450,
|
|
||||||
"narHash": "sha256-i+CQV2rPmP8wHxj0aq4siYyohHwVlsh40kV89f3nw1s=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "fc02ee70efb805d3b2865908a13ddd4474557ecf",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixos-unstable",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"inputs": {
|
|
||||||
"flake-utils": "flake-utils",
|
|
||||||
"nixpkgs": "nixpkgs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"systems": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "root",
|
|
||||||
"version": 7
|
|
||||||
}
|
|
||||||
120
flake.nix
120
flake.nix
@@ -1,120 +0,0 @@
|
|||||||
# flake.nix
|
|
||||||
{
|
|
||||||
description = "A portable development environment for atridotdad with Nix Flakes";
|
|
||||||
|
|
||||||
inputs = {
|
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
|
||||||
};
|
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils }:
|
|
||||||
flake-utils.lib.eachDefaultSystem (system:
|
|
||||||
let
|
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
|
||||||
|
|
||||||
isDarwin = pkgs.stdenv.isDarwin;
|
|
||||||
isLinux = pkgs.stdenv.isLinux;
|
|
||||||
|
|
||||||
commonDevTools = with pkgs; [
|
|
||||||
nodejs_24
|
|
||||||
nodePackages.pnpm
|
|
||||||
git
|
|
||||||
curl
|
|
||||||
];
|
|
||||||
|
|
||||||
# Common libraries needed for Playwright
|
|
||||||
playwrightCommonLibs = with pkgs; [
|
|
||||||
glib
|
|
||||||
nss
|
|
||||||
nspr
|
|
||||||
dbus
|
|
||||||
atk
|
|
||||||
at-spi2-atk
|
|
||||||
at-spi2-core
|
|
||||||
cups
|
|
||||||
expat
|
|
||||||
libxkbcommon
|
|
||||||
cairo
|
|
||||||
pango
|
|
||||||
fontconfig
|
|
||||||
freetype
|
|
||||||
harfbuzz
|
|
||||||
icu
|
|
||||||
libpng
|
|
||||||
gnutls
|
|
||||||
];
|
|
||||||
|
|
||||||
# Linux-specific libraries for Playwright
|
|
||||||
playwrightLinuxSpecificLibs = with pkgs; [
|
|
||||||
glibc
|
|
||||||
libgcc
|
|
||||||
xorg.libX11
|
|
||||||
xorg.libxcb
|
|
||||||
xorg.libXext
|
|
||||||
xorg.libXfixes
|
|
||||||
xorg.libXrandr
|
|
||||||
xorg.libXcomposite
|
|
||||||
xorg.libXdamage
|
|
||||||
xorg.libXcursor
|
|
||||||
xorg.libXi
|
|
||||||
xorg.libXrender
|
|
||||||
xorg.libXtst
|
|
||||||
mesa
|
|
||||||
libglvnd
|
|
||||||
libdrm
|
|
||||||
udev
|
|
||||||
alsa-lib
|
|
||||||
];
|
|
||||||
|
|
||||||
playwrightSelfDownloadLibs = playwrightCommonLibs ++ (if isLinux then playwrightLinuxSpecificLibs else []);
|
|
||||||
|
|
||||||
playwrightLibPath = pkgs.lib.makeBinPath playwrightSelfDownloadLibs;
|
|
||||||
|
|
||||||
in
|
|
||||||
{
|
|
||||||
devShells.default = pkgs.mkShell {
|
|
||||||
packages = commonDevTools ++ (
|
|
||||||
if isDarwin
|
|
||||||
then playwrightCommonLibs # For macOS, Playwright will download Chromium.
|
|
||||||
else [ pkgs.chromium ] ++ playwrightSelfDownloadLibs # For Linux, provide Chromium and its dependencies
|
|
||||||
);
|
|
||||||
|
|
||||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = if isDarwin then "0" else "1";
|
|
||||||
|
|
||||||
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH = pkgs.lib.optionalString isLinux "${pkgs.chromium}/bin/chromium";
|
|
||||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD = if isDarwin then "false" else "true";
|
|
||||||
PUPPETEER_EXECUTABLE_PATH = pkgs.lib.optionalString isLinux "${pkgs.chromium}/bin/chromium";
|
|
||||||
|
|
||||||
shellHook = ''
|
|
||||||
echo "🚀 atridotdad development environment loaded!"
|
|
||||||
echo "Node version: $(node --version)"
|
|
||||||
echo "pnpm version: $(pnpm --version)"
|
|
||||||
|
|
||||||
${pkgs.lib.optionalString isDarwin ''
|
|
||||||
echo "Chromium path: Playwright will download its own for macOS"
|
|
||||||
|
|
||||||
export LD_LIBRARY_PATH="${playwrightLibPath}:$LD_LIBRARY_PATH"
|
|
||||||
|
|
||||||
PLAYWRIGHT_BROWSERS_PATH="''${TMPDIR:-$HOME/.cache}/ms-playwright"
|
|
||||||
export PLAYWRIGHT_BROWSERS_PATH
|
|
||||||
|
|
||||||
if [ ! -d "$PLAYWRIGHT_BROWSERS_PATH" ] || [ -z "$(ls -A "$PLAYWRIGHT_BROWSERS_PATH")" ]; then
|
|
||||||
echo "🌐 Installing Playwright browsers (for macOS)..."
|
|
||||||
pnpm exec playwright install chromium
|
|
||||||
else
|
|
||||||
echo "✅ Playwright browsers already installed (for macOS)."
|
|
||||||
fi
|
|
||||||
''}
|
|
||||||
|
|
||||||
${pkgs.lib.optionalString isLinux ''
|
|
||||||
echo "Chromium path: ${pkgs.chromium}/bin/chromium"
|
|
||||||
''}
|
|
||||||
|
|
||||||
if [ ! -d "node_modules" ]; then
|
|
||||||
echo "📦 Installing pnpm dependencies..."
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
13
package.json
13
package.json
@@ -16,26 +16,27 @@
|
|||||||
"@astrojs/rss": "^4.0.14",
|
"@astrojs/rss": "^4.0.14",
|
||||||
"@iarna/toml": "^2.2.5",
|
"@iarna/toml": "^2.2.5",
|
||||||
"@preact/signals": "^2.5.1",
|
"@preact/signals": "^2.5.1",
|
||||||
|
"@react-pdf/renderer": "^4.3.1",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"astro": "^5.16.0",
|
"astro": "^5.16.3",
|
||||||
"astro-icon": "^1.1.5",
|
"astro-icon": "^1.1.5",
|
||||||
"lucide-preact": "^0.554.0",
|
"lucide-preact": "^0.555.0",
|
||||||
"playwright": "^1.56.1",
|
"preact": "^10.28.0",
|
||||||
"preact": "^10.27.2",
|
"react": "^19.2.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tailwindcss": "^4.1.17"
|
"tailwindcss": "^4.1.17"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/mdi": "^1.2.3",
|
"@iconify-json/mdi": "^1.2.3",
|
||||||
"@iconify-json/simple-icons": "^1.2.59",
|
"@iconify-json/simple-icons": "^1.2.61",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
"daisyui": "^5.5.5"
|
"daisyui": "^5.5.5"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@tailwindcss/oxide",
|
"@tailwindcss/oxide",
|
||||||
"esbuild",
|
"esbuild",
|
||||||
"puppeteer",
|
|
||||||
"sharp"
|
"sharp"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
957
pnpm-lock.yaml
generated
957
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ export default function ResumeDownloadButton({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/resume/pdf?t=${Date.now()}`);
|
const response = await fetch(`/api/resume/generate?t=${Date.now()}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default function ResumeSettingsModal({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/resume/pdf", {
|
const response = await fetch("/api/resume/generate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/plain",
|
"Content-Type": "text/plain",
|
||||||
|
|||||||
193
src/pages/api/resume/generate.ts
Normal file
193
src/pages/api/resume/generate.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { config } from "../../../config";
|
||||||
|
import * as TOML from "@iarna/toml";
|
||||||
|
import { renderToStream } from "@react-pdf/renderer";
|
||||||
|
import { ResumeDocument } from "../../../pdf/ResumeDocument";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
async function getSimpleIconPath(iconName: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://cdn.jsdelivr.net/npm/simple-icons@v10/icons/${iconName.toLowerCase()}.svg`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`Failed to fetch icon: ${iconName}`);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const svgContent = await response.text();
|
||||||
|
const match = svgContent.match(/d="([^"]+)"/);
|
||||||
|
return match ? match[1] : "";
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Error fetching icon ${iconName}:`, error);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMdiIconPath(iconName: string): string {
|
||||||
|
const iconMap: { [key: string]: string } = {
|
||||||
|
"mdi:email":
|
||||||
|
"M20,8L12,13L4,8V6L12,11L20,6M20,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V6C2.89,4 20,4.89 20,4Z",
|
||||||
|
"mdi:phone":
|
||||||
|
"M6.62,10.79C8.06,13.62 10.38,15.94 13.21,17.38L15.41,15.18C15.69,14.9 16.08,14.82 16.43,14.93C17.55,15.3 18.75,15.5 20,15.5A1,1 0 0,1 21,16.5V20A1,1 0 0,1 20,21A17,17 0 0,1 3,4A1,1 0 0,1 4,3H7.5A1,1 0 0,1 8.5,4C8.5,5.25 8.7,6.45 9.07,7.57C9.18,7.92 9.1,8.31 8.82,8.59L6.62,10.79Z",
|
||||||
|
"mdi:download": "M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z",
|
||||||
|
"mdi:link":
|
||||||
|
"M3.9,12C3.9,10.29 5.29,8.9 7,8.9H11V7H7A5,5 0 0,0 2,12A5,5 0 0,0 7,17H11V15.1H7C5.29,15.1 3.9,13.71 3.9,12M8,13H16V11H8V13M17,7H13V8.9H17C18.71,8.9 20.1,10.29 20.1,12C20.1,13.71 18.71,15.1 17,15.1H13V17H17A5,5 0 0,0 22,12A5,5 0 0,0 17,7Z",
|
||||||
|
};
|
||||||
|
return iconMap[iconName] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResumeData {
|
||||||
|
basics: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
website?: string;
|
||||||
|
profiles: {
|
||||||
|
network: string;
|
||||||
|
username: string;
|
||||||
|
url: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
layout?: {
|
||||||
|
left_column?: string[];
|
||||||
|
right_column?: string[];
|
||||||
|
};
|
||||||
|
summary: {
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
experience: {
|
||||||
|
company: string;
|
||||||
|
position: string;
|
||||||
|
location: string;
|
||||||
|
date: string;
|
||||||
|
description: string[];
|
||||||
|
url?: string;
|
||||||
|
}[];
|
||||||
|
education: {
|
||||||
|
institution: string;
|
||||||
|
degree: string;
|
||||||
|
field: string;
|
||||||
|
date: string;
|
||||||
|
details?: string[];
|
||||||
|
}[];
|
||||||
|
skills: {
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
}[];
|
||||||
|
volunteer: {
|
||||||
|
organization: string;
|
||||||
|
position: string;
|
||||||
|
date: string;
|
||||||
|
}[];
|
||||||
|
awards: {
|
||||||
|
title: string;
|
||||||
|
organization: string;
|
||||||
|
date: string;
|
||||||
|
description?: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchProfileIcons = async (profiles: any[]) => {
|
||||||
|
const profileIcons: { [key: string]: string } = {};
|
||||||
|
if (profiles) {
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const iconName = profile.network.toLowerCase();
|
||||||
|
profileIcons[profile.network] = await getSimpleIconPath(iconName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return profileIcons;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generatePDF = async (data: ResumeData) => {
|
||||||
|
const resumeConfig = config.resumeConfig;
|
||||||
|
|
||||||
|
const profileIcons = await fetchProfileIcons(data.basics.profiles);
|
||||||
|
const icons = {
|
||||||
|
...profileIcons,
|
||||||
|
email: getMdiIconPath("mdi:email"),
|
||||||
|
phone: getMdiIconPath("mdi:phone"),
|
||||||
|
};
|
||||||
|
|
||||||
|
return await renderToStream(
|
||||||
|
ResumeDocument({ data, resumeConfig, icons })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
if (!config.resumeConfig.tomlFile || !config.resumeConfig.tomlFile.trim()) {
|
||||||
|
return new Response("Resume not configured", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let tomlContent: string;
|
||||||
|
|
||||||
|
if (config.resumeConfig.tomlFile.startsWith("/")) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const baseUrl = `${url.protocol}//${url.host}`;
|
||||||
|
const response = await fetch(`${baseUrl}${config.resumeConfig.tomlFile}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch resume: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tomlContent = await response.text();
|
||||||
|
} else {
|
||||||
|
tomlContent = config.resumeConfig.tomlFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resumeData: ResumeData = TOML.parse(
|
||||||
|
tomlContent,
|
||||||
|
) as unknown as ResumeData;
|
||||||
|
|
||||||
|
const stream = await generatePDF(resumeData);
|
||||||
|
|
||||||
|
return new Response(stream as any, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/pdf",
|
||||||
|
"Content-Disposition": `attachment; filename="Atridad_Lahiji_Resume.pdf"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resumeData.basics?.name) {
|
||||||
|
return new Response("Resume must include basics.name", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = await generatePDF(resumeData);
|
||||||
|
const filename = `${resumeData.basics.name.replace(/[^a-zA-Z0-9]/g, "_")}_Resume.pdf`;
|
||||||
|
|
||||||
|
return new Response(stream as any, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/pdf",
|
||||||
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating PDF:", error);
|
||||||
|
return new Response("Error generating PDF", { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,498 +0,0 @@
|
|||||||
import type { APIRoute } from "astro";
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import { config } from "../../../config";
|
|
||||||
import * as TOML from "@iarna/toml";
|
|
||||||
|
|
||||||
// Helper function to fetch and return SVG icon from Simple Icons CDN
|
|
||||||
async function getSimpleIcon(iconName: string): Promise<string> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`https://cdn.jsdelivr.net/npm/simple-icons@v10/icons/${iconName.toLowerCase()}.svg`,
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
console.warn(`Failed to fetch icon: ${iconName}`);
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const svgContent = await response.text();
|
|
||||||
return svgContent.replace(
|
|
||||||
"<svg",
|
|
||||||
'<svg style="width: 12px; height: 12px; display: inline-block; vertical-align: middle; fill: currentColor;"',
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Error fetching icon ${iconName}:`, error);
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to get MDI icon SVG
|
|
||||||
function getMdiIcon(iconName: string): string {
|
|
||||||
const iconMap: { [key: string]: string } = {
|
|
||||||
"mdi:email":
|
|
||||||
'<svg style="width: 12px; height: 12px; display: inline-block; vertical-align: middle; fill: currentColor;" viewBox="0 0 24 24"><path d="M20,8L12,13L4,8V6L12,11L20,6M20,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V6C2.89,4 20,4.89 20,4Z"/></svg>',
|
|
||||||
"mdi:phone":
|
|
||||||
'<svg style="width: 12px; height: 12px; display: inline-block; vertical-align: middle; fill: currentColor;" viewBox="0 0 24 24"><path d="M6.62,10.79C8.06,13.62 10.38,15.94 13.21,17.38L15.41,15.18C15.69,14.9 16.08,14.82 16.43,14.93C17.55,15.3 18.75,15.5 20,15.5A1,1 0 0,1 21,16.5V20A1,1 0 0,1 20,21A17,17 0 0,1 3,4A1,1 0 0,1 4,3H7.5A1,1 0 0,1 8.5,4C8.5,5.25 8.7,6.45 9.07,7.57C9.18,7.92 9.1,8.31 8.82,8.59L6.62,10.79Z"/></svg>',
|
|
||||||
"mdi:download":
|
|
||||||
'<svg style="width: 12px; height: 12px; display: inline-block; vertical-align: middle; fill: currentColor;" viewBox="0 0 24 24"><path d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z"/></svg>',
|
|
||||||
"mdi:link":
|
|
||||||
'<svg style="width: 12px; height: 12px; display: inline-block; vertical-align: middle; fill: currentColor;" viewBox="0 0 24 24"><path d="M3.9,12C3.9,10.29 5.29,8.9 7,8.9H11V7H7A5,5 0 0,0 2,12A5,5 0 0,0 7,17H11V15.1H7C5.29,15.1 3.9,13.71 3.9,12M8,13H16V11H8V13M17,7H13V8.9H17C18.71,8.9 20.1,10.29 20.1,12C20.1,13.71 18.71,15.1 17,15.1H13V17H17A5,5 0 0,0 22,12A5,5 0 0,0 17,7Z"/></svg>',
|
|
||||||
};
|
|
||||||
return iconMap[iconName] || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResumeData {
|
|
||||||
basics: {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
phone?: string;
|
|
||||||
website?: string;
|
|
||||||
profiles: {
|
|
||||||
network: string;
|
|
||||||
username: string;
|
|
||||||
url: string;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
layout?: {
|
|
||||||
left_column?: string[];
|
|
||||||
right_column?: string[];
|
|
||||||
};
|
|
||||||
summary: {
|
|
||||||
content: string;
|
|
||||||
};
|
|
||||||
experience: {
|
|
||||||
company: string;
|
|
||||||
position: string;
|
|
||||||
location: string;
|
|
||||||
date: string;
|
|
||||||
description: string[];
|
|
||||||
url?: string;
|
|
||||||
}[];
|
|
||||||
education: {
|
|
||||||
institution: string;
|
|
||||||
degree: string;
|
|
||||||
field: string;
|
|
||||||
date: string;
|
|
||||||
details?: string[];
|
|
||||||
}[];
|
|
||||||
skills: {
|
|
||||||
name: string;
|
|
||||||
level: number;
|
|
||||||
}[];
|
|
||||||
volunteer: {
|
|
||||||
organization: string;
|
|
||||||
position: string;
|
|
||||||
date: string;
|
|
||||||
}[];
|
|
||||||
awards: {
|
|
||||||
title: string;
|
|
||||||
organization: string;
|
|
||||||
date: string;
|
|
||||||
description?: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Template helper functions
|
|
||||||
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}
|
|
||||||
</h2>
|
|
||||||
<div class="${spacing}">
|
|
||||||
${content}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const createExperienceItem = (exp: any) => `
|
|
||||||
<div class="mb-3 pl-2 border-l-2 border-blue-600">
|
|
||||||
<h3 class="text-xs font-semibold text-gray-900 mb-1">${exp.position}</h3>
|
|
||||||
<div class="text-xs text-gray-600 mb-1">
|
|
||||||
<span class="font-medium">${exp.company}</span>
|
|
||||||
<span class="mx-1">•</span>
|
|
||||||
<span>${exp.date}</span>
|
|
||||||
<span class="mx-1">•</span>
|
|
||||||
<span>${exp.location}</span>
|
|
||||||
</div>
|
|
||||||
<ul class="text-xs text-gray-700 leading-tight ml-3 list-disc">
|
|
||||||
${exp.description.map((item: string) => `<li class="mb-1">${item}</li>`).join("")}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const createSkillItem = (skill: any) => {
|
|
||||||
const progressValue = skill.level * 20;
|
|
||||||
return `
|
|
||||||
<div class="mb-1">
|
|
||||||
<div class="flex justify-between items-center mb-0.5">
|
|
||||||
<span class="text-xs font-medium text-gray-900">${skill.name}</span>
|
|
||||||
<span class="text-xs text-gray-600">${skill.level}/5</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
|
||||||
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300" style="width: ${progressValue}%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createEducationItem = (edu: any) => {
|
|
||||||
const detailsList = edu.details
|
|
||||||
? edu.details
|
|
||||||
.map((detail: string) => `<li class="mb-1">${detail}</li>`)
|
|
||||||
.join("")
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="mb-3 pl-2 border-l-2 border-green-600">
|
|
||||||
<h3 class="text-xs font-semibold text-gray-900 mb-1">${edu.institution}</h3>
|
|
||||||
<div class="text-xs text-gray-600 mb-1">
|
|
||||||
<span class="font-medium">${edu.degree} in ${edu.field}</span>
|
|
||||||
<span class="mx-1">•</span>
|
|
||||||
<span>${edu.date}</span>
|
|
||||||
</div>
|
|
||||||
${detailsList ? `<ul class="text-xs text-gray-700 leading-tight ml-3 list-disc">${detailsList}</ul>` : ""}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createVolunteerItem = (vol: any) => `
|
|
||||||
<div class="mb-2 pl-2 border-l-2 border-purple-600">
|
|
||||||
<h3 class="text-xs font-semibold text-gray-900 mb-1">${vol.organization}</h3>
|
|
||||||
<div class="text-xs text-gray-600">
|
|
||||||
<span class="font-medium">${vol.position}</span>
|
|
||||||
<span class="mx-1">•</span>
|
|
||||||
<span>${vol.date}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const createAwardItem = (award: any) => `
|
|
||||||
<div class="mb-2 pl-2 border-l-2 border-yellow-600">
|
|
||||||
<h3 class="text-xs font-semibold text-gray-900 mb-1">${award.title}</h3>
|
|
||||||
<div class="text-xs text-gray-600 mb-1">
|
|
||||||
<span class="font-medium">${award.organization}</span>
|
|
||||||
<span class="mx-1">•</span>
|
|
||||||
<span>${award.date}</span>
|
|
||||||
</div>
|
|
||||||
${award.description ? `<div class="text-xs text-gray-700 leading-tight">${award.description}</div>` : ""}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const createHead = (name: string) => `
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>${name} - Resume</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<style>
|
|
||||||
@media print {
|
|
||||||
body {
|
|
||||||
print-color-adjust: exact;
|
|
||||||
-webkit-print-color-adjust: exact;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.resume-container {
|
|
||||||
max-width: 8.5in;
|
|
||||||
min-height: 11in;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const createHeader = (
|
|
||||||
basics: any,
|
|
||||||
emailIcon: string,
|
|
||||||
phoneIcon: 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.phone ? `<div class="flex items-center gap-1">${phoneIcon} ${basics.phone}</div>` : ""}
|
|
||||||
${
|
|
||||||
basics.profiles
|
|
||||||
?.map((profile: any) => {
|
|
||||||
const icon = profileIcons[profile.network] || "";
|
|
||||||
const displayUrl = profile.url
|
|
||||||
.replace(/^https?:\/\//, "")
|
|
||||||
.replace(/\/$/, "");
|
|
||||||
return `<div class="flex items-center gap-1">${icon} ${displayUrl}</div>`;
|
|
||||||
})
|
|
||||||
.join("") || ""
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const createSummarySection = (summary: any, resumeConfig: any) => {
|
|
||||||
if (
|
|
||||||
!summary ||
|
|
||||||
!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"}
|
|
||||||
</h2>
|
|
||||||
<div class="text-xs text-gray-700 leading-tight">${summary.content}</div>
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createColumnSections = (
|
|
||||||
sectionNames: string[],
|
|
||||||
sections: { [key: string]: string },
|
|
||||||
resumeConfig: any,
|
|
||||||
) => {
|
|
||||||
const sectionConfig = {
|
|
||||||
experience: {
|
|
||||||
title: resumeConfig.sections.experience?.title || "Experience",
|
|
||||||
enabled: resumeConfig.sections.experience?.enabled !== false,
|
|
||||||
spacing: "space-y-3",
|
|
||||||
},
|
|
||||||
skills: {
|
|
||||||
title: resumeConfig.sections.skills?.title || "Skills",
|
|
||||||
enabled: resumeConfig.sections.skills?.enabled !== false,
|
|
||||||
spacing: "space-y-1",
|
|
||||||
},
|
|
||||||
education: {
|
|
||||||
title: resumeConfig.sections.education?.title || "Education",
|
|
||||||
enabled: resumeConfig.sections.education?.enabled !== false,
|
|
||||||
spacing: "space-y-3",
|
|
||||||
},
|
|
||||||
volunteer: {
|
|
||||||
title: resumeConfig.sections.volunteer?.title || "Volunteer Work",
|
|
||||||
enabled: resumeConfig.sections.volunteer?.enabled !== false,
|
|
||||||
spacing: "space-y-2",
|
|
||||||
},
|
|
||||||
awards: {
|
|
||||||
title: resumeConfig.sections.awards?.title || "Awards & Recognition",
|
|
||||||
enabled: resumeConfig.sections.awards?.enabled !== false,
|
|
||||||
spacing: "space-y-2",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return sectionNames
|
|
||||||
.map((sectionName) => {
|
|
||||||
const config = sectionConfig[sectionName as keyof typeof sectionConfig];
|
|
||||||
const content = sections[sectionName];
|
|
||||||
|
|
||||||
// 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("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchProfileIcons = async (profiles: any[]) => {
|
|
||||||
const profileIcons: { [key: string]: string } = {};
|
|
||||||
if (profiles) {
|
|
||||||
for (const profile of profiles) {
|
|
||||||
const iconName = profile.network.toLowerCase();
|
|
||||||
profileIcons[profile.network] = await getSimpleIcon(iconName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return profileIcons;
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateResumeHTML = async (data: ResumeData): Promise<string> => {
|
|
||||||
const resumeConfig = config.resumeConfig;
|
|
||||||
// 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);
|
|
||||||
const emailIcon = getMdiIcon("mdi:email");
|
|
||||||
const phoneIcon = getMdiIcon("mdi:phone");
|
|
||||||
|
|
||||||
// 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("")
|
|
||||||
: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
return `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
${createHead(data.basics.name)}
|
|
||||||
<body class="bg-white text-gray-900 text-xs leading-tight p-3">
|
|
||||||
<div class="resume-container mx-auto">
|
|
||||||
${createHeader(data.basics, emailIcon, phoneIcon, profileIcons)}
|
|
||||||
${createSummarySection(data.summary, resumeConfig)}
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div class="space-y-4">
|
|
||||||
${createColumnSections(layout.leftColumn ?? [], sections, resumeConfig)}
|
|
||||||
</div>
|
|
||||||
<div class="space-y-4">
|
|
||||||
${createColumnSections(layout.rightColumn ?? [], sections, resumeConfig)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (!config.resumeConfig.tomlFile || !config.resumeConfig.tomlFile.trim()) {
|
|
||||||
return new Response("Resume not configured", { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
let tomlContent: string;
|
|
||||||
|
|
||||||
// Check if tomlFile is a path (starts with /) or raw content
|
|
||||||
if (config.resumeConfig.tomlFile.startsWith("/")) {
|
|
||||||
// It's a file path - fetch it
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const baseUrl = `${url.protocol}//${url.host}`;
|
|
||||||
|
|
||||||
const response = await fetch(`${baseUrl}${config.resumeConfig.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 = config.resumeConfig.tomlFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pdfBuffer = await generatePDFFromToml(tomlContent);
|
|
||||||
|
|
||||||
return new Response(pdfBuffer, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/pdf",
|
|
||||||
"Content-Disposition": `attachment; filename="Atridad_Lahiji_Resume.pdf"`,
|
|
||||||
"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 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
333
src/pdf/ResumeDocument.tsx
Normal file
333
src/pdf/ResumeDocument.tsx
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
/** @jsxImportSource react */
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Document,
|
||||||
|
Page,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
StyleSheet,
|
||||||
|
Link,
|
||||||
|
Svg,
|
||||||
|
Path,
|
||||||
|
Font,
|
||||||
|
} from "@react-pdf/renderer";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: {
|
||||||
|
padding: 24,
|
||||||
|
fontFamily: "Helvetica",
|
||||||
|
fontSize: 8,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
color: "#111827",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
marginBottom: 10,
|
||||||
|
paddingBottom: 6,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#D1D5DB",
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: "column",
|
||||||
|
display: "flex",
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: 6,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
contactRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
color: "#4B5563",
|
||||||
|
fontSize: 8,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
contactItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginHorizontal: 8,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
width: 9,
|
||||||
|
height: 9,
|
||||||
|
marginRight: 4,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: "bold",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#D1D5DB",
|
||||||
|
marginBottom: 4,
|
||||||
|
paddingBottom: 2,
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
flexDirection: "row",
|
||||||
|
},
|
||||||
|
column: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: 8,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
// Experience
|
||||||
|
experienceItem: {
|
||||||
|
marginBottom: 6,
|
||||||
|
paddingLeft: 8,
|
||||||
|
borderLeftWidth: 2,
|
||||||
|
borderLeftColor: "#2563EB",
|
||||||
|
},
|
||||||
|
itemTitle: {
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: 1,
|
||||||
|
},
|
||||||
|
itemSubtitle: {
|
||||||
|
fontSize: 8,
|
||||||
|
color: "#4B5563",
|
||||||
|
marginBottom: 1,
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
listItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
marginBottom: 1,
|
||||||
|
},
|
||||||
|
bullet: {
|
||||||
|
width: 8,
|
||||||
|
fontSize: 8,
|
||||||
|
marginRight: 2,
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
flex: 1,
|
||||||
|
color: "#374151",
|
||||||
|
fontSize: 8,
|
||||||
|
},
|
||||||
|
// Skills
|
||||||
|
skillItem: {
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
skillHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 1,
|
||||||
|
},
|
||||||
|
progressBarBg: {
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: "#E5E7EB", // bg-gray-200
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
progressBarFill: {
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: "#2563EB", // bg-blue-600
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
// Education
|
||||||
|
educationItem: {
|
||||||
|
marginBottom: 6,
|
||||||
|
paddingLeft: 8,
|
||||||
|
borderLeftWidth: 2,
|
||||||
|
borderLeftColor: "#16A34A",
|
||||||
|
},
|
||||||
|
// Volunteer
|
||||||
|
volunteerItem: {
|
||||||
|
marginBottom: 4,
|
||||||
|
paddingLeft: 8,
|
||||||
|
borderLeftWidth: 2,
|
||||||
|
borderLeftColor: "#9333EA",
|
||||||
|
},
|
||||||
|
// Awards
|
||||||
|
awardItem: {
|
||||||
|
marginBottom: 4,
|
||||||
|
paddingLeft: 8,
|
||||||
|
borderLeftWidth: 2,
|
||||||
|
borderLeftColor: "#CA8A04",
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
marginBottom: 8,
|
||||||
|
color: "#374151",
|
||||||
|
fontSize: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const Icon = ({ path, color = "currentColor" }: { path: string; color?: string }) => (
|
||||||
|
<Svg viewBox="0 0 24 24" style={styles.icon}>
|
||||||
|
<Path d={path} fill={color} />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ResumeDocumentProps {
|
||||||
|
data: any;
|
||||||
|
resumeConfig: any;
|
||||||
|
icons: { [key: string]: string }; // Map of icon name to SVG path
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResumeDocument = ({ data, resumeConfig, icons }: ResumeDocumentProps) => {
|
||||||
|
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"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSectionContent = (sectionName: string) => {
|
||||||
|
switch (sectionName) {
|
||||||
|
case "experience":
|
||||||
|
return data.experience?.map((exp: any, i: number) => (
|
||||||
|
<View key={i} style={styles.experienceItem}>
|
||||||
|
<Text style={styles.itemTitle}>{exp.position}</Text>
|
||||||
|
<Text style={styles.itemSubtitle}>
|
||||||
|
{exp.company} • {exp.date} • {exp.location}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.list}>
|
||||||
|
{exp.description?.map((desc: string, j: number) => (
|
||||||
|
<View key={j} style={styles.listItem}>
|
||||||
|
<Text style={styles.bullet}>•</Text>
|
||||||
|
<Text style={styles.listContent}>{desc}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
));
|
||||||
|
case "skills":
|
||||||
|
return data.skills?.map((skill: any, i: number) => (
|
||||||
|
<View key={i} style={styles.skillItem}>
|
||||||
|
<View style={styles.skillHeader}>
|
||||||
|
<Text style={{ fontSize: 8 }}>{skill.name}</Text>
|
||||||
|
<Text style={{ color: "#4B5563", fontSize: 8 }}>{skill.level}/5</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.progressBarBg}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.progressBarFill,
|
||||||
|
{ width: `${skill.level * 20}%` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
));
|
||||||
|
case "education":
|
||||||
|
return data.education?.map((edu: any, i: number) => (
|
||||||
|
<View key={i} style={styles.educationItem}>
|
||||||
|
<Text style={styles.itemTitle}>{edu.institution}</Text>
|
||||||
|
<Text style={styles.itemSubtitle}>
|
||||||
|
{edu.degree} in {edu.field} • {edu.date}
|
||||||
|
</Text>
|
||||||
|
{edu.details && (
|
||||||
|
<View style={styles.list}>
|
||||||
|
{edu.details.map((detail: string, j: number) => (
|
||||||
|
<View key={j} style={styles.listItem}>
|
||||||
|
<Text style={styles.bullet}>•</Text>
|
||||||
|
<Text style={styles.listContent}>{detail}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
));
|
||||||
|
case "volunteer":
|
||||||
|
return data.volunteer?.map((vol: any, i: number) => (
|
||||||
|
<View key={i} style={styles.volunteerItem}>
|
||||||
|
<Text style={styles.itemTitle}>{vol.organization}</Text>
|
||||||
|
<Text style={styles.itemSubtitle}>
|
||||||
|
{vol.position} • {vol.date}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
));
|
||||||
|
case "awards":
|
||||||
|
return data.awards?.map((award: any, i: number) => (
|
||||||
|
<View key={i} style={styles.awardItem}>
|
||||||
|
<Text style={styles.itemTitle}>{award.title}</Text>
|
||||||
|
<Text style={styles.itemSubtitle}>
|
||||||
|
{award.organization} • {award.date}
|
||||||
|
</Text>
|
||||||
|
{award.description && (
|
||||||
|
<Text style={{ color: "#374151" }}>{award.description}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
));
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderColumn = (sectionNames: string[]) => {
|
||||||
|
return sectionNames.map((name) => {
|
||||||
|
const config = resumeConfig.sections[name];
|
||||||
|
const content = renderSectionContent(name);
|
||||||
|
|
||||||
|
// Check if section has content (simple check)
|
||||||
|
const hasContent = data[name] && data[name].length > 0;
|
||||||
|
|
||||||
|
if (!config || !hasContent || config.enabled === false) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={name} style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>{config.title}</Text>
|
||||||
|
{content}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Document>
|
||||||
|
<Page size="A4" style={styles.page}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.name}>{data.basics.name}</Text>
|
||||||
|
<View style={styles.contactRow}>
|
||||||
|
{data.basics.email && (
|
||||||
|
<View style={styles.contactItem}>
|
||||||
|
{icons["email"] && <Icon path={icons["email"]} />}
|
||||||
|
<Text>{data.basics.email}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{data.basics.phone && (
|
||||||
|
<View style={styles.contactItem}>
|
||||||
|
{icons["phone"] && <Icon path={icons["phone"]} />}
|
||||||
|
<Text>{data.basics.phone}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{data.basics.profiles?.map((profile: any, i: number) => (
|
||||||
|
<View key={i} style={styles.contactItem}>
|
||||||
|
{icons[profile.network] && <Icon path={icons[profile.network]} />}
|
||||||
|
<Link src={profile.url} style={{ color: "#4B5563", textDecoration: "none" }}>
|
||||||
|
{profile.url.replace(/^https?:\/\//, "").replace(/\/$/, "")}
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
{data.summary && resumeConfig.sections.summary?.enabled !== false && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
{resumeConfig.sections.summary?.title || "Summary"}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.summary}>{data.summary.content}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Columns */}
|
||||||
|
<View style={styles.columns}>
|
||||||
|
<View style={[styles.column, { marginLeft: 0, marginRight: 8 }]}>
|
||||||
|
{renderColumn(layout.leftColumn)}
|
||||||
|
</View>
|
||||||
|
<View style={[styles.column, { marginLeft: 8, marginRight: 0 }]}>
|
||||||
|
{renderColumn(layout.rightColumn)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user