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
+20 -8
View File
@@ -1,6 +1,12 @@
import { useEffect, useState } from "react";
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 { usePageTitle } from "../lib/pageTitle";
import "./HomePage.scss";
@@ -10,6 +16,7 @@ export function HomePage() {
const [featured, setFeatured] = useState<Project[]>([]);
const [recent, setRecent] = useState<Project[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [views, setViews] = useState<PortfolioView[]>([]);
usePageTitle(null); // site name only
@@ -19,11 +26,13 @@ export function HomePage() {
api.featured().catch(() => [] as Project[]),
api.projects({ sort: "date", perPage: 6 }),
api.categories(),
]).then(([s, f, r, c]) => {
api.views().catch(() => [] as PortfolioView[]),
]).then(([s, f, r, c, v]) => {
setSite(s);
setFeatured(f);
setRecent(r.data);
setCategories(c);
setViews(v);
});
}, []);
@@ -68,12 +77,15 @@ export function HomePage() {
)}
</Link>
))}
<Link to="/portfolio/game-dev" className="home-cats__chip home-cats__chip--view">
Game Dev view
</Link>
<Link to="/portfolio/engineering" className="home-cats__chip home-cats__chip--view">
Engineering view
</Link>
{views.map((v) => (
<Link
key={v.id}
to={`/portfolio/${v.slug}`}
className="home-cats__chip home-cats__chip--view"
>
{v.name}
</Link>
))}
</div>
</section>
+5 -4
View File
@@ -516,16 +516,17 @@ export function AdminDashboard() {
</section>
<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 }}>
<li>
<code>/portfolio/game-dev</code> animation first, then modeling
<code>/portfolio</code> full portfolio
</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>
<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>
<code>/resume</code> shareable resume URL
+45 -55
View File
@@ -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
View File
@@ -10,16 +10,173 @@ function escapeHtml(s: string): string {
.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
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",
});
}
});
}