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
This commit is contained in:
@@ -5,10 +5,10 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
UserRound,
|
UserRound,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
|
import { PortfolioCover } from "@/components/site/portfolio-cover";
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CardContent } from "@/components/ui/card";
|
import { CardContent } from "@/components/ui/card";
|
||||||
@@ -16,7 +16,6 @@ import { getLocalizedPath, type AppLocale } from "@/lib/locale";
|
|||||||
import {
|
import {
|
||||||
getLocalizedValue,
|
getLocalizedValue,
|
||||||
resolvePortfolioProjectViewMode,
|
resolvePortfolioProjectViewMode,
|
||||||
type PortfolioAssetView,
|
|
||||||
type PortfolioProjectView,
|
type PortfolioProjectView,
|
||||||
type PortfolioSectionView,
|
type PortfolioSectionView,
|
||||||
} from "@/lib/portfolio";
|
} from "@/lib/portfolio";
|
||||||
@@ -28,27 +27,76 @@ type PortfolioProjectDetailProps = {
|
|||||||
t: (key: "back" | "preview" | "openLink" | "gallery" | "download") => string;
|
t: (key: "back" | "preview" | "openLink" | "gallery" | "download") => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function PortfolioImage({
|
type ProjectImage = {
|
||||||
src,
|
key: string;
|
||||||
alt,
|
src: string | null;
|
||||||
className,
|
label: string;
|
||||||
width,
|
};
|
||||||
height,
|
|
||||||
|
/**
|
||||||
|
* 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;
|
image: ProjectImage;
|
||||||
alt: string;
|
aspect?: string;
|
||||||
className: string;
|
chrome?: boolean;
|
||||||
width: number;
|
priority?: boolean;
|
||||||
height: number;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Image
|
<figure className="overflow-hidden rounded-surface border border-border bg-surface-2 shadow-card">
|
||||||
src={src}
|
{chrome ? (
|
||||||
alt={alt}
|
<div className="flex items-center gap-2 border-b border-border bg-surface-3 px-4 py-3">
|
||||||
width={width}
|
<span className="h-2.5 w-2.5 rounded-full bg-border-strong" />
|
||||||
height={height}
|
<span className="h-2.5 w-2.5 rounded-full bg-border-strong" />
|
||||||
className={className}
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,22 +108,10 @@ function ProjectMeta({
|
|||||||
locale: AppLocale;
|
locale: AppLocale;
|
||||||
}) {
|
}) {
|
||||||
const metadata = [
|
const metadata = [
|
||||||
{
|
{ icon: Tag, label: getLocalizedValue(item.category.name, locale) },
|
||||||
icon: Tag,
|
{ icon: CalendarDays, label: String(item.projectYear) },
|
||||||
label: getLocalizedValue(item.category.name, locale),
|
{ icon: FolderKanban, label: getLocalizedValue(item.serviceLabel, locale) },
|
||||||
},
|
{ icon: UserRound, label: item.clientName },
|
||||||
{
|
|
||||||
icon: CalendarDays,
|
|
||||||
label: String(item.projectYear),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: FolderKanban,
|
|
||||||
label: getLocalizedValue(item.serviceLabel, locale),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: UserRound,
|
|
||||||
label: item.clientName,
|
|
||||||
},
|
|
||||||
].filter((entry) => entry.label);
|
].filter((entry) => entry.label);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -84,19 +120,111 @@ function ProjectMeta({
|
|||||||
const Icon = meta.icon;
|
const Icon = meta.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard key={`${meta.label}-${Icon.name}`} level={2}>
|
<span
|
||||||
<CardContent className="flex items-center gap-2 p-3">
|
key={`${meta.label}-${Icon.name}`}
|
||||||
<Icon className="h-4 w-4 text-brand-primary" />
|
className="inline-flex items-center gap-2 rounded-pill border border-border/70 bg-surface-2 px-3.5 py-1.5"
|
||||||
{meta.label}
|
>
|
||||||
</CardContent>
|
<Icon className="h-4 w-4 text-brand-primary" />
|
||||||
</AppCard>
|
{meta.label}
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionBlock({
|
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,
|
section,
|
||||||
locale,
|
locale,
|
||||||
t,
|
t,
|
||||||
@@ -108,326 +236,204 @@ function SectionBlock({
|
|||||||
const title = getLocalizedValue(section.title, locale);
|
const title = getLocalizedValue(section.title, locale);
|
||||||
const body = getLocalizedValue(section.body, locale);
|
const body = getLocalizedValue(section.body, locale);
|
||||||
|
|
||||||
if (section.type === "GALLERY") {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-xl font-semibold text-foreground">{title}</h3>
|
|
||||||
{section.imagePath ? (
|
|
||||||
<PortfolioImage
|
|
||||||
src={section.imagePath}
|
|
||||||
alt={title}
|
|
||||||
width={1400}
|
|
||||||
height={880}
|
|
||||||
className="h-72 w-full rounded-surface object-cover"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (section.type === "LINK") {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-xl font-semibold text-foreground">{title}</h3>
|
|
||||||
{body ? <p className="whitespace-pre-line text-sm leading-7 text-muted-foreground">{body}</p> : null}
|
|
||||||
{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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<h3 className="text-xl font-semibold text-foreground">{title}</h3>
|
<h3 className="text-h3 text-foreground">{title}</h3>
|
||||||
<p className="whitespace-pre-line text-sm leading-7 text-muted-foreground">{body}</p>
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssetGallery({
|
/* ============================================================
|
||||||
assets,
|
WEB (viewMode: CASE_STUDY)
|
||||||
locale,
|
Two columns: stacked full screenshots + sticky info panel.
|
||||||
t,
|
============================================================ */
|
||||||
}: {
|
function WebTemplate({ item, locale, defaultLocale, t }: PortfolioProjectDetailProps) {
|
||||||
assets: PortfolioAssetView[];
|
const images = collectImages(item, locale);
|
||||||
locale: AppLocale;
|
const texts = textSections(item);
|
||||||
t: PortfolioProjectDetailProps["t"];
|
|
||||||
}) {
|
|
||||||
if (assets.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MotionFade delay={0.12}>
|
<div className="space-y-8">
|
||||||
<AppCard>
|
<MotionFade>
|
||||||
<CardContent className="p-6 lg:p-8">
|
<div className="space-y-6">
|
||||||
<div className="flex items-end justify-between gap-4">
|
<BackLink locale={locale} defaultLocale={defaultLocale} t={t} />
|
||||||
<div>
|
<ProjectIntro item={item} locale={locale} />
|
||||||
<p className="text-sm uppercase tracking-[0.18em] text-muted-foreground">Assets</p>
|
</div>
|
||||||
<h2 className="mt-2 text-2xl font-semibold text-foreground">{t("gallery")}</h2>
|
</MotionFade>
|
||||||
</div>
|
|
||||||
<BadgeCount count={assets.length} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
|
||||||
{assets.map((asset) => (
|
|
||||||
<div key={asset.id} className="overflow-hidden rounded-surface border border-border bg-card">
|
|
||||||
{asset.kind === "IMAGE" ? (
|
|
||||||
<PortfolioImage
|
|
||||||
src={asset.filePath}
|
|
||||||
alt={getLocalizedValue(asset.alt, locale)}
|
|
||||||
width={1200}
|
|
||||||
height={760}
|
|
||||||
className="h-72 w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex min-h-72 items-center justify-center bg-muted/30 p-6 text-center">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-sm font-medium text-foreground">
|
|
||||||
{getLocalizedValue(asset.alt, locale)}
|
|
||||||
</p>
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href={asset.filePath} target="_blank" rel="noreferrer">
|
|
||||||
{t("download")}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BadgeCount({ count }: { count: number }) {
|
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_340px] lg:items-start">
|
||||||
return (
|
<div className="order-2 space-y-6 lg:order-1">
|
||||||
<div className="rounded-pill border border-border/70 bg-background px-4 py-2 text-sm text-muted-foreground">
|
{images.map((image, index) => (
|
||||||
{count} {count === 1 ? "file" : "files"}
|
<MotionFade key={image.key} delay={0.04 * index}>
|
||||||
</div>
|
<MediaFrame image={image} chrome aspect="aspect-[16/11]" priority={index === 0} />
|
||||||
);
|
</MotionFade>
|
||||||
}
|
))}
|
||||||
|
|
||||||
function ProjectHeader({
|
{texts.length > 0 ? (
|
||||||
item,
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
locale,
|
{texts.map((section) => (
|
||||||
defaultLocale,
|
<AppCard key={section.id}>
|
||||||
t,
|
<CardContent className="p-5 lg:p-6">
|
||||||
}: {
|
<TextSection section={section} locale={locale} t={t} />
|
||||||
item: PortfolioProjectView;
|
</CardContent>
|
||||||
locale: AppLocale;
|
</AppCard>
|
||||||
defaultLocale: AppLocale;
|
))}
|
||||||
t: PortfolioProjectDetailProps["t"];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<MotionFade>
|
|
||||||
<AppCard level={3}>
|
|
||||||
<CardContent className="p-6 lg:p-10">
|
|
||||||
<Button asChild variant="ghost" className="h-auto px-0 py-0 text-sm">
|
|
||||||
<Link href={getLocalizedPath(locale, "/portfolio", defaultLocale)}>{t("back")}</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<ProjectMeta item={item} locale={locale} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{item.previewUrl ? (
|
|
||||||
<div className="mt-8">
|
|
||||||
<Button asChild>
|
|
||||||
<Link href={item.previewUrl} target="_blank" rel="noreferrer">
|
|
||||||
{t("preview")}
|
|
||||||
<ArrowUpRight className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</div>
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
<aside className="order-1 lg:order-2">
|
||||||
|
<InfoPanel item={item} locale={locale} t={t} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GridTemplate({
|
/* ============================================================
|
||||||
item,
|
PRINT (viewMode: GRID)
|
||||||
locale,
|
Gallery: mixed-size grid of images (logos, posters, ...).
|
||||||
defaultLocale,
|
============================================================ */
|
||||||
t,
|
function PrintTemplate({ item, locale, defaultLocale, t }: PortfolioProjectDetailProps) {
|
||||||
}: 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<ProjectHeader item={item} locale={locale} defaultLocale={defaultLocale} t={t} />
|
<MotionFade>
|
||||||
|
<BackLink locale={locale} defaultLocale={defaultLocale} t={t} />
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
{item.coverImagePath ? (
|
<MotionFade delay={0.04}>
|
||||||
<MotionFade delay={0.04}>
|
<div className="relative flex min-h-[420px] items-end overflow-hidden rounded-surface border border-border">
|
||||||
<AppCard>
|
<div className="absolute inset-0">
|
||||||
<CardContent className="p-3 lg:p-4">
|
<PortfolioCover src={cover.src} title={cover.label} priority sizes="100vw" />
|
||||||
<PortfolioImage
|
|
||||||
src={item.coverImagePath}
|
|
||||||
alt={getLocalizedValue(item.title, locale)}
|
|
||||||
width={1800}
|
|
||||||
height={1000}
|
|
||||||
className="h-auto w-full rounded-surface object-cover"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
|
||||||
{item.sections.map((section, index) => (
|
|
||||||
<MotionFade key={section.id} delay={0.06 * (index + 1)}>
|
|
||||||
<AppCard>
|
|
||||||
<CardContent className="p-5 lg:p-6">
|
|
||||||
<SectionBlock section={section} locale={locale} t={t} />
|
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AssetGallery assets={item.assets} locale={locale} t={t} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StoryTemplate({
|
|
||||||
item,
|
|
||||||
locale,
|
|
||||||
defaultLocale,
|
|
||||||
t,
|
|
||||||
}: PortfolioProjectDetailProps) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<ProjectHeader item={item} locale={locale} defaultLocale={defaultLocale} t={t} />
|
|
||||||
|
|
||||||
{item.coverImagePath ? (
|
|
||||||
<MotionFade delay={0.04}>
|
|
||||||
<div className="overflow-hidden rounded-[32px] border border-border/70 bg-card p-3 shadow-card">
|
|
||||||
<PortfolioImage
|
|
||||||
src={item.coverImagePath}
|
|
||||||
alt={getLocalizedValue(item.title, locale)}
|
|
||||||
width={1800}
|
|
||||||
height={1100}
|
|
||||||
className="h-[440px] w-full rounded-[28px] object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</MotionFade>
|
<div className="absolute inset-0 bg-gradient-to-t from-background/90 via-background/40 to-transparent" />
|
||||||
) : null}
|
<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="space-y-4">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{item.sections.map((section, index) => (
|
<ProjectMeta item={item} locale={locale} />
|
||||||
<MotionFade key={section.id} delay={0.06 * (index + 1)}>
|
<PreviewButton item={item} t={t} />
|
||||||
<div className="grid gap-4 lg:grid-cols-[120px_minmax(0,1fr)]">
|
</div>
|
||||||
<div className="pt-4">
|
|
||||||
<div className="inline-flex rounded-pill border border-border/70 bg-background px-4 py-2 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
<MotionFade delay={0.06}>
|
||||||
{String(index + 1).padStart(2, "0")}
|
<p className="max-w-3xl py-6 text-h3 font-medium leading-snug text-foreground/90">
|
||||||
</div>
|
{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>
|
</div>
|
||||||
<AppCard className="overflow-hidden">
|
</MotionFade>
|
||||||
<CardContent className="p-6 lg:p-8">
|
);
|
||||||
<SectionBlock section={section} locale={locale} t={t} />
|
})}
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
</div>
|
|
||||||
</MotionFade>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AssetGallery assets={item.assets} locale={locale} t={t} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<ProjectHeader item={item} locale={locale} defaultLocale={defaultLocale} t={t} />
|
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_420px]">
|
|
||||||
<div className="space-y-6">
|
|
||||||
{item.coverImagePath ? (
|
|
||||||
<MotionFade delay={0.04}>
|
|
||||||
<AppCard className="overflow-hidden">
|
|
||||||
<CardContent className="p-3">
|
|
||||||
<PortfolioImage
|
|
||||||
src={item.coverImagePath}
|
|
||||||
alt={getLocalizedValue(item.title, locale)}
|
|
||||||
width={1800}
|
|
||||||
height={1100}
|
|
||||||
className="h-auto w-full rounded-surface object-cover"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{leadSections.map((section, index) => (
|
|
||||||
<MotionFade key={section.id} delay={0.06 * (index + 1)}>
|
|
||||||
<AppCard level={index === 1 ? 3 : 1}>
|
|
||||||
<CardContent className="space-y-4 p-6 lg:p-8">
|
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
|
||||||
{index === 0 ? "Challenge" : index === 1 ? "Solution" : "Outcome"}
|
|
||||||
</p>
|
|
||||||
<SectionBlock section={section} locale={locale} t={t} />
|
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<MotionFade delay={0.08}>
|
|
||||||
<AppCard level={2} className="sticky top-24">
|
|
||||||
<CardContent className="space-y-5 p-6">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Case Study Snapshot</p>
|
|
||||||
<h2 className="mt-2 text-2xl font-semibold text-foreground">
|
|
||||||
{getLocalizedValue(item.title, locale)}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm leading-7 text-muted-foreground">
|
|
||||||
{getLocalizedValue(item.summary, locale)}
|
|
||||||
</p>
|
|
||||||
<ProjectMeta item={item} locale={locale} />
|
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{restSections.length > 0 ? (
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
|
||||||
{restSections.map((section, index) => (
|
|
||||||
<MotionFade key={section.id} delay={0.1 + index * 0.04}>
|
|
||||||
<AppCard>
|
|
||||||
<CardContent className="p-5 lg:p-6">
|
|
||||||
<SectionBlock section={section} locale={locale} t={t} />
|
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<AssetGallery assets={item.assets} locale={locale} t={t} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -441,12 +447,12 @@ export function PortfolioProjectDetail({
|
|||||||
const viewMode = resolvePortfolioProjectViewMode(item.viewMode);
|
const viewMode = resolvePortfolioProjectViewMode(item.viewMode);
|
||||||
|
|
||||||
if (viewMode === "STORY") {
|
if (viewMode === "STORY") {
|
||||||
return <StoryTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
|
return <EditorialTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewMode === "CASE_STUDY") {
|
if (viewMode === "CASE_STUDY") {
|
||||||
return <CaseStudyTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
|
return <WebTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <GridTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
|
return <PrintTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user