465 lines
14 KiB
TypeScript
465 lines
14 KiB
TypeScript
import {
|
|
ArrowUpRight,
|
|
CalendarDays,
|
|
FolderKanban,
|
|
Tag,
|
|
UserRound,
|
|
} from "lucide-react";
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
|
|
import { MotionFade } from "@/components/motion-fade";
|
|
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 PortfolioAssetView,
|
|
type PortfolioProjectView,
|
|
type PortfolioSectionView,
|
|
} from "@/lib/portfolio";
|
|
|
|
type PortfolioProjectDetailProps = {
|
|
item: PortfolioProjectView;
|
|
locale: AppLocale;
|
|
defaultLocale: AppLocale;
|
|
t: (key: "back" | "preview" | "openLink" | "gallery" | "download") => string;
|
|
};
|
|
|
|
function PortfolioImage({
|
|
src,
|
|
alt,
|
|
className,
|
|
width,
|
|
height,
|
|
}: {
|
|
src: string;
|
|
alt: string;
|
|
className: string;
|
|
width: number;
|
|
height: number;
|
|
}) {
|
|
return (
|
|
<Image
|
|
src={src}
|
|
alt={alt}
|
|
width={width}
|
|
height={height}
|
|
unoptimized
|
|
className={className}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<AppCard key={`${meta.label}-${Icon.name}`} level={2}>
|
|
<CardContent className="flex items-center gap-2 p-3">
|
|
<Icon className="h-4 w-4 text-brand-primary" />
|
|
{meta.label}
|
|
</CardContent>
|
|
</AppCard>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SectionBlock({
|
|
section,
|
|
locale,
|
|
t,
|
|
}: {
|
|
section: PortfolioSectionView;
|
|
locale: AppLocale;
|
|
t: PortfolioProjectDetailProps["t"];
|
|
}) {
|
|
const title = getLocalizedValue(section.title, 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"
|
|
/>
|
|
) : (
|
|
<div className="rounded-surface border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground">
|
|
No image configured.
|
|
</div>
|
|
)}
|
|
</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 (
|
|
<div className="space-y-4">
|
|
<h3 className="text-xl font-semibold text-foreground">{title}</h3>
|
|
<p className="whitespace-pre-line text-sm leading-7 text-muted-foreground">{body}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AssetGallery({
|
|
assets,
|
|
locale,
|
|
t,
|
|
}: {
|
|
assets: PortfolioAssetView[];
|
|
locale: AppLocale;
|
|
t: PortfolioProjectDetailProps["t"];
|
|
}) {
|
|
if (assets.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<MotionFade delay={0.12}>
|
|
<AppCard>
|
|
<CardContent className="p-6 lg:p-8">
|
|
<div className="flex items-end justify-between gap-4">
|
|
<div>
|
|
<p className="text-sm uppercase tracking-[0.18em] text-muted-foreground">Assets</p>
|
|
<h2 className="mt-2 text-2xl font-semibold text-foreground">{t("gallery")}</h2>
|
|
</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 }) {
|
|
return (
|
|
<div className="rounded-pill border border-border/70 bg-background px-4 py-2 text-sm text-muted-foreground">
|
|
{count}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProjectHeader({
|
|
item,
|
|
locale,
|
|
defaultLocale,
|
|
t,
|
|
}: {
|
|
item: PortfolioProjectView;
|
|
locale: AppLocale;
|
|
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>
|
|
|
|
<h1 className="mt-5 text-3xl font-semibold text-foreground sm:text-4xl lg:text-5xl">
|
|
{getLocalizedValue(item.title, locale)}
|
|
</h1>
|
|
<p className="mt-4 max-w-3xl text-base leading-8 text-muted-foreground sm:text-lg">
|
|
{getLocalizedValue(item.summary, locale)}
|
|
</p>
|
|
|
|
<div className="mt-8">
|
|
<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>
|
|
) : null}
|
|
</CardContent>
|
|
</AppCard>
|
|
</MotionFade>
|
|
);
|
|
}
|
|
|
|
function GridTemplate({
|
|
item,
|
|
locale,
|
|
defaultLocale,
|
|
t,
|
|
}: PortfolioProjectDetailProps) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<ProjectHeader item={item} locale={locale} defaultLocale={defaultLocale} t={t} />
|
|
|
|
{item.coverImagePath ? (
|
|
<MotionFade delay={0.04}>
|
|
<AppCard>
|
|
<CardContent className="p-3 lg:p-4">
|
|
<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>
|
|
</MotionFade>
|
|
) : null}
|
|
|
|
<div className="space-y-4">
|
|
{item.sections.map((section, index) => (
|
|
<MotionFade key={section.id} delay={0.06 * (index + 1)}>
|
|
<div className="grid gap-4 lg:grid-cols-[120px_minmax(0,1fr)]">
|
|
<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">
|
|
{String(index + 1).padStart(2, "0")}
|
|
</div>
|
|
</div>
|
|
<AppCard className="overflow-hidden">
|
|
<CardContent className="p-6 lg:p-8">
|
|
<SectionBlock section={section} locale={locale} t={t} />
|
|
</CardContent>
|
|
</AppCard>
|
|
</div>
|
|
</MotionFade>
|
|
))}
|
|
</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>
|
|
);
|
|
}
|
|
|
|
export function PortfolioProjectDetail({
|
|
item,
|
|
locale,
|
|
defaultLocale,
|
|
t,
|
|
}: PortfolioProjectDetailProps) {
|
|
const viewMode = resolvePortfolioProjectViewMode(item.viewMode);
|
|
|
|
if (viewMode === "STORY") {
|
|
return <StoryTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
|
|
}
|
|
|
|
if (viewMode === "CASE_STUDY") {
|
|
return <CaseStudyTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
|
|
}
|
|
|
|
return <GridTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
|
|
}
|