Files
meshkee/src/app/portfolio/page.tsx
T
Alireza HassaniandCursor 37d8e4be91 Improve portfolio list card layout with four-column grid.
Show the English title as a low-opacity backdrop behind the Farsi title and move the category label below it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 18:07:48 +03:30

226 lines
7.8 KiB
TypeScript

import type { Metadata } from "next";
import Link from "next/link";
import { ContentPageShell } from "@/components/ContentPageShell";
import { Pagination } from "@/components/Pagination";
import {
categoryLabelFa,
findPortfolioCategory,
getDescendantCategoryIds,
getRootCategories,
portfolioCategoryHref,
topPortfolioCategories,
type PortfolioCategory,
} from "@/data/portfolioCategories";
import {
getPortfolioCategories,
getPortfolioCoverImage,
getPortfolios,
getPortfoliosByCategory,
} from "@/lib/api";
export const metadata: Metadata = {
title: "نمونه‌کارها",
description: "نمونه پروژه‌ها و کارهای انجام‌شده توسط تیم مشکی.",
alternates: { canonical: "/portfolio" },
};
const PAGE_SIZE = 12;
type PortfolioPageProps = {
searchParams: Promise<{ page?: string; category?: 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,
}));
}
export default async function PortfolioListPage({
searchParams,
}: PortfolioPageProps) {
const { page: pageParam, category: categoryParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
let allCategories: PortfolioCategory[] = topPortfolioCategories;
try {
allCategories = mapApiCategories(await getPortfolioCategories());
} catch {
allCategories = topPortfolioCategories;
}
const rootCategories = getRootCategories(allCategories);
const activeCategory =
findPortfolioCategory(categoryParam, allCategories) ??
findPortfolioCategory(categoryParam, rootCategories);
let items: Awaited<ReturnType<typeof getPortfolios>>["items"] = [];
let total = 0;
try {
if (activeCategory) {
const data = await getPortfoliosByCategory({
categoryId: activeCategory.id,
categoryIds: getDescendantCategoryIds(allCategories, activeCategory.id),
page,
pageSize: PAGE_SIZE,
});
items = data.items;
total = data.total;
} else {
const data = await getPortfolios({ page, pageSize: PAGE_SIZE });
items = data.items;
total = data.total;
}
} catch {
items = [];
total = 0;
}
const paginationQuery = activeCategory
? { category: activeCategory.slug }
: undefined;
const filterCategories =
rootCategories.length > 0 ? rootCategories : topPortfolioCategories;
return (
<ContentPageShell>
<section className="pb-20 lg:pb-28">
<div className="mx-auto max-w-7xl px-5 lg:px-8">
<div className="reveal mb-10 max-w-2xl md:mb-12">
<h1 className="whitespace-normal text-[clamp(1.35rem,3.6vw,2.75rem)] font-normal tracking-tight text-ink">
نمونه‌کارها
</h1>
<p className="latin title-en text-muted">
Portfolio
</p>
<p className="mt-5 text-sm leading-8 text-muted md:text-base">
نگاهی به پروژه‌ها و کارهای انجام‌شده توسط تیم مشکی.
</p>
</div>
<div className="reveal mb-10 flex flex-wrap gap-2 md:mb-12">
<Link
href="/portfolio"
className={`rounded-full border px-4 py-2 text-sm transition ${
!activeCategory
? "border-ink bg-ink text-surface"
: "border-line/70 text-ink-soft hover:border-ink/30 hover:text-ink"
}`}
>
همه
</Link>
{filterCategories.map((category) => {
const active = activeCategory?.id === category.id;
return (
<Link
key={category.id}
href={portfolioCategoryHref(category.slug)}
className={`rounded-full border px-4 py-2 text-sm transition ${
active
? "border-ink bg-ink text-surface"
: "border-line/70 text-ink-soft hover:border-ink/30 hover:text-ink"
}`}
>
{category.nameFa}
</Link>
);
})}
</div>
{items.length === 0 ? (
<p className="text-sm text-muted">
نمونه‌کاری برای نمایش وجود ندارد.
</p>
) : (
<div
key={`${activeCategory?.slug ?? "all"}-${page}`}
className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4"
>
{items.map((item, index) => {
const image = getPortfolioCoverImage(item);
const categoryFa = categoryLabelFa(
item.categoryName,
allCategories,
);
const titleFa = item.titleFa || item.title;
const titleEn =
item.titleEn ??
(item.titleFa && item.title !== item.titleFa
? item.title
: null);
return (
<article
key={item.id}
className={`reveal group overflow-hidden rounded-2xl border border-line/70 ${
index % 4 === 1
? "reveal-delay-1"
: index % 4 === 2
? "reveal-delay-2"
: index % 4 === 3
? "reveal-delay-3"
: ""
}`}
>
<Link href={`/portfolio/${item.slug}`} className="block">
<div className="relative overflow-hidden bg-paper-deep">
{image ? (
<img
src={image}
alt=""
className="h-auto w-full transition duration-700 group-hover:scale-105"
/>
) : null}
</div>
<div className="px-4 py-4">
<div className="relative mb-2 min-h-[3.25rem] overflow-hidden">
{titleEn ? (
<p
aria-hidden
className="latin pointer-events-none absolute inset-x-0 top-1/2 z-0 line-clamp-2 -translate-y-1/2 text-[clamp(1rem,2.2vw,1.35rem)] font-light uppercase leading-none tracking-[0.12em] text-ink/[0.07]"
>
{titleEn}
</p>
) : null}
<h2 className="group-text-hover relative z-10 line-clamp-2 whitespace-normal text-base font-normal leading-7 text-ink">
{titleFa}
</h2>
</div>
{categoryFa ? (
<p className="text-[11px] text-muted">{categoryFa}</p>
) : null}
{item.abstract ? (
<p className="mt-2 line-clamp-3 text-sm leading-6 text-muted">
{item.abstract}
</p>
) : null}
</div>
</Link>
</article>
);
})}
</div>
)}
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={total}
basePath="/portfolio"
query={paginationQuery}
/>
</div>
</section>
</ContentPageShell>
);
}