Ship local assets, portfolio filters, favicon, and UI polish.
Host CDN images locally, add top-level portfolio category filtering with nav sublinks, refresh the Meshkee favicon, and tighten title/gallery/footer presentation across the site. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
112e5eb46d
commit
4e635bb038
+122
-25
@@ -2,46 +2,104 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ContentPageShell } from "@/components/ContentPageShell";
|
||||
import { Pagination } from "@/components/Pagination";
|
||||
import { getPortfolioCoverImage, getPortfolios } from "@/lib/api";
|
||||
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: "نمونهکارها | مشکی",
|
||||
title: "نمونهکارها",
|
||||
description: "نمونه پروژهها و کارهای انجامشده توسط تیم مشکی.",
|
||||
alternates: { canonical: "/portfolio" },
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
type PortfolioPageProps = {
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
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 } = await searchParams;
|
||||
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 {
|
||||
const data = await getPortfolios({ page, pageSize: PAGE_SIZE });
|
||||
items = data.items;
|
||||
total = data.total;
|
||||
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-12 max-w-2xl md:mb-16">
|
||||
<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 mt-3 text-xs uppercase tracking-[0.2em] text-muted">
|
||||
<p className="latin title-en text-muted">
|
||||
Portfolio
|
||||
</p>
|
||||
<p className="mt-5 text-sm leading-8 text-muted md:text-base">
|
||||
@@ -49,19 +107,55 @@ export default async function PortfolioListPage({
|
||||
</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={page} className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div
|
||||
key={`${activeCategory?.slug ?? "all"}-${page}`}
|
||||
className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
const image = getPortfolioCoverImage(item);
|
||||
const categoryFa = categoryLabelFa(
|
||||
item.categoryName,
|
||||
allCategories,
|
||||
);
|
||||
|
||||
return (
|
||||
<article
|
||||
key={item.id}
|
||||
className={`reveal group ${
|
||||
className={`reveal group overflow-hidden rounded-2xl border border-line/70 ${
|
||||
index % 3 === 1
|
||||
? "reveal-delay-1"
|
||||
: index % 3 === 2
|
||||
@@ -70,7 +164,7 @@ export default async function PortfolioListPage({
|
||||
}`}
|
||||
>
|
||||
<Link href={`/portfolio/${item.slug}`} className="block">
|
||||
<div className="relative mb-4 overflow-hidden rounded-2xl bg-paper-deep">
|
||||
<div className="relative overflow-hidden bg-paper-deep">
|
||||
{image ? (
|
||||
<img
|
||||
src={image}
|
||||
@@ -79,19 +173,21 @@ export default async function PortfolioListPage({
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{item.categoryName ? (
|
||||
<p className="latin mb-2 text-[10px] uppercase tracking-widest text-muted">
|
||||
{item.categoryName}
|
||||
</p>
|
||||
) : null}
|
||||
<h2 className="group-text-hover mb-2 line-clamp-2 whitespace-normal text-lg font-normal leading-8 text-ink">
|
||||
{item.titleFa || item.title}
|
||||
</h2>
|
||||
{item.abstract ? (
|
||||
<p className="line-clamp-3 text-sm leading-7 text-muted">
|
||||
{item.abstract}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="px-4 py-4">
|
||||
{categoryFa ? (
|
||||
<p className="mb-2 text-[11px] text-muted">
|
||||
{categoryFa}
|
||||
</p>
|
||||
) : null}
|
||||
<h2 className="group-text-hover mb-0.5 line-clamp-2 whitespace-normal text-lg font-normal leading-7 text-ink">
|
||||
{item.titleFa || item.title}
|
||||
</h2>
|
||||
{item.abstract ? (
|
||||
<p className="line-clamp-3 text-sm leading-6 text-muted">
|
||||
{item.abstract}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
</article>
|
||||
);
|
||||
@@ -104,6 +200,7 @@ export default async function PortfolioListPage({
|
||||
pageSize={PAGE_SIZE}
|
||||
total={total}
|
||||
basePath="/portfolio"
|
||||
query={paginationQuery}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user