diff --git a/client/src/components/ProjectCard.scss b/client/src/components/ProjectCard.scss index 3e36e65..4cd6299 100644 --- a/client/src/components/ProjectCard.scss +++ b/client/src/components/ProjectCard.scss @@ -49,7 +49,8 @@ width: 100%; height: 100%; object-fit: cover; - opacity: 0; + // Stay slightly visible under shimmer so cards never look "broken" + opacity: 0.12; transition: transform 0.55s ease, opacity 0.35s ease; &.is-loaded { diff --git a/client/src/components/ProjectCard.tsx b/client/src/components/ProjectCard.tsx index 91e5ded..519f02e 100644 --- a/client/src/components/ProjectCard.tsx +++ b/client/src/components/ProjectCard.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useState } from "react"; import { Link } from "react-router-dom"; import type { Project } from "../lib/api"; import { thumbOf, thumbAltOf } from "../lib/api"; @@ -10,8 +10,17 @@ export function ProjectCard({ project, index = 0 }: { project: Project; index?: const alt = thumbAltOf(project); const year = formatProjectYear(project.date); const [loaded, setLoaded] = useState(false); - // First row-ish: eager load so portfolio doesn't look empty pre-scroll - const eager = index < 8; + const [failed, setFailed] = useState(false); + // Load full first page eagerly so mid-grid never stays black on first paint + const eager = index < 24; + + // Handle cache hits (onLoad may not fire if already complete when attached) + const imgRef = useCallback((node: HTMLImageElement | null) => { + if (!node) return; + if (node.complete && node.naturalWidth > 0) { + setLoaded(true); + } + }, []); return (
-
- {thumb ? ( +
+ {thumb && !failed ? ( {alt} setLoaded(true)} + onError={() => setFailed(true)} className={loaded ? "is-loaded" : undefined} /> ) : ( diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index d4471e1..5ad7809 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -24,13 +24,15 @@ export function HomePage() { Promise.all([ api.site(), api.featured().catch(() => [] as Project[]), - api.projects({ sort: "date", perPage: 6 }), + // Fetch extra so we can exclude featured IDs from Recent + api.projects({ sort: "date", perPage: 18 }), api.categories(), api.views().catch(() => [] as PortfolioView[]), ]).then(([s, f, r, c, v]) => { setSite(s); setFeatured(f); - setRecent(r.data); + const featuredIds = new Set(f.map((p) => p.id)); + setRecent(r.data.filter((p) => !featuredIds.has(p.id)).slice(0, 6)); setCategories(c); setViews(v); }); @@ -41,9 +43,25 @@ export function HomePage() { const liveCategories = categories.filter( (c) => typeof c.projectCount !== "number" || c.projectCount > 0 ); - // Avoid advertising a view that collides with a category slug (e.g. cad) - const categorySlugs = new Set(categories.map((c) => c.slug)); - const liveViews = views.filter((v) => !categorySlugs.has(v.slug)); + const categoryById = new Map(categories.map((c) => [c.id, c])); + const categoryBySlug = new Map(categories.map((c) => [c.slug, c])); + // Avoid advertising a view that collides with a category slug, or whose + // prioritized categories all have zero published projects (e.g. AI with empty cats) + const liveViews = views.filter((v) => { + if (categoryBySlug.has(v.slug)) return false; + const prios = [...(v.categoryPriorities || [])].sort( + (a, b) => a.priority - b.priority + ); + if (prios.length === 0) return true; + // Only promote a view when its *primary* category has published work + // (avoids AI view dumping industrial projects via showOthers) + const primary = prios[0]; + const cat = + (primary.category?.id && categoryById.get(primary.category.id)) || + (primary.category?.slug && categoryBySlug.get(primary.category.slug)) || + (primary.categoryId ? categoryById.get(primary.categoryId) : undefined); + return (cat?.projectCount ?? 0) > 0; + }); const contactEmail = site?.social?.email || "jmartin@jmartgraphix.com"; return ( diff --git a/client/src/styles/global.scss b/client/src/styles/global.scss index ec43750..41b862c 100644 --- a/client/src/styles/global.scss +++ b/client/src/styles/global.scss @@ -32,7 +32,7 @@ html { scroll-behavior: smooth; /* Keep anchored content clear of sticky site header */ - scroll-padding-top: calc(var(--header-h) + 0.75rem); + scroll-padding-top: calc(var(--header-h) + 1.25rem); } body { diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts index 39bed2f..d916727 100644 --- a/server/prisma/seed.ts +++ b/server/prisma/seed.ts @@ -79,11 +79,23 @@ async function main() { "cad", "3d-modeling", ]); - await createView("AI", "ai", "AI-generated creative work.", [ - "ai-generated-content", - "illustration", - "graphic-design", - ]); + { + const view = await prisma.portfolioView.create({ + data: { + name: "AI", + slug: "ai", + description: "AI-generated creative work.", + showOthers: false, + }, + }); + for (const [i, s] of ["ai-generated-content", "illustration", "graphic-design"].entries()) { + const cat = bySlug[s]; + if (!cat) continue; + await prisma.portfolioViewCategory.create({ + data: { viewId: view.id, categoryId: cat.id, priority: i }, + }); + } + } for (const [canonical, short] of Object.entries(aliases)) { if (short === "3d" || short === "animation" || short === "ai") continue;