diff --git a/components/site/portfolio-project-detail.tsx b/components/site/portfolio-project-detail.tsx
index e829dd2..8260d3f 100644
--- a/components/site/portfolio-project-detail.tsx
+++ b/components/site/portfolio-project-detail.tsx
@@ -5,10 +5,10 @@ import {
Tag,
UserRound,
} from "lucide-react";
-import Image from "next/image";
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";
@@ -16,7 +16,6 @@ import { getLocalizedPath, type AppLocale } from "@/lib/locale";
import {
getLocalizedValue,
resolvePortfolioProjectViewMode,
- type PortfolioAssetView,
type PortfolioProjectView,
type PortfolioSectionView,
} from "@/lib/portfolio";
@@ -28,27 +27,76 @@ type PortfolioProjectDetailProps = {
t: (key: "back" | "preview" | "openLink" | "gallery" | "download") => string;
};
-function PortfolioImage({
- src,
- alt,
- className,
- width,
- height,
+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,
}: {
- src: string;
- alt: string;
- className: string;
- width: number;
- height: number;
+ image: ProjectImage;
+ aspect?: string;
+ chrome?: boolean;
+ priority?: boolean;
}) {
return (
-
+
+ {chrome ? (
+
+
+
+
+
+
+ ) : null}
+
+
);
}
@@ -60,22 +108,10 @@ function ProjectMeta({
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,
- },
+ { 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 (
@@ -84,19 +120,111 @@ function ProjectMeta({
const Icon = meta.icon;
return (
-
-
-
- {meta.label}
-
-
+
+
+ {meta.label}
+
);
})}
);
}
-function SectionBlock({
+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,
@@ -108,326 +236,204 @@ function SectionBlock({
const title = getLocalizedValue(section.title, locale);
const body = getLocalizedValue(section.body, locale);
- if (section.type === "GALLERY") {
- return (
-
-
{title}
- {section.imagePath ? (
-
- ) : null}
-
- );
- }
-
- if (section.type === "LINK") {
- return (
-
-
{title}
- {body ?
{body}
: null}
- {section.linkUrl ? (
-
- ) : null}
-
- );
- }
-
return (
-
-
{title}
-
{body}
+
+
{title}
+ {body ? (
+
{body}
+ ) : null}
+ {section.type === "LINK" && section.linkUrl ? (
+
+ ) : null}
);
}
-function AssetGallery({
- assets,
- locale,
- t,
-}: {
- assets: PortfolioAssetView[];
- locale: AppLocale;
- t: PortfolioProjectDetailProps["t"];
-}) {
- if (assets.length === 0) {
- return 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 (
-
-
-
-
-
-
Assets
-
{t("gallery")}
-
-
-
-
- {assets.map((asset) => (
-
- {asset.kind === "IMAGE" ? (
-
- ) : (
-
-
-
- {getLocalizedValue(asset.alt, locale)}
-
-
-
-
- )}
-
- ))}
-
-
-
-
- );
-}
+
+
+
+
-function BadgeCount({ count }: { count: number }) {
- return (
-
- {count} {count === 1 ? "file" : "files"}
-
- );
-}
+
+
+ {images.map((image, index) => (
+
+
+
+ ))}
-function ProjectHeader({
- item,
- locale,
- defaultLocale,
- t,
-}: {
- item: PortfolioProjectView;
- locale: AppLocale;
- defaultLocale: AppLocale;
- t: PortfolioProjectDetailProps["t"];
-}) {
- return (
-
-
-
-
-
-
-
- {item.previewUrl ? (
-
-
+ {texts.length > 0 ? (
+
+ {texts.map((section) => (
+
+
+
+
+
+ ))}
) : null}
-
-
-
+
+
+
+
+
);
}
-function GridTemplate({
- item,
- locale,
- defaultLocale,
- t,
-}: PortfolioProjectDetailProps) {
+/* ============================================================
+ 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 (
-
+
+
+
- {item.coverImagePath ? (
-
-
-
-
-
-
-
- ) : null}
-
-
- {item.sections.map((section, index) => (
-
-
-
-
-
-
-
- ))}
-
-
-
-
- );
-}
-
-function StoryTemplate({
- item,
- locale,
- defaultLocale,
- t,
-}: PortfolioProjectDetailProps) {
- return (
-
-
-
- {item.coverImagePath ? (
-
-
-
+
+
+
-
- ) : null}
+
+
+
+ {getLocalizedValue(item.category.name, locale)} · {item.projectYear}
+
+
{getLocalizedValue(item.title, locale)}
+
+
+
-
- {item.sections.map((section, index) => (
-
-
-
-
- {String(index + 1).padStart(2, "0")}
-
+
+
+
+
+ {getLocalizedValue(item.summary, locale)}
+
+
+
+
+ {sections.map((section, index) => {
+ if (section.type === "GALLERY") {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ *:first-child]:order-2" : ""
+ }`}
+ >
+
+
-
-
-
-
-
-
-
- ))}
+
+ );
+ })}
-
-
-
- );
-}
-
-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 (
-
-
-
-
-
- {item.coverImagePath ? (
-
-
-
-
-
-
-
- ) : null}
-
- {leadSections.map((section, index) => (
-
-
-
-
- {index === 0 ? "Challenge" : index === 1 ? "Solution" : "Outcome"}
-
-
-
-
-
- ))}
-
-
-
-
-
-
-
-
Case Study Snapshot
-
- {getLocalizedValue(item.title, locale)}
-
-
-
- {getLocalizedValue(item.summary, locale)}
-
-
-
-
-
-
-
-
- {restSections.length > 0 ? (
-
- {restSections.map((section, index) => (
-
-
-
-
-
-
-
- ))}
-
- ) : null}
-
-
);
}
@@ -441,12 +447,12 @@ export function PortfolioProjectDetail({
const viewMode = resolvePortfolioProjectViewMode(item.viewMode);
if (viewMode === "STORY") {
- return ;
+ return ;
}
if (viewMode === "CASE_STUDY") {
- return ;
+ return ;
}
- return ;
+ return ;
}