Files
meshkee/src/app/portfolio/[slug]/page.tsx
T
Alireza HassaniandCursor 908d86939c Polish portfolio, blog, and site chrome for clearer browsing.
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>
2026-08-23 22:17:25 +03:30

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