import { ArrowUpRight, CalendarDays, FolderKanban, Tag, UserRound, } from "lucide-react"; 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"; import { getLocalizedPath, type AppLocale } from "@/lib/locale"; import { getLocalizedValue, resolvePortfolioProjectViewMode, type PortfolioProjectView, type PortfolioSectionView, } from "@/lib/portfolio"; type PortfolioProjectDetailProps = { item: PortfolioProjectView; locale: AppLocale; defaultLocale: AppLocale; t: (key: "back" | "preview" | "openLink" | "gallery" | "download") => string; }; 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, }: { image: ProjectImage; aspect?: string; chrome?: boolean; priority?: boolean; }) { return (
{chrome ? (
) : null}
); } function ProjectMeta({ item, locale, }: { item: PortfolioProjectView; 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 }, ].filter((entry) => entry.label); return (
{metadata.map((meta) => { const Icon = meta.icon; return ( {meta.label} ); })}
); } 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, }: { section: PortfolioSectionView; locale: AppLocale; t: PortfolioProjectDetailProps["t"]; }) { const title = getLocalizedValue(section.title, locale); const body = getLocalizedValue(section.body, locale); return (

{title}

{body ? (

{body}

) : null} {section.type === "LINK" && section.linkUrl ? ( ) : 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 (
{images.map((image, index) => ( ))} {texts.length > 0 ? (
{texts.map((section) => ( ))}
) : null}
); } /* ============================================================ 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 (

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

{getLocalizedValue(item.title, locale)}

{getLocalizedValue(item.summary, locale)}

{sections.map((section, index) => { if (section.type === "GALLERY") { return ( ); } return (
*:first-child]:order-2" : "" }`} >
); })}
); } export function PortfolioProjectDetail({ item, locale, defaultLocale, t, }: PortfolioProjectDetailProps) { const viewMode = resolvePortfolioProjectViewMode(item.viewMode); if (viewMode === "STORY") { return ; } if (viewMode === "CASE_STUDY") { return ; } return ; }