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:
+45
-55
@@ -39,66 +39,56 @@ async function main() {
|
||||
};
|
||||
// We keep canonical slugs; PortfolioView can use short names
|
||||
|
||||
console.log("Seeding default portfolio views…");
|
||||
const cats = await prisma.category.findMany();
|
||||
const bySlug = Object.fromEntries(cats.map((c) => [c.slug, c]));
|
||||
// Seed portfolio views only on a fresh DB. Do not re-create views after the admin deletes them.
|
||||
const viewCount = await prisma.portfolioView.count();
|
||||
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(
|
||||
name: string,
|
||||
viewSlug: string,
|
||||
description: string,
|
||||
orderedSlugs: string[]
|
||||
) {
|
||||
const view = await prisma.portfolioView.upsert({
|
||||
where: { slug: viewSlug },
|
||||
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 },
|
||||
async function createView(
|
||||
name: string,
|
||||
viewSlug: string,
|
||||
description: string,
|
||||
orderedSlugs: string[]
|
||||
) {
|
||||
const view = await prisma.portfolioView.create({
|
||||
data: { name, slug: viewSlug, description, showOthers: true },
|
||||
});
|
||||
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(
|
||||
"Game Development",
|
||||
"game-dev",
|
||||
"Work prioritized for game studios and interactive media.",
|
||||
["3d-animation", "3d-modeling", "3d-sculpting", "ai-generated-content", "cad"]
|
||||
);
|
||||
await upsertView(
|
||||
"Engineering",
|
||||
"engineering",
|
||||
"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(
|
||||
"3D",
|
||||
"3d",
|
||||
"All 3D work front and center.",
|
||||
["3d-modeling", "3d-animation", "3d-sculpting", "cad"]
|
||||
);
|
||||
await createView("Animation", "animation", "Animation-focused portfolio.", [
|
||||
"3d-animation",
|
||||
"video",
|
||||
]);
|
||||
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 } });
|
||||
|
||||
+197
-58
@@ -10,16 +10,173 @@ function escapeHtml(s: string): string {
|
||||
.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
|
||||
function renderResumeHtml(resume: any): string {
|
||||
if (resume.htmlContent) {
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${escapeHtml(
|
||||
resume.fullName
|
||||
)} — Resume</title>
|
||||
<style>
|
||||
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>`;
|
||||
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"/>
|
||||
<title>${escapeHtml(resume.fullName)} — Resume</title>
|
||||
<style>${RESUME_CSS}</style>
|
||||
</head><body>${resume.htmlContent}</body></html>`;
|
||||
}
|
||||
|
||||
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>`;
|
||||
if (sec.type === "skills" && Array.isArray(sec.items)) {
|
||||
for (const g of sec.items) {
|
||||
sectionsHtml += `<p><strong>${escapeHtml(g.group || "")}</strong> — ${(g.skills || [])
|
||||
.map((s: string) => escapeHtml(s))
|
||||
.join(", ")}</p>`;
|
||||
sectionsHtml += `<p class="skills-line"><strong>${escapeHtml(
|
||||
g.group || ""
|
||||
)}:</strong> ${(g.skills || []).map((s: string) => escapeHtml(s)).join(", ")}</p>`;
|
||||
}
|
||||
} else if (sec.type === "links" && Array.isArray(sec.items)) {
|
||||
sectionsHtml += `<ul>${sec.items
|
||||
sectionsHtml += `<ul class="links-list">${sec.items
|
||||
.map(
|
||||
(i: { label: string; url: string }) =>
|
||||
`<li><a href="${escapeHtml(i.url)}">${escapeHtml(i.label)}</a></li>`
|
||||
@@ -41,18 +198,23 @@ function renderResumeHtml(resume: any): string {
|
||||
.join("")}</ul>`;
|
||||
} else if (Array.isArray(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">
|
||||
<h3>${escapeHtml(item.title || "")}${
|
||||
item.organization ? ` · ${escapeHtml(item.organization)}` : ""
|
||||
}</h3>
|
||||
<div class="meta">${[item.location, [item.startDate, item.endDate].filter(Boolean).join(" – ")]
|
||||
.filter(Boolean)
|
||||
.map(escapeHtml)
|
||||
.join(" · ")}</div>
|
||||
item.organization ? ` · ${escapeHtml(item.organization)}` : ""
|
||||
}</h3>
|
||||
${
|
||||
metaParts.length
|
||||
? `<div class="meta">${metaParts.map(escapeHtml).join(" · ")}</div>`
|
||||
: ""
|
||||
}
|
||||
${item.description ? `<p>${escapeHtml(item.description)}</p>` : ""}
|
||||
${
|
||||
item.highlights
|
||||
? `<ul>${item.highlights.map((h: string) => `<li>${escapeHtml(h)}</li>`).join("")}</ul>`
|
||||
item.highlights?.length
|
||||
? `<ul>${item.highlights
|
||||
.map((h: string) => `<li>${escapeHtml(h)}</li>`)
|
||||
.join("")}</ul>`
|
||||
: ""
|
||||
}
|
||||
</div>`;
|
||||
@@ -61,43 +223,23 @@ function renderResumeHtml(resume: any): string {
|
||||
sectionsHtml += `</section>`;
|
||||
}
|
||||
|
||||
const contact = [resume.email, resume.phone, resume.location, resume.website]
|
||||
.filter(Boolean)
|
||||
.map(escapeHtml)
|
||||
.join(" · ");
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>${escapeHtml(resume.fullName)} — Resume</title>
|
||||
<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>
|
||||
<style>${RESUME_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>${escapeHtml(resume.fullName)}</h1>
|
||||
${resume.title ? `<div class="title">${escapeHtml(resume.title)}</div>` : ""}
|
||||
<div class="contact">
|
||||
${[
|
||||
resume.email,
|
||||
resume.phone,
|
||||
resume.location,
|
||||
resume.website,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map(escapeHtml)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
${contact ? `<div class="contact">${contact}</div>` : ""}
|
||||
</header>
|
||||
${resume.summary ? `<p class="summary">${escapeHtml(resume.summary)}</p>` : ""}
|
||||
${sectionsHtml}
|
||||
@@ -118,7 +260,6 @@ export async function resumePdfRoutes(app: FastifyInstance) {
|
||||
|
||||
const html = renderResumeHtml(resume);
|
||||
try {
|
||||
// Dynamic import so local dev without chromium still works for HTML
|
||||
const puppeteer = await import("puppeteer");
|
||||
const browser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
@@ -129,9 +270,11 @@ export async function resumePdfRoutes(app: FastifyInstance) {
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: "load" });
|
||||
const pdf = await page.pdf({
|
||||
format: "A4",
|
||||
format: "Letter",
|
||||
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`;
|
||||
return reply
|
||||
@@ -143,15 +286,11 @@ export async function resumePdfRoutes(app: FastifyInstance) {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("PDF generation failed:", err);
|
||||
// Fallback: return HTML with print hint
|
||||
return reply
|
||||
.code(503)
|
||||
.type("application/json")
|
||||
.send({
|
||||
error: "PDF generation unavailable",
|
||||
htmlUrl: `${config.publicUrl}/api/v1/resume/html`,
|
||||
message: "Open the HTML version and use Print → Save as PDF",
|
||||
});
|
||||
return reply.code(503).type("application/json").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