feat(projects): صفحه‌بندی شماره‌دار به‌جای «مشاهده بیشتر» — ۱۲ پروژه در هر صفحه، با برگشت نگاه به ابتدای فهرست

This commit is contained in:
aramesh-site deploy
2026-10-09 16:52:50 +00:00
parent 3a72752598
commit b801867611
2 changed files with 72 additions and 27 deletions
+6
View File
@@ -12,6 +12,9 @@ export const fa = {
switchLang: 'English', switchLang: 'English',
switchLangAria: 'تغییر زبان به انگلیسی', switchLangAria: 'تغییر زبان به انگلیسی',
understood: 'متوجه شدم', understood: 'متوجه شدم',
paginationAria: 'صفحه‌بندی پروژه‌ها',
previousPage: 'صفحهٔ قبل',
nextPage: 'صفحهٔ بعد',
// هدر و ناوبری // هدر و ناوبری
mainNav: 'ناوبری اصلی', mainNav: 'ناوبری اصلی',
@@ -141,6 +144,9 @@ export const en: Dict = {
switchLang: 'فا', switchLang: 'فا',
switchLangAria: 'Switch language to Persian (Farsi)', switchLangAria: 'Switch language to Persian (Farsi)',
understood: 'Got it', understood: 'Got it',
paginationAria: 'Projects pagination',
previousPage: 'Previous page',
nextPage: 'Next page',
mainNav: 'Main navigation', mainNav: 'Main navigation',
mobileNav: 'Mobile navigation', mobileNav: 'Mobile navigation',
+66 -27
View File
@@ -1,8 +1,9 @@
import React, { useMemo, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { PageHero } from '../components/PageHero'; import { PageHero } from '../components/PageHero';
import { Reveal } from '../components/Reveal'; import { Reveal } from '../components/Reveal';
import { ProjectCard } from '../components/ProjectCard'; import { ProjectCard } from '../components/ProjectCard';
import { PremiumButton } from '../components/PremiumButton'; import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { toPersianDigits } from '../utils/digits';
import { ContactSection } from '../components/ContactSection'; import { ContactSection } from '../components/ContactSection';
import { useSite, useStatusLabel } from '../api'; import { useSite, useStatusLabel } from '../api';
import { useLang } from '../i18n/lang'; import { useLang } from '../i18n/lang';
@@ -14,8 +15,10 @@ import { MAP_PROJECTS, type MapProject } from '../data/mapProjects';
export function Projects() { export function Projects() {
const { status_labels, projects: dbProjects } = useSite(); const { status_labels, projects: dbProjects } = useSite();
const { t } = useLang(); const { t, lang } = useLang();
const save = useFieldSaver(); const save = useFieldSaver();
/** لنگر ابتدای فهرست — پس از تغییر صفحه، نگاه کاربر به بالا برمی‌گردد */
const listTopRef = useRef<HTMLDivElement | null>(null);
const saveStatusLabel = (key: string, value: string) => { const saveStatusLabel = (key: string, value: string) => {
const label = status_labels.find((s) => s.key === key); const label = status_labels.find((s) => s.key === key);
@@ -38,9 +41,8 @@ export function Projects() {
// فایل استاتیک فقط وقتی دیتابیس خالی است جانشین می‌شود (محتوای قدیمی). // فایل استاتیک فقط وقتی دیتابیس خالی است جانشین می‌شود (محتوای قدیمی).
const cardProjects = useMemo(() => dbProjects, [dbProjects]); const cardProjects = useMemo(() => dbProjects, [dbProjects]);
// Filtering and Pagination State // صفحه‌بندی شماره‌دار — هر صفحه ۱۲ پروژه
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const itemsPerPage = 12; const itemsPerPage = 12;
// Filter projects by status // Filter projects by status
@@ -48,27 +50,32 @@ export function Projects() {
return cardProjects.filter((p) => p.status === status); return cardProjects.filter((p) => p.status === status);
}, [cardProjects, status]); }, [cardProjects, status]);
// Currently displayed projects // پروژه‌های همین صفحه
const totalPages = Math.max(1, Math.ceil(filteredProjects.length / itemsPerPage));
const displayedProjects = useMemo(() => { const displayedProjects = useMemo(() => {
return filteredProjects.slice(0, page * itemsPerPage); const start = (page - 1) * itemsPerPage;
return filteredProjects.slice(start, start + itemsPerPage);
}, [filteredProjects, page]); }, [filteredProjects, page]);
// با تغییر فیلتر یا کوتاه‌شدن فهرست، صفحه به بازهٔ معتبر برگردد
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
/** رفتن به صفحه و بازگرداندن نگاه کاربر به ابتدای فهرست */
const goToPage = (next: number) => {
const target = Math.min(Math.max(1, next), totalPages);
if (target === page) return;
setPage(target);
listTopRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
// Handle Tab Switch // Handle Tab Switch
const handleTabSwitch = (newStatus: 'done' | 'progress') => { const handleTabSwitch = (newStatus: 'done' | 'progress') => {
setStatus(newStatus); setStatus(newStatus);
setPage(1); // Reset pagination on tab switch setPage(1); // Reset pagination on tab switch
}; };
// Handle Load More
const handleLoadMore = () => {
setIsLoading(true);
// Brief loading state
setTimeout(() => {
setPage((prev) => prev + 1);
setIsLoading(false);
}, 400);
};
const BASE_IMG_URL = "/seed-media/2026/09/"; const BASE_IMG_URL = "/seed-media/2026/09/";
// Resolve a project href from a MapProject entry by matching names — first in the // Resolve a project href from a MapProject entry by matching names — first in the
@@ -112,6 +119,8 @@ export function Projects() {
<section className="bg-ink py-24 lg:py-32" aria-label={t('projectsListAria')}> <section className="bg-ink py-24 lg:py-32" aria-label={t('projectsListAria')}>
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
<div ref={listTopRef} aria-hidden="true" />
{/* دکمه‌های فیلتر */} {/* دکمه‌های فیلتر */}
<div className="flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}> <div className="flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}>
{FILTERS.map((filter, index) => { {FILTERS.map((filter, index) => {
@@ -167,17 +176,47 @@ export function Projects() {
<AddItemButton resource="projects" editHrefBase="/admin/projects" /> <AddItemButton resource="projects" editHrefBase="/admin/projects" />
</div> </div>
{/* Load More Button */} {/* صفحه‌بندی شماره‌دار */}
{displayedProjects.length < filteredProjects.length && ( {totalPages > 1 && (
<div className="mt-16 flex justify-center"> <nav className="mt-16 flex flex-wrap items-center justify-center gap-2" aria-label={t('paginationAria')}>
<PremiumButton <button
onClick={handleLoadMore} type="button"
disabled={isLoading} onClick={() => goToPage(page - 1)}
className="px-10 py-4 w-48" disabled={page === 1}
aria-label={t('previousPage')}
className="flex h-10 items-center gap-1.5 border border-ivory/20 px-4 text-xs font-light text-ivory/75 transition-colors duration-200 hover:border-copper hover:text-copper disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:border-ivory/20 disabled:hover:text-ivory/75"
> >
{isLoading ? 'در حال بارگذاری...' : 'مشاهده بیشتر'} <ChevronRightIcon className="h-3.5 w-3.5 rtl:rotate-0 ltr:rotate-180" />
</PremiumButton> {t('previousPage')}
</div> </button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
<button
key={n}
type="button"
onClick={() => goToPage(n)}
aria-current={n === page ? 'page' : undefined}
className={`flex h-10 w-10 items-center justify-center border text-xs font-bold transition-colors duration-200 ${
n === page
? 'border-copper bg-copper/15 text-copper'
: 'border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
}`}
>
{lang === 'fa' ? toPersianDigits(String(n)) : n}
</button>
))}
<button
type="button"
onClick={() => goToPage(page + 1)}
disabled={page === totalPages}
aria-label={t('nextPage')}
className="flex h-10 items-center gap-1.5 border border-ivory/20 px-4 text-xs font-light text-ivory/75 transition-colors duration-200 hover:border-copper hover:text-copper disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:border-ivory/20 disabled:hover:text-ivory/75"
>
{t('nextPage')}
<ChevronLeftIcon className="h-3.5 w-3.5 rtl:rotate-0 ltr:rotate-180" />
</button>
</nav>
)} )}
</div> </div>
</section> </section>