From f7355b53564aa388a675798e9952e62c7a7c1200 Mon Sep 17 00:00:00 2001 From: MohFarawati Date: Tue, 14 Jul 2026 22:13:26 +0200 Subject: [PATCH] feat(portfolio): image-led project gallery with branded placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the portfolio list/category grid into a premium, image-first gallery (Phase 3), applying the warm-premium tokens. - Add components/site/portfolio-cover.tsx: renders the stored cover image (next/image, object-cover, hover zoom) or a restrained branded placeholder (monogram + category) when none is set. Placeholders are temporary — the owner will swap in real artwork. - Rewrite portfolio-project-grid: responsive 1/2/3-col gallery, featured projects span two columns with a larger cover, cards use the type scale (text-h2/h3/caption), eyebrow category labels, line-clamped summaries, soft elevation and hover lift. Directly fixes the "portfolio shows no images" gap on the list and category pages. Verified: eslint clean, no new type errors, tailwind generates the utilities. --- components/site/portfolio-cover.tsx | 67 +++++++++++++ components/site/portfolio-project-grid.tsx | 105 ++++++++++++++------- 2 files changed, 138 insertions(+), 34 deletions(-) create mode 100644 components/site/portfolio-cover.tsx diff --git a/components/site/portfolio-cover.tsx b/components/site/portfolio-cover.tsx new file mode 100644 index 0000000..65cf00a --- /dev/null +++ b/components/site/portfolio-cover.tsx @@ -0,0 +1,67 @@ +import Image from "next/image"; +import { ImageIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +type PortfolioCoverProps = { + src: string | null; + title: string; + category?: string; + sizes?: string; + priority?: boolean; + className?: string; +}; + +const DEFAULT_SIZES = + "(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"; + +/** + * Project cover. Renders the stored image when present, otherwise a restrained + * branded placeholder (owner will replace placeholders with real artwork). + * Fills a positioned, aspect-sized parent (`relative` + `aspect-*`). + */ +export function PortfolioCover({ + src, + title, + category, + sizes = DEFAULT_SIZES, + priority = false, + className, +}: PortfolioCoverProps) { + if (src) { + return ( + {title} + ); + } + + const monogram = title.trim().charAt(0).toUpperCase() || "•"; + + return ( +
+ + + {monogram} + + + + {category} + +
+ ); +} diff --git a/components/site/portfolio-project-grid.tsx b/components/site/portfolio-project-grid.tsx index 7e5a77e..627c43e 100644 --- a/components/site/portfolio-project-grid.tsx +++ b/components/site/portfolio-project-grid.tsx @@ -2,10 +2,10 @@ import { ArrowUpRight, CalendarDays } from "lucide-react"; import Link from "next/link"; import { MotionFade } from "@/components/motion-fade"; -import { AppCard } from "@/components/ui/app-card"; -import { CardContent } from "@/components/ui/card"; +import { PortfolioCover } from "@/components/site/portfolio-cover"; import { getLocalizedPath, type AppLocale } from "@/lib/locale"; import { getLocalizedValue, type PortfolioProjectView } from "@/lib/portfolio"; +import { cn } from "@/lib/utils"; type PortfolioProjectGridProps = { locale: AppLocale; @@ -22,48 +22,85 @@ export function PortfolioProjectGrid({ emptyLabel, openLabel, }: PortfolioProjectGridProps) { + if (projects.length === 0) { + return ( +
+
+ {emptyLabel} +
+
+ ); + } + return ( -
- {projects.map((item, index) => ( - - - - + {projects.map((item, index) => { + const featured = item.isFeatured; + const title = getLocalizedValue(item.title, locale); + const category = getLocalizedValue(item.category.name, locale); + + return ( + + +
+ +
+ +
-

- {getLocalizedValue(item.category.name, locale)} -

-

+ + {category} + + {item.projectYear} -

+
-

- {getLocalizedValue(item.title, locale)} + +

+ {title}

-

+ +

{getLocalizedValue(item.summary, locale)}

-

- {openLabel} - -

- - - - - ))} - {projects.length === 0 ? ( - - - {emptyLabel} - - - ) : null} + + {openLabel} + + +
+ +
+ ); + })}
); }