diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index ca58ba1..7178386 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -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([]); const [recent, setRecent] = useState([]); const [categories, setCategories] = useState([]); + const [views, setViews] = useState([]); 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() { )} ))} - - Game Dev view - - - Engineering view - + {views.map((v) => ( + + {v.name} + + ))} diff --git a/client/src/pages/admin/AdminDashboard.tsx b/client/src/pages/admin/AdminDashboard.tsx index db0d007..3a9987b 100644 --- a/client/src/pages/admin/AdminDashboard.tsx +++ b/client/src/pages/admin/AdminDashboard.tsx @@ -516,16 +516,17 @@ export function AdminDashboard() {
-

Quick links for audiences

+

Quick links

  • - /portfolio/game-dev — animation first, then modeling + /portfolio — full portfolio
  • - /portfolio/engineering — CAD & product visualization first + /portfolio/<category-slug> — category filter (example:{" "} + /portfolio/photography)
  • - /portfolio/3d, /portfolio/cad, /portfolio/ai + /portfolio/<view-slug> — audience views from Admin → Portfolio Views
  • /resume — shareable resume URL diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts index ad80d1c..376f984 100644 --- a/server/prisma/seed.ts +++ b/server/prisma/seed.ts @@ -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 } }); diff --git a/server/src/routes/resume-pdf.ts b/server/src/routes/resume-pdf.ts index fa37c0e..a85a794 100644 --- a/server/src/routes/resume-pdf.ts +++ b/server/src/routes/resume-pdf.ts @@ -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 `${escapeHtml( - resume.fullName - )} — Resume -${resume.htmlContent}`; + return ` +${escapeHtml(resume.fullName)} — Resume + +${resume.htmlContent}`; } const sections = Array.isArray(resume.sections) ? resume.sections : []; @@ -28,12 +185,12 @@ function renderResumeHtml(resume: any): string { sectionsHtml += `

    ${escapeHtml(sec.title || sec.type || "")}

    `; if (sec.type === "skills" && Array.isArray(sec.items)) { for (const g of sec.items) { - sectionsHtml += `

    ${escapeHtml(g.group || "")} — ${(g.skills || []) - .map((s: string) => escapeHtml(s)) - .join(", ")}

    `; + sectionsHtml += `

    ${escapeHtml( + g.group || "" + )}: ${(g.skills || []).map((s: string) => escapeHtml(s)).join(", ")}

    `; } } else if (sec.type === "links" && Array.isArray(sec.items)) { - sectionsHtml += `
      ${sec.items + sectionsHtml += ``; } 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 += `

      ${escapeHtml(item.title || "")}${ - item.organization ? ` · ${escapeHtml(item.organization)}` : "" - }

      -
      ${[item.location, [item.startDate, item.endDate].filter(Boolean).join(" – ")] - .filter(Boolean) - .map(escapeHtml) - .join(" · ")}
      + item.organization ? ` · ${escapeHtml(item.organization)}` : "" + } + ${ + metaParts.length + ? `
      ${metaParts.map(escapeHtml).join(" · ")}
      ` + : "" + } ${item.description ? `

      ${escapeHtml(item.description)}

      ` : ""} ${ - item.highlights - ? `
        ${item.highlights.map((h: string) => `
      • ${escapeHtml(h)}
      • `).join("")}
      ` + item.highlights?.length + ? `
        ${item.highlights + .map((h: string) => `
      • ${escapeHtml(h)}
      • `) + .join("")}
      ` : "" }
      `; @@ -61,43 +223,23 @@ function renderResumeHtml(resume: any): string { sectionsHtml += `
    `; } + const contact = [resume.email, resume.phone, resume.location, resume.website] + .filter(Boolean) + .map(escapeHtml) + .join(" · "); + return ` ${escapeHtml(resume.fullName)} — Resume - +

    ${escapeHtml(resume.fullName)}

    ${resume.title ? `
    ${escapeHtml(resume.title)}
    ` : ""} -
    - ${[ - resume.email, - resume.phone, - resume.location, - resume.website, - ] - .filter(Boolean) - .map(escapeHtml) - .join(" · ")} -
    + ${contact ? `
    ${contact}
    ` : ""}
    ${resume.summary ? `

    ${escapeHtml(resume.summary)}

    ` : ""} ${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", + }); } }); }