238 lines
7.8 KiB
TypeScript
238 lines
7.8 KiB
TypeScript
import type { Metadata } from "next";
|
|
import { ArrowLeft, ArrowUpRight, CalendarDays, FolderKanban, Tag, UserRound } from "lucide-react";
|
|
import Link from "next/link";
|
|
import Image from "next/image";
|
|
import { getTranslations } from "next-intl/server";
|
|
import { notFound } from "next/navigation";
|
|
|
|
import { Container } from "@/components/layout/container";
|
|
import { MotionFade } from "@/components/motion-fade";
|
|
import { buildLocalizedMetadata } from "@/lib/metadata";
|
|
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
|
import {
|
|
getLocalizedValue,
|
|
getPublishedPortfolioProjectBySlug,
|
|
} from "@/lib/portfolio";
|
|
import { AppCard } from "@/components/ui/app-card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { CardContent } from "@/components/ui/card";
|
|
|
|
type PortfolioItemPageProps = {
|
|
params: {
|
|
locale: string;
|
|
slug: string;
|
|
};
|
|
};
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
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}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export async function generateMetadata({
|
|
params: { locale, slug },
|
|
}: PortfolioItemPageProps): Promise<Metadata> {
|
|
const localeKey = resolveLocale(locale);
|
|
const item = await getPublishedPortfolioProjectBySlug(slug);
|
|
|
|
if (!item) {
|
|
return await buildLocalizedMetadata({
|
|
locale: localeKey,
|
|
pathname: `/portfolio/${slug}`,
|
|
title: "Portfolio",
|
|
description: "Portfolio item",
|
|
});
|
|
}
|
|
|
|
return await buildLocalizedMetadata({
|
|
locale: localeKey,
|
|
pathname: `/portfolio/${slug}`,
|
|
title: getLocalizedValue(item.title, localeKey),
|
|
description: getLocalizedValue(item.summary, localeKey),
|
|
});
|
|
}
|
|
|
|
export default async function PortfolioItemPage({
|
|
params: { locale, slug },
|
|
}: PortfolioItemPageProps) {
|
|
const localeKey = resolveLocale(locale);
|
|
const item = await getPublishedPortfolioProjectBySlug(slug);
|
|
|
|
if (!item) {
|
|
notFound();
|
|
}
|
|
|
|
const t = await getTranslations({ locale: localeKey, namespace: "portfolioDetail" });
|
|
|
|
return (
|
|
<Container size="wide" className="flex flex-col gap-section py-10 lg:py-14">
|
|
<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(localeKey, "/portfolio")}>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
{t("back")}
|
|
</Link>
|
|
</Button>
|
|
|
|
<h1 className="mt-5 text-3xl font-semibold text-foreground sm:text-4xl">
|
|
{getLocalizedValue(item.title, localeKey)}
|
|
</h1>
|
|
<p className="mt-4 text-base text-muted-foreground sm:text-lg">
|
|
{getLocalizedValue(item.summary, localeKey)}
|
|
</p>
|
|
|
|
{item.coverImagePath ? (
|
|
<div className="mt-6 overflow-hidden rounded-surface border border-border">
|
|
<PortfolioImage
|
|
src={item.coverImagePath}
|
|
alt={getLocalizedValue(item.title, localeKey)}
|
|
width={1600}
|
|
height={900}
|
|
className="h-auto w-full object-cover"
|
|
/>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="mt-6 flex flex-wrap gap-3 text-sm text-foreground/80">
|
|
{[
|
|
{
|
|
icon: Tag,
|
|
label: getLocalizedValue(item.category.name, localeKey),
|
|
},
|
|
{
|
|
icon: CalendarDays,
|
|
label: String(item.projectYear),
|
|
},
|
|
{
|
|
icon: FolderKanban,
|
|
label: getLocalizedValue(item.serviceLabel, localeKey),
|
|
},
|
|
{
|
|
icon: UserRound,
|
|
label: item.clientName,
|
|
},
|
|
].map((meta) => {
|
|
const Icon = meta.icon;
|
|
|
|
return (
|
|
<AppCard key={meta.label} 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>
|
|
|
|
{item.previewUrl ? (
|
|
<div className="mt-6">
|
|
<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>
|
|
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
{item.sections.map((section, index) => (
|
|
<MotionFade key={section.id} delay={0.05 * (index + 1)}>
|
|
<AppCard>
|
|
<CardContent className="p-5">
|
|
<h2 className="text-lg font-semibold text-foreground">
|
|
{getLocalizedValue(section.title, localeKey)}
|
|
</h2>
|
|
<p className="mt-2 whitespace-pre-line text-sm text-muted-foreground">
|
|
{getLocalizedValue(section.body, localeKey)}
|
|
</p>
|
|
{section.imagePath ? (
|
|
<PortfolioImage
|
|
src={section.imagePath}
|
|
alt={getLocalizedValue(section.title, localeKey)}
|
|
width={1200}
|
|
height={720}
|
|
className="mt-4 h-48 w-full rounded-nested object-cover"
|
|
/>
|
|
) : null}
|
|
{section.linkUrl ? (
|
|
<Button asChild variant="outline" className="mt-4">
|
|
<Link href={section.linkUrl} target="_blank" rel="noreferrer">
|
|
{t("openLink")}
|
|
<ArrowUpRight className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
) : null}
|
|
</CardContent>
|
|
</AppCard>
|
|
</MotionFade>
|
|
))}
|
|
</div>
|
|
|
|
{item.assets.length > 0 ? (
|
|
<MotionFade delay={0.1}>
|
|
<AppCard>
|
|
<CardContent className="p-6 lg:p-8">
|
|
<h2 className="text-xl font-semibold text-foreground">{t("gallery")}</h2>
|
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
|
{item.assets.map((asset) => (
|
|
<div key={asset.id} className="overflow-hidden rounded-surface border border-border">
|
|
{asset.kind === "IMAGE" ? (
|
|
<PortfolioImage
|
|
src={asset.filePath}
|
|
alt={getLocalizedValue(asset.alt, localeKey)}
|
|
width={1200}
|
|
height={720}
|
|
className="h-64 w-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="flex h-64 items-center justify-center bg-surface-1 p-6 text-center text-sm text-muted-foreground">
|
|
<div className="space-y-3">
|
|
<p>{getLocalizedValue(asset.alt, localeKey)}</p>
|
|
<Button asChild variant="outline">
|
|
<Link href={asset.filePath} target="_blank" rel="noreferrer">
|
|
{t("download")}
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</AppCard>
|
|
</MotionFade>
|
|
) : null}
|
|
</Container>
|
|
);
|
|
}
|