diff --git a/components/site/portfolio-project-detail.tsx b/components/site/portfolio-project-detail.tsx index e829dd2..8260d3f 100644 --- a/components/site/portfolio-project-detail.tsx +++ b/components/site/portfolio-project-detail.tsx @@ -5,10 +5,10 @@ import { Tag, UserRound, } from "lucide-react"; -import Image from "next/image"; import Link from "next/link"; import { MotionFade } from "@/components/motion-fade"; +import { PortfolioCover } from "@/components/site/portfolio-cover"; import { AppCard } from "@/components/ui/app-card"; import { Button } from "@/components/ui/button"; import { CardContent } from "@/components/ui/card"; @@ -16,7 +16,6 @@ import { getLocalizedPath, type AppLocale } from "@/lib/locale"; import { getLocalizedValue, resolvePortfolioProjectViewMode, - type PortfolioAssetView, type PortfolioProjectView, type PortfolioSectionView, } from "@/lib/portfolio"; @@ -28,27 +27,76 @@ type PortfolioProjectDetailProps = { t: (key: "back" | "preview" | "openLink" | "gallery" | "download") => string; }; -function PortfolioImage({ - src, - alt, - className, - width, - height, +type ProjectImage = { + key: string; + src: string | null; + label: string; +}; + +/** + * All image sources for a project, in reading order: cover, gallery-section + * images, then gallery assets. `src` may be null — PortfolioCover then renders + * the branded placeholder, so layouts look intentional even without artwork. + */ +function collectImages(item: PortfolioProjectView, locale: AppLocale): ProjectImage[] { + const images: ProjectImage[] = []; + const coverTitle = getLocalizedValue(item.title, locale); + + images.push({ key: "cover", src: item.coverImagePath ?? null, label: coverTitle }); + + for (const section of item.sections) { + if (section.type === "GALLERY") { + images.push({ + key: `section-${section.id}`, + src: section.imagePath ?? null, + label: getLocalizedValue(section.title, locale), + }); + } + } + + for (const asset of item.assets) { + if (asset.kind === "IMAGE") { + images.push({ + key: `asset-${asset.id}`, + src: asset.filePath, + label: getLocalizedValue(asset.alt, locale), + }); + } + } + + return images; +} + +/** Text-bearing sections (everything that is not a gallery image). */ +function textSections(item: PortfolioProjectView): PortfolioSectionView[] { + return item.sections.filter((section) => section.type !== "GALLERY"); +} + +function MediaFrame({ + image, + aspect = "aspect-[16/10]", + chrome = false, + priority = false, }: { - src: string; - alt: string; - className: string; - width: number; - height: number; + image: ProjectImage; + aspect?: string; + chrome?: boolean; + priority?: boolean; }) { return ( - {alt} +
+ {chrome ? ( +
+ + + + +
+ ) : null} +
+ +
+
); } @@ -60,22 +108,10 @@ function ProjectMeta({ locale: AppLocale; }) { const metadata = [ - { - icon: Tag, - label: getLocalizedValue(item.category.name, locale), - }, - { - icon: CalendarDays, - label: String(item.projectYear), - }, - { - icon: FolderKanban, - label: getLocalizedValue(item.serviceLabel, locale), - }, - { - icon: UserRound, - label: item.clientName, - }, + { icon: Tag, label: getLocalizedValue(item.category.name, locale) }, + { icon: CalendarDays, label: String(item.projectYear) }, + { icon: FolderKanban, label: getLocalizedValue(item.serviceLabel, locale) }, + { icon: UserRound, label: item.clientName }, ].filter((entry) => entry.label); return ( @@ -84,19 +120,111 @@ function ProjectMeta({ const Icon = meta.icon; return ( - - - - {meta.label} - - + + + {meta.label} + ); })} ); } -function SectionBlock({ +function BackLink({ + locale, + defaultLocale, + t, +}: { + locale: AppLocale; + defaultLocale: AppLocale; + t: PortfolioProjectDetailProps["t"]; +}) { + return ( + + ); +} + +function PreviewButton({ + item, + t, + full = false, +}: { + item: PortfolioProjectView; + t: PortfolioProjectDetailProps["t"]; + full?: boolean; +}) { + if (!item.previewUrl) { + return null; + } + + return ( + + ); +} + +function ProjectIntro({ item, locale }: { item: PortfolioProjectView; locale: AppLocale }) { + return ( +
+

+ {getLocalizedValue(item.category.name, locale)} · {item.projectYear} +

+

{getLocalizedValue(item.title, locale)}

+

+ {getLocalizedValue(item.summary, locale)} +

+
+ ); +} + +function InfoPanel({ + item, + locale, + t, +}: { + item: PortfolioProjectView; + locale: AppLocale; + t: PortfolioProjectDetailProps["t"]; +}) { + const rows = [ + { label: "Client", value: item.clientName }, + { label: "Year", value: String(item.projectYear) }, + { label: "Service", value: getLocalizedValue(item.serviceLabel, locale) }, + { label: "Category", value: getLocalizedValue(item.category.name, locale) }, + ].filter((row) => row.value); + + return ( + + +
+ {rows.map((row, index) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+ +
+
+ ); +} + +function TextSection({ section, locale, t, @@ -108,326 +236,204 @@ function SectionBlock({ const title = getLocalizedValue(section.title, locale); const body = getLocalizedValue(section.body, locale); - if (section.type === "GALLERY") { - return ( -
-

{title}

- {section.imagePath ? ( - - ) : null} -
- ); - } - - if (section.type === "LINK") { - return ( -
-

{title}

- {body ?

{body}

: null} - {section.linkUrl ? ( - - ) : null} -
- ); - } - return ( -
-

{title}

-

{body}

+
+

{title}

+ {body ? ( +

{body}

+ ) : null} + {section.type === "LINK" && section.linkUrl ? ( + + ) : null}
); } -function AssetGallery({ - assets, - locale, - t, -}: { - assets: PortfolioAssetView[]; - locale: AppLocale; - t: PortfolioProjectDetailProps["t"]; -}) { - if (assets.length === 0) { - return null; - } +/* ============================================================ + WEB (viewMode: CASE_STUDY) + Two columns: stacked full screenshots + sticky info panel. + ============================================================ */ +function WebTemplate({ item, locale, defaultLocale, t }: PortfolioProjectDetailProps) { + const images = collectImages(item, locale); + const texts = textSections(item); return ( - - - -
-
-

Assets

-

{t("gallery")}

-
- -
-
- {assets.map((asset) => ( -
- {asset.kind === "IMAGE" ? ( - - ) : ( -
-
-

- {getLocalizedValue(asset.alt, locale)} -

- -
-
- )} -
- ))} -
-
-
-
- ); -} +
+ +
+ + +
+
-function BadgeCount({ count }: { count: number }) { - return ( -
- {count} {count === 1 ? "file" : "files"} -
- ); -} +
+
+ {images.map((image, index) => ( + + + + ))} -function ProjectHeader({ - item, - locale, - defaultLocale, - t, -}: { - item: PortfolioProjectView; - locale: AppLocale; - defaultLocale: AppLocale; - t: PortfolioProjectDetailProps["t"]; -}) { - return ( - - - - - -
- -
- - {item.previewUrl ? ( -
- + {texts.length > 0 ? ( +
+ {texts.map((section) => ( + + + + + + ))}
) : null} - - - +
+ + +
+
); } -function GridTemplate({ - item, - locale, - defaultLocale, - t, -}: PortfolioProjectDetailProps) { +/* ============================================================ + PRINT (viewMode: GRID) + Gallery: mixed-size grid of images (logos, posters, ...). + ============================================================ */ +function PrintTemplate({ item, locale, defaultLocale, t }: PortfolioProjectDetailProps) { + const images = collectImages(item, locale); + const texts = textSections(item); + + return ( +
+ +
+ + +
+ + +
+
+
+ + +
+ {images.map((image, index) => { + // First image spans a large feature tile; the rest alternate size. + const span = + index === 0 + ? "col-span-2 row-span-2" + : index % 4 === 0 + ? "col-span-2" + : ""; + const aspect = index === 0 ? "aspect-square" : "aspect-[4/3]"; + + return ( +
+ +
+ ); + })} +
+
+ + {texts.length > 0 ? ( +
+ {texts.map((section) => ( + + + + + + + + ))} +
+ ) : null} +
+ ); +} + +/* ============================================================ + EDITORIAL (viewMode: STORY) + Full-bleed hero + alternating text / media sections. + ============================================================ */ +function EditorialTemplate({ item, locale, defaultLocale, t }: PortfolioProjectDetailProps) { + const cover = collectImages(item, locale)[0]!; + const sections = item.sections; + const galleryImages = collectImages(item, locale).slice(1); + return (
- + + + - {item.coverImagePath ? ( - - - - - - - - ) : null} - -
- {item.sections.map((section, index) => ( - - - - - - - - ))} -
- - -
- ); -} - -function StoryTemplate({ - item, - locale, - defaultLocale, - t, -}: PortfolioProjectDetailProps) { - return ( -
- - - {item.coverImagePath ? ( - -
- + +
+
+
- - ) : null} +
+
+

+ {getLocalizedValue(item.category.name, locale)} · {item.projectYear} +

+

{getLocalizedValue(item.title, locale)}

+
+
+ -
- {item.sections.map((section, index) => ( - -
-
-
- {String(index + 1).padStart(2, "0")} -
+
+ + +
+ + +

+ {getLocalizedValue(item.summary, locale)} +

+
+ +
+ {sections.map((section, index) => { + if (section.type === "GALLERY") { + return ( + + + + ); + } + + return ( + +
*:first-child]:order-2" : "" + }`} + > + +
- - - - - -
- - ))} + + ); + })}
- - -
- ); -} - -function CaseStudyTemplate({ - item, - locale, - defaultLocale, - t, -}: PortfolioProjectDetailProps) { - const [challenge, solution, outcome, ...restSections] = item.sections; - const leadSections = [challenge, solution, outcome].filter( - (section): section is PortfolioSectionView => Boolean(section), - ); - - return ( -
- - -
-
- {item.coverImagePath ? ( - - - - - - - - ) : null} - - {leadSections.map((section, index) => ( - - - -

- {index === 0 ? "Challenge" : index === 1 ? "Solution" : "Outcome"} -

- -
-
-
- ))} -
- -
- - - -
-

Case Study Snapshot

-

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

-
-

- {getLocalizedValue(item.summary, locale)} -

- -
-
-
-
-
- - {restSections.length > 0 ? ( -
- {restSections.map((section, index) => ( - - - - - - - - ))} -
- ) : null} - -
); } @@ -441,12 +447,12 @@ export function PortfolioProjectDetail({ const viewMode = resolvePortfolioProjectViewMode(item.viewMode); if (viewMode === "STORY") { - return ; + return ; } if (viewMode === "CASE_STUDY") { - return ; + return ; } - return ; + return ; }