Add patterned title bands, lightbox gallery, project link bar, footer stats/credit, gradient link hovers, and more reliable blog covers/body rendering. Co-authored-by: Cursor <cursoragent@cursor.com>
202 lines
6.2 KiB
TypeScript
202 lines
6.2 KiB
TypeScript
import type { Metadata } from "next";
|
|
import Link from "next/link";
|
|
import { notFound } from "next/navigation";
|
|
import { ContentPageShell } from "@/components/ContentPageShell";
|
|
import { HtmlContent } from "@/components/HtmlContent";
|
|
import { PortfolioGallery } from "@/components/PortfolioGallery";
|
|
import { PortfolioProjectLink } from "@/components/PortfolioProjectLink";
|
|
import {
|
|
categoryLabelFa,
|
|
findPortfolioCategory,
|
|
getRootCategories,
|
|
portfolioCategoryHref,
|
|
topPortfolioCategories,
|
|
type PortfolioCategory,
|
|
} from "@/data/portfolioCategories";
|
|
import {
|
|
ApiNotFoundError,
|
|
formatFaDate,
|
|
getPortfolio,
|
|
getPortfolioCategories,
|
|
getPortfolioCoverImage,
|
|
} from "@/lib/api";
|
|
|
|
type PortfolioDetailPageProps = {
|
|
params: Promise<{ slug: string }>;
|
|
};
|
|
|
|
function mapApiCategories(
|
|
items: Awaited<ReturnType<typeof getPortfolioCategories>>,
|
|
): PortfolioCategory[] {
|
|
return items.map((item) => ({
|
|
id: String(item.id),
|
|
slug: item.slug,
|
|
nameEn: item.name,
|
|
nameFa: item.nameFa || item.name,
|
|
parentId: item.parentId,
|
|
sortOrder: item.sortOrder ?? 0,
|
|
}));
|
|
}
|
|
|
|
function findRootForCategory(
|
|
categoryId: string | number | null | undefined,
|
|
categories: PortfolioCategory[],
|
|
): PortfolioCategory | undefined {
|
|
if (categoryId == null) return undefined;
|
|
let current = categories.find((c) => c.id === String(categoryId));
|
|
if (!current) return undefined;
|
|
|
|
while (current?.parentId) {
|
|
const parent = categories.find((c) => c.id === String(current?.parentId));
|
|
if (!parent) break;
|
|
current = parent;
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
export async function generateMetadata({
|
|
params,
|
|
}: PortfolioDetailPageProps): Promise<Metadata> {
|
|
const { slug } = await params;
|
|
|
|
try {
|
|
const portfolio = await getPortfolio(slug);
|
|
const title = portfolio.titleFa || portfolio.title;
|
|
return {
|
|
title: `${title} | مشکی`,
|
|
description: portfolio.abstract ?? undefined,
|
|
};
|
|
} catch {
|
|
return { title: "نمونهکار | مشکی" };
|
|
}
|
|
}
|
|
|
|
export default async function PortfolioDetailPage({
|
|
params,
|
|
}: PortfolioDetailPageProps) {
|
|
const { slug } = await params;
|
|
|
|
let portfolio;
|
|
try {
|
|
portfolio = await getPortfolio(slug);
|
|
} catch (error) {
|
|
if (error instanceof ApiNotFoundError) notFound();
|
|
throw error;
|
|
}
|
|
|
|
const cover = getPortfolioCoverImage(portfolio);
|
|
const date = formatFaDate(portfolio.publishedAt);
|
|
const title = portfolio.titleFa || portfolio.title;
|
|
|
|
let allCategories: PortfolioCategory[] = topPortfolioCategories;
|
|
try {
|
|
allCategories = mapApiCategories(await getPortfolioCategories());
|
|
} catch {
|
|
allCategories = topPortfolioCategories;
|
|
}
|
|
|
|
const categoryFa = categoryLabelFa(portfolio.categoryName, allCategories);
|
|
const rootCategory =
|
|
findRootForCategory(portfolio.categoryId, allCategories) ??
|
|
findPortfolioCategory(portfolio.categoryName, getRootCategories(allCategories));
|
|
const gallery = (portfolio.gallery ?? []).filter(
|
|
(item) => item.url !== cover,
|
|
);
|
|
|
|
const titleEn =
|
|
portfolio.titleEn ??
|
|
(portfolio.titleFa && portfolio.title !== portfolio.titleFa
|
|
? portfolio.title
|
|
: null);
|
|
const categoryHref = rootCategory
|
|
? portfolioCategoryHref(rootCategory.slug)
|
|
: "/portfolio";
|
|
|
|
return (
|
|
<ContentPageShell flushTop>
|
|
<article className="pb-20 lg:pb-28">
|
|
<div className="page-title-band mb-8 md:mb-10">
|
|
<div className="relative mx-auto max-w-7xl px-5 pb-4 pt-[calc(var(--header-height)+0.75rem)] lg:px-8 lg:pb-5 lg:pt-[calc(var(--header-height)+1rem)]">
|
|
{titleEn ? (
|
|
<p
|
|
aria-hidden
|
|
className="latin pointer-events-none absolute inset-x-5 top-[calc(var(--header-height)+2.4rem)] z-0 text-[clamp(1.75rem,5.5vw,3.75rem)] font-light uppercase leading-none tracking-[0.05em] text-ink/[0.07] lg:inset-x-8 lg:top-[calc(var(--header-height)+2.6rem)]"
|
|
>
|
|
{titleEn}
|
|
</p>
|
|
) : null}
|
|
|
|
<div className="reveal relative z-10 max-w-3xl">
|
|
<nav
|
|
aria-label="مسیر صفحه"
|
|
className="mb-3 flex flex-wrap items-center gap-1.5 text-xs text-muted"
|
|
>
|
|
<Link
|
|
href="/portfolio"
|
|
className="link-hover transition hover:text-ink"
|
|
>
|
|
نمونهکارها
|
|
</Link>
|
|
{rootCategory || categoryFa ? (
|
|
<>
|
|
<span aria-hidden className="text-line">
|
|
/
|
|
</span>
|
|
<Link
|
|
href={categoryHref}
|
|
className="link-hover transition hover:text-ink"
|
|
>
|
|
{rootCategory?.nameFa ?? categoryFa}
|
|
</Link>
|
|
</>
|
|
) : null}
|
|
<span aria-hidden className="text-line">
|
|
/
|
|
</span>
|
|
<span className="line-clamp-1 text-ink-soft">{title}</span>
|
|
</nav>
|
|
|
|
<h1 className="whitespace-normal text-[clamp(1.35rem,3.8vw,2.5rem)] font-normal leading-tight tracking-tight text-ink">
|
|
{title}
|
|
</h1>
|
|
|
|
{portfolio.abstract ? (
|
|
<p className="mt-2 text-justify text-sm leading-7 text-muted md:text-base">
|
|
{portfolio.abstract}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mx-auto max-w-5xl px-5 lg:px-8">
|
|
<PortfolioGallery
|
|
cover={cover}
|
|
images={gallery.map((item) => ({
|
|
id: item.mediaId,
|
|
url: item.url,
|
|
}))}
|
|
category={categoryFa}
|
|
date={date}
|
|
dateTime={portfolio.publishedAt}
|
|
/>
|
|
|
|
{portfolio.mainTextHtml ? (
|
|
<div className="reveal reveal-delay-2 mx-auto max-w-3xl">
|
|
<HtmlContent html={portfolio.mainTextHtml} />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{portfolio.projectUrl ? (
|
|
<PortfolioProjectLink
|
|
url={portfolio.projectUrl}
|
|
title={title}
|
|
/>
|
|
) : null}
|
|
</article>
|
|
</ContentPageShell>
|
|
);
|
|
}
|