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:
2026-08-01 11:28:51 -04:00
parent c4ecc934e9
commit af4e658e72
4 changed files with 267 additions and 125 deletions
+19 -7
View File
@@ -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"
>
{v.name}
</Link> </Link>
))}
</div> </div>
</section> </section>
+5 -4
View File
@@ -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/&lt;category-slug&gt;</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/&lt;view-slug&gt;</code> audience views from Admin Portfolio Views
</li> </li>
<li> <li>
<code>/resume</code> shareable resume URL <code>/resume</code> shareable resume URL
+15 -25
View File
@@ -39,22 +39,22 @@ 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 viewCount = await prisma.portfolioView.count();
if (viewCount === 0) {
console.log("Seeding default portfolio views (empty table)…");
const cats = await prisma.category.findMany(); const cats = await prisma.category.findMany();
const bySlug = Object.fromEntries(cats.map((c) => [c.slug, c])); 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++) { for (let i = 0; i < orderedSlugs.length; i++) {
const cat = bySlug[orderedSlugs[i]]; const cat = bySlug[orderedSlugs[i]];
if (!cat) continue; if (!cat) continue;
@@ -64,42 +64,32 @@ async function main() {
} }
} }
await upsertView( await createView(
"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",
"3d", "3d",
"All 3D work front and center.", "All 3D work front and center.",
["3d-modeling", "3d-animation", "3d-sculpting", "cad"] ["3d-modeling", "3d-animation", "3d-sculpting", "cad"]
); );
await upsertView("Animation", "animation", "Animation-focused portfolio.", [ await createView("Animation", "animation", "Animation-focused portfolio.", [
"3d-animation", "3d-animation",
"video", "video",
]); ]);
await upsertView("CAD", "cad", "CAD and technical design.", ["cad", "3d-modeling"]); await createView("CAD", "cad", "CAD and technical design.", ["cad", "3d-modeling"]);
await upsertView("AI", "ai", "AI-generated creative work.", [ await createView("AI", "ai", "AI-generated creative work.", [
"ai-generated-content", "ai-generated-content",
"illustration", "illustration",
"graphic-design", "graphic-design",
]); ]);
// Shorter category filter aliases as views that just prioritize that category
for (const [canonical, short] of Object.entries(aliases)) { for (const [canonical, short] of Object.entries(aliases)) {
if (short === "3d" || short === "animation" || short === "ai") continue; if (short === "3d" || short === "animation" || short === "ai") continue;
await upsertView(bySlug[canonical]?.name ?? short, short, `Filter: ${short}`, [ await createView(bySlug[canonical]?.name ?? short, short, `Filter: ${short}`, [
canonical, 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 } });
if (!existingResume) { if (!existingResume) {
+191 -52
View File
@@ -10,16 +10,173 @@ function escapeHtml(s: string): string {
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
/** Compact, professional resume CSS for 12 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,11 +286,7 @@ 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
.code(503)
.type("application/json")
.send({
error: "PDF generation unavailable", error: "PDF generation unavailable",
htmlUrl: `${config.publicUrl}/api/v1/resume/html`, htmlUrl: `${config.publicUrl}/api/v1/resume/html`,
message: "Open the HTML version and use Print → Save as PDF", message: "Open the HTML version and use Print → Save as PDF",