feat(projects): صفحهبندی شمارهدار بهجای «مشاهده بیشتر» — ۱۲ پروژه در هر صفحه، با برگشت نگاه به ابتدای فهرست
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user