Load home portfolio views from API; improve resume PDF layout
Stop hardcoding deleted audience views and re-seeding them on boot. Tighten resume print CSS with professional fonts and page-break rules for a cleaner 1–2 page Letter PDF.
This commit is contained in:
@@ -1,6 +1,12 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { api, type Project, type SiteSettings, type Category } from "../lib/api";
|
import {
|
||||||
|
api,
|
||||||
|
type Project,
|
||||||
|
type SiteSettings,
|
||||||
|
type Category,
|
||||||
|
type PortfolioView,
|
||||||
|
} from "../lib/api";
|
||||||
import { ProjectCard } from "../components/ProjectCard";
|
import { ProjectCard } from "../components/ProjectCard";
|
||||||
import { usePageTitle } from "../lib/pageTitle";
|
import { usePageTitle } from "../lib/pageTitle";
|
||||||
import "./HomePage.scss";
|
import "./HomePage.scss";
|
||||||
@@ -10,6 +16,7 @@ export function HomePage() {
|
|||||||
const [featured, setFeatured] = useState<Project[]>([]);
|
const [featured, setFeatured] = useState<Project[]>([]);
|
||||||
const [recent, setRecent] = useState<Project[]>([]);
|
const [recent, setRecent] = useState<Project[]>([]);
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [views, setViews] = useState<PortfolioView[]>([]);
|
||||||
|
|
||||||
usePageTitle(null); // site name only
|
usePageTitle(null); // site name only
|
||||||
|
|
||||||
@@ -19,11 +26,13 @@ export function HomePage() {
|
|||||||
api.featured().catch(() => [] as Project[]),
|
api.featured().catch(() => [] as Project[]),
|
||||||
api.projects({ sort: "date", perPage: 6 }),
|
api.projects({ sort: "date", perPage: 6 }),
|
||||||
api.categories(),
|
api.categories(),
|
||||||
]).then(([s, f, r, c]) => {
|
api.views().catch(() => [] as PortfolioView[]),
|
||||||
|
]).then(([s, f, r, c, v]) => {
|
||||||
setSite(s);
|
setSite(s);
|
||||||
setFeatured(f);
|
setFeatured(f);
|
||||||
setRecent(r.data);
|
setRecent(r.data);
|
||||||
setCategories(c);
|
setCategories(c);
|
||||||
|
setViews(v);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -68,12 +77,15 @@ export function HomePage() {
|
|||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
<Link to="/portfolio/game-dev" className="home-cats__chip home-cats__chip--view">
|
{views.map((v) => (
|
||||||
Game Dev view
|
<Link
|
||||||
</Link>
|
key={v.id}
|
||||||
<Link to="/portfolio/engineering" className="home-cats__chip home-cats__chip--view">
|
to={`/portfolio/${v.slug}`}
|
||||||
Engineering view
|
className="home-cats__chip home-cats__chip--view"
|
||||||
</Link>
|
>
|
||||||
|
{v.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -516,16 +516,17 @@ export function AdminDashboard() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section style={{ marginTop: "2.5rem" }}>
|
<section style={{ marginTop: "2.5rem" }}>
|
||||||
<h2 style={{ fontSize: "1rem", color: "var(--text-muted)" }}>Quick links for audiences</h2>
|
<h2 style={{ fontSize: "1rem", color: "var(--text-muted)" }}>Quick links</h2>
|
||||||
<ul style={{ color: "var(--text-dim)", lineHeight: 1.9 }}>
|
<ul style={{ color: "var(--text-dim)", lineHeight: 1.9 }}>
|
||||||
<li>
|
<li>
|
||||||
<code>/portfolio/game-dev</code> — animation first, then modeling
|
<code>/portfolio</code> — full portfolio
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<code>/portfolio/engineering</code> — CAD & product visualization first
|
<code>/portfolio/<category-slug></code> — category filter (example:{" "}
|
||||||
|
<code>/portfolio/photography</code>)
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<code>/portfolio/3d</code>, <code>/portfolio/cad</code>, <code>/portfolio/ai</code>
|
<code>/portfolio/<view-slug></code> — audience views from Admin → Portfolio Views
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<code>/resume</code> — shareable resume URL
|
<code>/resume</code> — shareable resume URL
|
||||||
|
|||||||
+45
-55
@@ -39,66 +39,56 @@ async function main() {
|
|||||||
};
|
};
|
||||||
// We keep canonical slugs; PortfolioView can use short names
|
// We keep canonical slugs; PortfolioView can use short names
|
||||||
|
|
||||||
console.log("Seeding default portfolio views…");
|
// Seed portfolio views only on a fresh DB. Do not re-create views after the admin deletes them.
|
||||||
const cats = await prisma.category.findMany();
|
const viewCount = await prisma.portfolioView.count();
|
||||||
const bySlug = Object.fromEntries(cats.map((c) => [c.slug, c]));
|
if (viewCount === 0) {
|
||||||
|
console.log("Seeding default portfolio views (empty table)…");
|
||||||
|
const cats = await prisma.category.findMany();
|
||||||
|
const bySlug = Object.fromEntries(cats.map((c) => [c.slug, c]));
|
||||||
|
|
||||||
async function upsertView(
|
async function createView(
|
||||||
name: string,
|
name: string,
|
||||||
viewSlug: string,
|
viewSlug: string,
|
||||||
description: string,
|
description: string,
|
||||||
orderedSlugs: string[]
|
orderedSlugs: string[]
|
||||||
) {
|
) {
|
||||||
const view = await prisma.portfolioView.upsert({
|
const view = await prisma.portfolioView.create({
|
||||||
where: { slug: viewSlug },
|
data: { name, slug: viewSlug, description, showOthers: true },
|
||||||
create: { name, slug: viewSlug, description, showOthers: true },
|
|
||||||
update: { name, description },
|
|
||||||
});
|
|
||||||
await prisma.portfolioViewCategory.deleteMany({ where: { viewId: view.id } });
|
|
||||||
for (let i = 0; i < orderedSlugs.length; i++) {
|
|
||||||
const cat = bySlug[orderedSlugs[i]];
|
|
||||||
if (!cat) continue;
|
|
||||||
await prisma.portfolioViewCategory.create({
|
|
||||||
data: { viewId: view.id, categoryId: cat.id, priority: i },
|
|
||||||
});
|
});
|
||||||
|
for (let i = 0; i < orderedSlugs.length; i++) {
|
||||||
|
const cat = bySlug[orderedSlugs[i]];
|
||||||
|
if (!cat) continue;
|
||||||
|
await prisma.portfolioViewCategory.create({
|
||||||
|
data: { viewId: view.id, categoryId: cat.id, priority: i },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await upsertView(
|
await createView(
|
||||||
"Game Development",
|
"3D",
|
||||||
"game-dev",
|
"3d",
|
||||||
"Work prioritized for game studios and interactive media.",
|
"All 3D work front and center.",
|
||||||
["3d-animation", "3d-modeling", "3d-sculpting", "ai-generated-content", "cad"]
|
["3d-modeling", "3d-animation", "3d-sculpting", "cad"]
|
||||||
);
|
);
|
||||||
await upsertView(
|
await createView("Animation", "animation", "Animation-focused portfolio.", [
|
||||||
"Engineering",
|
"3d-animation",
|
||||||
"engineering",
|
"video",
|
||||||
"Work prioritized for engineering and product teams.",
|
|
||||||
["cad", "3d-modeling", "illustration", "graphic-design"]
|
|
||||||
);
|
|
||||||
await upsertView(
|
|
||||||
"3D",
|
|
||||||
"3d",
|
|
||||||
"All 3D work front and center.",
|
|
||||||
["3d-modeling", "3d-animation", "3d-sculpting", "cad"]
|
|
||||||
);
|
|
||||||
await upsertView("Animation", "animation", "Animation-focused portfolio.", [
|
|
||||||
"3d-animation",
|
|
||||||
"video",
|
|
||||||
]);
|
|
||||||
await upsertView("CAD", "cad", "CAD and technical design.", ["cad", "3d-modeling"]);
|
|
||||||
await upsertView("AI", "ai", "AI-generated creative work.", [
|
|
||||||
"ai-generated-content",
|
|
||||||
"illustration",
|
|
||||||
"graphic-design",
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Shorter category filter aliases as views that just prioritize that category
|
|
||||||
for (const [canonical, short] of Object.entries(aliases)) {
|
|
||||||
if (short === "3d" || short === "animation" || short === "ai") continue;
|
|
||||||
await upsertView(bySlug[canonical]?.name ?? short, short, `Filter: ${short}`, [
|
|
||||||
canonical,
|
|
||||||
]);
|
]);
|
||||||
|
await createView("CAD", "cad", "CAD and technical design.", ["cad", "3d-modeling"]);
|
||||||
|
await createView("AI", "ai", "AI-generated creative work.", [
|
||||||
|
"ai-generated-content",
|
||||||
|
"illustration",
|
||||||
|
"graphic-design",
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const [canonical, short] of Object.entries(aliases)) {
|
||||||
|
if (short === "3d" || short === "animation" || short === "ai") continue;
|
||||||
|
await createView(bySlug[canonical]?.name ?? short, short, `Filter: ${short}`, [
|
||||||
|
canonical,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`Skipping portfolio view seed (${viewCount} existing views).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingResume = await prisma.resume.findFirst({ where: { isActive: true } });
|
const existingResume = await prisma.resume.findFirst({ where: { isActive: true } });
|
||||||
|
|||||||
+197
-58
@@ -10,16 +10,173 @@ function escapeHtml(s: string): string {
|
|||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compact, professional resume CSS for 1–2 printed pages. */
|
||||||
|
const RESUME_CSS = `
|
||||||
|
@page {
|
||||||
|
size: Letter;
|
||||||
|
margin: 0.55in 0.6in 0.55in 0.6in;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: #1a1a1a;
|
||||||
|
background: #fff;
|
||||||
|
font-family: "Liberation Serif", "Times New Roman", "Nimbus Roman", Times, serif;
|
||||||
|
font-size: 10.5pt;
|
||||||
|
line-height: 1.35;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
max-width: 7.5in;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
a { color: #1a1a1a; text-decoration: none; }
|
||||||
|
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
padding-bottom: 8pt;
|
||||||
|
border-bottom: 1.25pt solid #222;
|
||||||
|
break-after: avoid;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 2pt;
|
||||||
|
font-family: "Liberation Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 18pt;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
margin: 0 0 4pt;
|
||||||
|
font-family: "Liberation Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 10.5pt;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.contact {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Liberation Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
margin: 0 0 10pt;
|
||||||
|
font-size: 9.75pt;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: justify;
|
||||||
|
hyphens: auto;
|
||||||
|
break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec {
|
||||||
|
margin: 0 0 8pt;
|
||||||
|
break-inside: avoid-page;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 10pt 0 5pt;
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
font-family: "Liberation Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #111;
|
||||||
|
border-bottom: 0.75pt solid #888;
|
||||||
|
break-after: avoid;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
margin: 0 0 7pt;
|
||||||
|
break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
.item:last-child { margin-bottom: 0; }
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 1pt;
|
||||||
|
font-family: "Liberation Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 10.25pt;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
break-after: avoid;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
margin: 0 0 3pt;
|
||||||
|
font-family: "Liberation Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 8.75pt;
|
||||||
|
color: #555;
|
||||||
|
font-style: italic;
|
||||||
|
break-after: avoid;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
.item p {
|
||||||
|
margin: 0 0 2pt;
|
||||||
|
font-size: 9.5pt;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
margin: 2pt 0 0 14pt;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
margin: 0 0 1.5pt;
|
||||||
|
font-size: 9.5pt;
|
||||||
|
line-height: 1.32;
|
||||||
|
}
|
||||||
|
.skills-line {
|
||||||
|
margin: 0 0 3pt;
|
||||||
|
font-size: 9.5pt;
|
||||||
|
line-height: 1.35;
|
||||||
|
break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
.skills-line strong {
|
||||||
|
font-family: "Liberation Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 9.25pt;
|
||||||
|
}
|
||||||
|
.links-list {
|
||||||
|
margin: 2pt 0 0 14pt;
|
||||||
|
columns: 2;
|
||||||
|
column-gap: 18pt;
|
||||||
|
}
|
||||||
|
.links-list li {
|
||||||
|
break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body { max-width: none; }
|
||||||
|
.item, .skills-line, header, h2, h3, .meta {
|
||||||
|
break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
h2, h3, .meta {
|
||||||
|
break-after: avoid;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
/* Prefer keeping a heading with at least part of the next block */
|
||||||
|
h2 {
|
||||||
|
orphans: 3;
|
||||||
|
widows: 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
function renderResumeHtml(resume: any): string {
|
function renderResumeHtml(resume: any): string {
|
||||||
if (resume.htmlContent) {
|
if (resume.htmlContent) {
|
||||||
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${escapeHtml(
|
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"/>
|
||||||
resume.fullName
|
<title>${escapeHtml(resume.fullName)} — Resume</title>
|
||||||
)} — Resume</title>
|
<style>${RESUME_CSS}</style>
|
||||||
<style>
|
</head><body>${resume.htmlContent}</body></html>`;
|
||||||
body{font-family:Georgia,serif;max-width:800px;margin:40px auto;padding:0 24px;color:#111;line-height:1.5}
|
|
||||||
@media print{body{margin:0}}
|
|
||||||
</style></head><body>${resume.htmlContent}</body></html>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sections = Array.isArray(resume.sections) ? resume.sections : [];
|
const sections = Array.isArray(resume.sections) ? resume.sections : [];
|
||||||
@@ -28,12 +185,12 @@ function renderResumeHtml(resume: any): string {
|
|||||||
sectionsHtml += `<section class="sec"><h2>${escapeHtml(sec.title || sec.type || "")}</h2>`;
|
sectionsHtml += `<section class="sec"><h2>${escapeHtml(sec.title || sec.type || "")}</h2>`;
|
||||||
if (sec.type === "skills" && Array.isArray(sec.items)) {
|
if (sec.type === "skills" && Array.isArray(sec.items)) {
|
||||||
for (const g of sec.items) {
|
for (const g of sec.items) {
|
||||||
sectionsHtml += `<p><strong>${escapeHtml(g.group || "")}</strong> — ${(g.skills || [])
|
sectionsHtml += `<p class="skills-line"><strong>${escapeHtml(
|
||||||
.map((s: string) => escapeHtml(s))
|
g.group || ""
|
||||||
.join(", ")}</p>`;
|
)}:</strong> ${(g.skills || []).map((s: string) => escapeHtml(s)).join(", ")}</p>`;
|
||||||
}
|
}
|
||||||
} else if (sec.type === "links" && Array.isArray(sec.items)) {
|
} else if (sec.type === "links" && Array.isArray(sec.items)) {
|
||||||
sectionsHtml += `<ul>${sec.items
|
sectionsHtml += `<ul class="links-list">${sec.items
|
||||||
.map(
|
.map(
|
||||||
(i: { label: string; url: string }) =>
|
(i: { label: string; url: string }) =>
|
||||||
`<li><a href="${escapeHtml(i.url)}">${escapeHtml(i.label)}</a></li>`
|
`<li><a href="${escapeHtml(i.url)}">${escapeHtml(i.label)}</a></li>`
|
||||||
@@ -41,18 +198,23 @@ function renderResumeHtml(resume: any): string {
|
|||||||
.join("")}</ul>`;
|
.join("")}</ul>`;
|
||||||
} else if (Array.isArray(sec.items)) {
|
} else if (Array.isArray(sec.items)) {
|
||||||
for (const item of sec.items) {
|
for (const item of sec.items) {
|
||||||
|
const dateRange = [item.startDate, item.endDate].filter(Boolean).join(" – ");
|
||||||
|
const metaParts = [item.location, dateRange].filter(Boolean);
|
||||||
sectionsHtml += `<div class="item">
|
sectionsHtml += `<div class="item">
|
||||||
<h3>${escapeHtml(item.title || "")}${
|
<h3>${escapeHtml(item.title || "")}${
|
||||||
item.organization ? ` · ${escapeHtml(item.organization)}` : ""
|
item.organization ? ` · ${escapeHtml(item.organization)}` : ""
|
||||||
}</h3>
|
}</h3>
|
||||||
<div class="meta">${[item.location, [item.startDate, item.endDate].filter(Boolean).join(" – ")]
|
${
|
||||||
.filter(Boolean)
|
metaParts.length
|
||||||
.map(escapeHtml)
|
? `<div class="meta">${metaParts.map(escapeHtml).join(" · ")}</div>`
|
||||||
.join(" · ")}</div>
|
: ""
|
||||||
|
}
|
||||||
${item.description ? `<p>${escapeHtml(item.description)}</p>` : ""}
|
${item.description ? `<p>${escapeHtml(item.description)}</p>` : ""}
|
||||||
${
|
${
|
||||||
item.highlights
|
item.highlights?.length
|
||||||
? `<ul>${item.highlights.map((h: string) => `<li>${escapeHtml(h)}</li>`).join("")}</ul>`
|
? `<ul>${item.highlights
|
||||||
|
.map((h: string) => `<li>${escapeHtml(h)}</li>`)
|
||||||
|
.join("")}</ul>`
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -61,43 +223,23 @@ function renderResumeHtml(resume: any): string {
|
|||||||
sectionsHtml += `</section>`;
|
sectionsHtml += `</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contact = [resume.email, resume.phone, resume.location, resume.website]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(escapeHtml)
|
||||||
|
.join(" · ");
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<title>${escapeHtml(resume.fullName)} — Resume</title>
|
<title>${escapeHtml(resume.fullName)} — Resume</title>
|
||||||
<style>
|
<style>${RESUME_CSS}</style>
|
||||||
*{box-sizing:border-box}
|
|
||||||
body{font-family:"Segoe UI",system-ui,sans-serif;max-width:820px;margin:0 auto;padding:48px 32px;color:#0f0f0f;line-height:1.55;background:#fff}
|
|
||||||
h1{font-size:2rem;margin:0 0 4px;letter-spacing:-0.02em}
|
|
||||||
.title{color:#444;font-size:1.1rem;margin-bottom:8px}
|
|
||||||
.contact{color:#555;font-size:0.9rem;margin-bottom:24px}
|
|
||||||
.summary{font-size:1rem;margin-bottom:28px;border-left:3px solid #111;padding-left:16px}
|
|
||||||
h2{font-size:0.85rem;text-transform:uppercase;letter-spacing:0.12em;border-bottom:1px solid #ddd;padding-bottom:6px;margin:28px 0 14px;color:#222}
|
|
||||||
h3{font-size:1.05rem;margin:0 0 4px}
|
|
||||||
.meta{color:#666;font-size:0.85rem;margin-bottom:8px}
|
|
||||||
.item{margin-bottom:18px}
|
|
||||||
ul{margin:6px 0 0 18px;padding:0}
|
|
||||||
li{margin-bottom:4px}
|
|
||||||
a{color:#111}
|
|
||||||
@media print{body{padding:24px}}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>${escapeHtml(resume.fullName)}</h1>
|
<h1>${escapeHtml(resume.fullName)}</h1>
|
||||||
${resume.title ? `<div class="title">${escapeHtml(resume.title)}</div>` : ""}
|
${resume.title ? `<div class="title">${escapeHtml(resume.title)}</div>` : ""}
|
||||||
<div class="contact">
|
${contact ? `<div class="contact">${contact}</div>` : ""}
|
||||||
${[
|
|
||||||
resume.email,
|
|
||||||
resume.phone,
|
|
||||||
resume.location,
|
|
||||||
resume.website,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.map(escapeHtml)
|
|
||||||
.join(" · ")}
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
${resume.summary ? `<p class="summary">${escapeHtml(resume.summary)}</p>` : ""}
|
${resume.summary ? `<p class="summary">${escapeHtml(resume.summary)}</p>` : ""}
|
||||||
${sectionsHtml}
|
${sectionsHtml}
|
||||||
@@ -118,7 +260,6 @@ export async function resumePdfRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const html = renderResumeHtml(resume);
|
const html = renderResumeHtml(resume);
|
||||||
try {
|
try {
|
||||||
// Dynamic import so local dev without chromium still works for HTML
|
|
||||||
const puppeteer = await import("puppeteer");
|
const puppeteer = await import("puppeteer");
|
||||||
const browser = await puppeteer.default.launch({
|
const browser = await puppeteer.default.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
@@ -129,9 +270,11 @@ export async function resumePdfRoutes(app: FastifyInstance) {
|
|||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await page.setContent(html, { waitUntil: "load" });
|
await page.setContent(html, { waitUntil: "load" });
|
||||||
const pdf = await page.pdf({
|
const pdf = await page.pdf({
|
||||||
format: "A4",
|
format: "Letter",
|
||||||
printBackground: true,
|
printBackground: true,
|
||||||
margin: { top: "16mm", bottom: "16mm", left: "14mm", right: "14mm" },
|
preferCSSPageSize: true,
|
||||||
|
displayHeaderFooter: false,
|
||||||
|
margin: { top: "0.55in", bottom: "0.55in", left: "0.6in", right: "0.6in" },
|
||||||
});
|
});
|
||||||
const filename = `${(resume.fullName || "resume").replace(/\s+/g, "_")}_Resume.pdf`;
|
const filename = `${(resume.fullName || "resume").replace(/\s+/g, "_")}_Resume.pdf`;
|
||||||
return reply
|
return reply
|
||||||
@@ -143,15 +286,11 @@ export async function resumePdfRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("PDF generation failed:", err);
|
console.error("PDF generation failed:", err);
|
||||||
// Fallback: return HTML with print hint
|
return reply.code(503).type("application/json").send({
|
||||||
return reply
|
error: "PDF generation unavailable",
|
||||||
.code(503)
|
htmlUrl: `${config.publicUrl}/api/v1/resume/html`,
|
||||||
.type("application/json")
|
message: "Open the HTML version and use Print → Save as PDF",
|
||||||
.send({
|
});
|
||||||
error: "PDF generation unavailable",
|
|
||||||
htmlUrl: `${config.publicUrl}/api/v1/resume/html`,
|
|
||||||
message: "Open the HTML version and use Print → Save as PDF",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user