Files
sass-mohfarawati/components/site/portfolio-project-detail.tsx
T
moh 22cd3f8645 STYLED - Rework project detail into three real layouts per viewMode
- CASE_STUDY -> Web: two columns, stacked full-width screenshots with a
  browser frame plus a sticky project info panel
- GRID -> Print: mixed-size gallery grid for logos/posters/etc.
- STORY -> Editorial: full-bleed hero with title overlay, then alternating
  text/media sections
- Add a prominent title/summary intro (was missing from the header)
- Use PortfolioCover for all media so empty projects show the branded
  placeholder instead of broken images; gallery sections render even
  before an image is uploaded
2026-09-20 16:27:05 +02:00

459 lines
14 KiB
TypeScript

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 (
<figure className="overflow-hidden rounded-surface border border-border bg-surface-2 shadow-card">
{chrome ? (
<div className="flex items-center gap-2 border-b border-border bg-surface-3 px-4 py-3">
<span className="h-2.5 w-2.5 rounded-full bg-border-strong" />
<span className="h-2.5 w-2.5 rounded-full bg-border-strong" />
<span className="h-2.5 w-2.5 rounded-full bg-border-strong" />
<span className="ms-3 h-5 flex-1 rounded-md bg-surface-1" />
</div>
) : null}
<div className={`group relative ${aspect}`}>
<PortfolioCover src={image.src} title={image.label} priority={priority} />
</div>
</figure>
);
}
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 (
<div className="flex flex-wrap gap-3 text-sm text-foreground/80">
{metadata.map((meta) => {
const Icon = meta.icon;
return (
<span
key={`${meta.label}-${Icon.name}`}
className="inline-flex items-center gap-2 rounded-pill border border-border/70 bg-surface-2 px-3.5 py-1.5"
>
<Icon className="h-4 w-4 text-brand-primary" />
{meta.label}
</span>
);
})}
</div>
);
}
function BackLink({
locale,
defaultLocale,
t,
}: {
locale: AppLocale;
defaultLocale: AppLocale;
t: PortfolioProjectDetailProps["t"];
}) {
return (
<Button asChild variant="ghost" className="h-auto px-0 py-0 text-sm text-muted-foreground">
<Link href={getLocalizedPath(locale, "/portfolio", defaultLocale)}>{t("back")}</Link>
</Button>
);
}
function PreviewButton({
item,
t,
full = false,
}: {
item: PortfolioProjectView;
t: PortfolioProjectDetailProps["t"];
full?: boolean;
}) {
if (!item.previewUrl) {
return null;
}
return (
<Button asChild className={full ? "w-full justify-center" : undefined}>
<Link href={item.previewUrl} target="_blank" rel="noreferrer">
{t("preview")}
<ArrowUpRight className="h-4 w-4" />
</Link>
</Button>
);
}
function ProjectIntro({ item, locale }: { item: PortfolioProjectView; locale: AppLocale }) {
return (
<div className="space-y-4">
<p className="text-overline uppercase tracking-[0.18em] text-brand-primary">
{getLocalizedValue(item.category.name, locale)} · {item.projectYear}
</p>
<h1 className="text-h1 text-foreground">{getLocalizedValue(item.title, locale)}</h1>
<p className="max-w-2xl text-body-lg text-muted-foreground">
{getLocalizedValue(item.summary, locale)}
</p>
</div>
);
}
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 (
<AppCard level={2} className="lg:sticky lg:top-24">
<CardContent className="space-y-5 p-6">
<dl className="space-y-0">
{rows.map((row, index) => (
<div
key={row.label}
className={`flex items-center justify-between gap-4 py-2.5 text-sm ${
index < rows.length - 1 ? "border-b border-dashed border-border" : ""
}`}
>
<dt className="text-muted-foreground">{row.label}</dt>
<dd className="font-semibold text-foreground">{row.value}</dd>
</div>
))}
</dl>
<PreviewButton item={item} t={t} full />
</CardContent>
</AppCard>
);
}
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 (
<div className="space-y-3">
<h3 className="text-h3 text-foreground">{title}</h3>
{body ? (
<p className="whitespace-pre-line text-body leading-7 text-muted-foreground">{body}</p>
) : null}
{section.type === "LINK" && section.linkUrl ? (
<Button asChild variant="outline">
<Link href={section.linkUrl} target="_blank" rel="noreferrer">
{t("openLink")}
<ArrowUpRight className="h-4 w-4" />
</Link>
</Button>
) : null}
</div>
);
}
/* ============================================================
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 (
<div className="space-y-8">
<MotionFade>
<div className="space-y-6">
<BackLink locale={locale} defaultLocale={defaultLocale} t={t} />
<ProjectIntro item={item} locale={locale} />
</div>
</MotionFade>
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_340px] lg:items-start">
<div className="order-2 space-y-6 lg:order-1">
{images.map((image, index) => (
<MotionFade key={image.key} delay={0.04 * index}>
<MediaFrame image={image} chrome aspect="aspect-[16/11]" priority={index === 0} />
</MotionFade>
))}
{texts.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{texts.map((section) => (
<AppCard key={section.id}>
<CardContent className="p-5 lg:p-6">
<TextSection section={section} locale={locale} t={t} />
</CardContent>
</AppCard>
))}
</div>
) : null}
</div>
<aside className="order-1 lg:order-2">
<InfoPanel item={item} locale={locale} t={t} />
</aside>
</div>
</div>
);
}
/* ============================================================
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 (
<div className="space-y-10">
<MotionFade>
<div className="space-y-6">
<BackLink locale={locale} defaultLocale={defaultLocale} t={t} />
<ProjectIntro item={item} locale={locale} />
<div className="flex flex-wrap items-center gap-3">
<ProjectMeta item={item} locale={locale} />
<PreviewButton item={item} t={t} />
</div>
</div>
</MotionFade>
<MotionFade delay={0.06}>
<div className="grid auto-rows-[minmax(0,1fr)] grid-cols-2 gap-4 md:grid-cols-3">
{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 (
<div key={image.key} className={span}>
<MediaFrame image={image} aspect={index === 0 ? "aspect-square" : aspect} priority={index === 0} />
</div>
);
})}
</div>
</MotionFade>
{texts.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2">
{texts.map((section) => (
<MotionFade key={section.id} delay={0.04}>
<AppCard>
<CardContent className="p-6">
<TextSection section={section} locale={locale} t={t} />
</CardContent>
</AppCard>
</MotionFade>
))}
</div>
) : null}
</div>
);
}
/* ============================================================
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 (
<div className="space-y-6">
<MotionFade>
<BackLink locale={locale} defaultLocale={defaultLocale} t={t} />
</MotionFade>
<MotionFade delay={0.04}>
<div className="relative flex min-h-[420px] items-end overflow-hidden rounded-surface border border-border">
<div className="absolute inset-0">
<PortfolioCover src={cover.src} title={cover.label} priority sizes="100vw" />
</div>
<div className="absolute inset-0 bg-gradient-to-t from-background/90 via-background/40 to-transparent" />
<div className="relative z-10 max-w-3xl space-y-4 p-8 lg:p-12">
<p className="text-overline uppercase tracking-[0.18em] text-brand-primary">
{getLocalizedValue(item.category.name, locale)} · {item.projectYear}
</p>
<h1 className="text-display text-foreground">{getLocalizedValue(item.title, locale)}</h1>
</div>
</div>
</MotionFade>
<div className="flex flex-wrap items-center gap-3">
<ProjectMeta item={item} locale={locale} />
<PreviewButton item={item} t={t} />
</div>
<MotionFade delay={0.06}>
<p className="max-w-3xl py-6 text-h3 font-medium leading-snug text-foreground/90">
{getLocalizedValue(item.summary, locale)}
</p>
</MotionFade>
<div className="space-y-16 py-4">
{sections.map((section, index) => {
if (section.type === "GALLERY") {
return (
<MotionFade key={section.id} delay={0.04}>
<MediaFrame
image={{
key: section.id,
src: section.imagePath ?? null,
label: getLocalizedValue(section.title, locale),
}}
aspect="aspect-[16/8]"
/>
</MotionFade>
);
}
return (
<MotionFade key={section.id} delay={0.04}>
<div
className={`grid items-center gap-8 lg:grid-cols-2 ${
index % 2 === 1 ? "lg:[&>*:first-child]:order-2" : ""
}`}
>
<TextSection section={section} locale={locale} t={t} />
<MediaFrame
image={galleryImages[index % Math.max(galleryImages.length, 1)] ?? cover}
aspect="aspect-[4/3]"
/>
</div>
</MotionFade>
);
})}
</div>
</div>
);
}
export function PortfolioProjectDetail({
item,
locale,
defaultLocale,
t,
}: PortfolioProjectDetailProps) {
const viewMode = resolvePortfolioProjectViewMode(item.viewMode);
if (viewMode === "STORY") {
return <EditorialTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
}
if (viewMode === "CASE_STUDY") {
return <WebTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
}
return <PrintTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
}