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 ;
}