mirror of
https://git.meshkee.com/Meshkee-Websites/raoufi-group.git
synced 2026-08-11 20:29:33 +04:30
new create
This commit is contained in:
+4
-1
@@ -5,10 +5,13 @@ const nextConfig: NextConfig = {
|
||||
formats: ["image/avif", "image/webp"],
|
||||
// Every quality value used across the page. Required from Next.js 16 on.
|
||||
qualities: [70, 72, 75, 80, 82, 88, 90],
|
||||
// Portfolio media served live from the meshkee API.
|
||||
// Portfolio media served live from the meshkee API. The storage host has
|
||||
// already changed once mid-project (parminstorage.ir -> parspack.net) —
|
||||
// keep both around since either could be what a given item still points to.
|
||||
remotePatterns: [
|
||||
{ protocol: "https", hostname: "meshkee-storage.sas.amin.parminstorage.ir" },
|
||||
{ protocol: "https", hostname: "cdn.meshkee.com" },
|
||||
{ protocol: "https", hostname: "c804387.parspack.net" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { NewsListing } from "@/components/news/NewsListing";
|
||||
import { Editorial } from "@/components/shared/Editorial";
|
||||
import { PageCta } from "@/components/shared/PageCta";
|
||||
import { PageHero } from "@/components/shared/PageHero";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import heroImage from "@/assets/images/hero/hero-2.jpg";
|
||||
import heroImageMobile from "@/assets/images/hero/hero-2-mobile.jpg";
|
||||
import { blog, blogListingPage, newsAndBlogPage } from "@/data/site";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "بلاگ",
|
||||
description: "فهرست کامل مقالات منتشرشدهی گروه ساختمانی رئوفی.",
|
||||
};
|
||||
|
||||
export default async function BlogListPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}) {
|
||||
const { page: pageParam } = await searchParams;
|
||||
const page = Math.max(1, Number(pageParam) || 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
|
||||
<main>
|
||||
<PageHero
|
||||
image={heroImage}
|
||||
imageMobile={heroImageMobile}
|
||||
caption={blogListingPage.hero.caption}
|
||||
title={blogListingPage.hero.title}
|
||||
breadcrumb={blogListingPage.hero.breadcrumb}
|
||||
/>
|
||||
|
||||
<section className="bg-paper">
|
||||
<Editorial
|
||||
caption={blogListingPage.overview.caption}
|
||||
captionLatin={blogListingPage.overview.captionLatin}
|
||||
title={blogListingPage.overview.title}
|
||||
body={blogListingPage.overview.body}
|
||||
compact
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="bg-sand pb-16 lg:pb-24">
|
||||
<NewsListing
|
||||
items={blog}
|
||||
page={page}
|
||||
basePath="/blog"
|
||||
emptyMessage={blogListingPage.emptyMessage}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<PageCta
|
||||
caption={newsAndBlogPage.cta.caption}
|
||||
title={newsAndBlogPage.cta.title}
|
||||
action={newsAndBlogPage.cta.action}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import heroImage from "@/assets/images/hero/hero-1.jpg";
|
||||
import heroImageMobile from "@/assets/images/hero/hero-1-mobile.jpg";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
import { NewsCarousel } from "@/components/news/NewsCarousel";
|
||||
import { Editorial } from "@/components/shared/Editorial";
|
||||
import { PageCta } from "@/components/shared/PageCta";
|
||||
import { PageHero } from "@/components/shared/PageHero";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { blog, news, newsAndBlogPage } from "@/data/site";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "مقالات و اخبار",
|
||||
description: "آخرین اخبار، رویدادها و مقالات گروه ساختمانی رئوفی.",
|
||||
};
|
||||
|
||||
export default function NewsAndBlogPage() {
|
||||
const hasNews = news.length > 0;
|
||||
const hasBlog = blog.length > 0;
|
||||
const hasNothing = !hasNews && !hasBlog;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
|
||||
<main>
|
||||
<PageHero
|
||||
image={heroImage}
|
||||
imageMobile={heroImageMobile}
|
||||
caption={newsAndBlogPage.hero.caption}
|
||||
title={newsAndBlogPage.hero.title}
|
||||
breadcrumb={newsAndBlogPage.hero.breadcrumb}
|
||||
/>
|
||||
|
||||
<section className="bg-paper">
|
||||
<Editorial
|
||||
caption={newsAndBlogPage.overview.caption}
|
||||
captionLatin={newsAndBlogPage.overview.captionLatin}
|
||||
title={newsAndBlogPage.overview.title}
|
||||
body={newsAndBlogPage.overview.body}
|
||||
compact
|
||||
/>
|
||||
</section>
|
||||
|
||||
{hasNothing && (
|
||||
<section className="bg-sand py-16 lg:py-24">
|
||||
<p className="text-center text-muted">{newsAndBlogPage.emptyMessage}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hasNews && (
|
||||
<section className="bg-sand py-16 lg:py-24">
|
||||
<div className="mx-auto max-w-[1400px] px-6 lg:px-12">
|
||||
<Reveal>
|
||||
<p className="kicker mb-10 justify-center">
|
||||
{newsAndBlogPage.news.caption}
|
||||
<span className="latin">{newsAndBlogPage.news.captionLatin}</span>
|
||||
</p>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<NewsCarousel items={news} basePath="/news" />
|
||||
|
||||
<div className="mx-auto mt-10 max-w-[1400px] px-6 text-center lg:px-12">
|
||||
<Link
|
||||
href="/news"
|
||||
className="inline-block rounded-full bg-gold px-9 py-3.5 text-white transition-colors duration-300 hover:bg-ink"
|
||||
>
|
||||
{newsAndBlogPage.news.action}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hasBlog && (
|
||||
<section className={`py-16 lg:py-24 ${hasNews ? "bg-paper" : "bg-sand"}`}>
|
||||
<div className="mx-auto max-w-[1400px] px-6 lg:px-12">
|
||||
<Reveal>
|
||||
<p className="kicker mb-10 justify-center">
|
||||
{newsAndBlogPage.blog.caption}
|
||||
<span className="latin">{newsAndBlogPage.blog.captionLatin}</span>
|
||||
</p>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<NewsCarousel items={blog} basePath="/blog" />
|
||||
|
||||
<div className="mx-auto mt-10 max-w-[1400px] px-6 text-center lg:px-12">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-block rounded-full bg-gold px-9 py-3.5 text-white transition-colors duration-300 hover:bg-ink"
|
||||
>
|
||||
{newsAndBlogPage.blog.action}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<PageCta
|
||||
caption={newsAndBlogPage.cta.caption}
|
||||
title={newsAndBlogPage.cta.title}
|
||||
action={newsAndBlogPage.cta.action}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { NewsListing } from "@/components/news/NewsListing";
|
||||
import { Editorial } from "@/components/shared/Editorial";
|
||||
import { PageCta } from "@/components/shared/PageCta";
|
||||
import { PageHero } from "@/components/shared/PageHero";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import heroImage from "@/assets/images/hero/hero-1.jpg";
|
||||
import heroImageMobile from "@/assets/images/hero/hero-1-mobile.jpg";
|
||||
import { news, newsAndBlogPage, newsListingPage } from "@/data/site";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "اخبار",
|
||||
description: "فهرست کامل اخبار و رویدادهای منتشرشدهی گروه ساختمانی رئوفی.",
|
||||
};
|
||||
|
||||
export default async function NewsListPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}) {
|
||||
const { page: pageParam } = await searchParams;
|
||||
const page = Math.max(1, Number(pageParam) || 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
|
||||
<main>
|
||||
<PageHero
|
||||
image={heroImage}
|
||||
imageMobile={heroImageMobile}
|
||||
caption={newsListingPage.hero.caption}
|
||||
title={newsListingPage.hero.title}
|
||||
breadcrumb={newsListingPage.hero.breadcrumb}
|
||||
/>
|
||||
|
||||
<section className="bg-paper">
|
||||
<Editorial
|
||||
caption={newsListingPage.overview.caption}
|
||||
captionLatin={newsListingPage.overview.captionLatin}
|
||||
title={newsListingPage.overview.title}
|
||||
body={newsListingPage.overview.body}
|
||||
compact
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="bg-sand pb-16 lg:pb-24">
|
||||
<NewsListing
|
||||
items={news}
|
||||
page={page}
|
||||
basePath="/news"
|
||||
emptyMessage={newsListingPage.emptyMessage}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<PageCta
|
||||
caption={newsAndBlogPage.cta.caption}
|
||||
title={newsAndBlogPage.cta.title}
|
||||
action={newsAndBlogPage.cta.action}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export function News() {
|
||||
|
||||
<Reveal delay={140}>
|
||||
<Link
|
||||
href="/news"
|
||||
href="/news-and-blog"
|
||||
className="group flex items-center gap-3 border-b border-ink/20 pb-2 text-ink transition-colors duration-300 hover:border-gold hover:text-gold"
|
||||
>
|
||||
آرشیو مقالات و اخبار
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
import type { NewsItem } from "@/data/site";
|
||||
|
||||
/** Horizontal snap-scroll rail of news/blog cards, matching the site's other carousels. */
|
||||
export function NewsCarousel({ items, basePath }: { items: NewsItem[]; basePath: string }) {
|
||||
const trackRef = useRef<HTMLUListElement>(null);
|
||||
const [atStart, setAtStart] = useState(true);
|
||||
const [atEnd, setAtEnd] = useState(false);
|
||||
|
||||
const syncEdges = useCallback(() => {
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
const travelled = Math.abs(el.scrollLeft);
|
||||
const max = el.scrollWidth - el.clientWidth;
|
||||
setAtStart(travelled < 8);
|
||||
setAtEnd(travelled >= max - 8);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
syncEdges();
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
el.addEventListener("scroll", syncEdges, { passive: true });
|
||||
window.addEventListener("resize", syncEdges);
|
||||
return () => {
|
||||
el.removeEventListener("scroll", syncEdges);
|
||||
window.removeEventListener("resize", syncEdges);
|
||||
};
|
||||
}, [syncEdges]);
|
||||
|
||||
const step = (direction: "next" | "prev") => {
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
const card = el.querySelector("li");
|
||||
const amount = card ? card.getBoundingClientRect().width + 24 : el.clientWidth * 0.8;
|
||||
el.scrollBy({ left: direction === "next" ? -amount : amount, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const showArrows = items.length > 3;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showArrows && (
|
||||
<div className="mx-auto mb-6 flex max-w-[1400px] justify-end gap-2 px-6 lg:px-12">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step("prev")}
|
||||
disabled={atStart}
|
||||
aria-label="اخبار قبلی"
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full border border-line text-ink transition-colors duration-300 hover:border-gold hover:bg-gold hover:text-white disabled:pointer-events-none disabled:opacity-30"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
|
||||
<path d="M5 12h14m0 0-6-6m6 6-6 6" stroke="currentColor" strokeWidth="1.6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step("next")}
|
||||
disabled={atEnd}
|
||||
aria-label="اخبار بعدی"
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full border border-line text-ink transition-colors duration-300 hover:border-gold hover:bg-gold hover:text-white disabled:pointer-events-none disabled:opacity-30"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
|
||||
<path d="M19 12H5m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="1.6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul
|
||||
ref={trackRef}
|
||||
className="no-scrollbar flex snap-x snap-mandatory gap-6 overflow-x-auto scroll-smooth px-6 pb-2 lg:gap-8 lg:px-12"
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<li
|
||||
key={item.slug}
|
||||
className="w-[82vw] shrink-0 snap-start sm:w-[52vw] lg:w-[34vw] xl:w-[26rem]"
|
||||
>
|
||||
<Reveal delay={(i % 4) * 80}>
|
||||
<Link href={`${basePath}/${item.slug}`} className="group block h-full">
|
||||
<article className="flex h-full flex-col bg-paper">
|
||||
<div className="relative aspect-[16/11] overflow-hidden">
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={item.title}
|
||||
fill
|
||||
quality={80}
|
||||
sizes="(max-width: 640px) 82vw, (max-width: 1024px) 52vw, 26rem"
|
||||
placeholder="blur"
|
||||
loading={i < 2 ? "eager" : "lazy"}
|
||||
className="object-cover transition-transform duration-[1100ms] ease-out-soft group-hover:scale-[1.06]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-ink/0 transition-colors duration-500 group-hover:bg-ink/20" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col p-7 lg:p-8">
|
||||
<time className="flex items-center gap-2.5 text-[0.8rem] text-muted">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5 text-gold">
|
||||
<rect x="3.5" y="5" width="17" height="15.5" rx="2" stroke="currentColor" strokeWidth="1.4" />
|
||||
<path d="M3.5 10h17M8 3.5V6M16 3.5V6" stroke="currentColor" strokeWidth="1.4" />
|
||||
</svg>
|
||||
{item.date}
|
||||
</time>
|
||||
|
||||
<h3 className="mt-4 text-[1.1rem] leading-[1.75] font-bold text-ink transition-colors duration-300 group-hover:text-gold">
|
||||
{item.title}
|
||||
</h3>
|
||||
|
||||
<span className="mt-auto flex items-center gap-2 pt-7 text-sm text-muted transition-colors duration-300 group-hover:text-gold">
|
||||
ادامه مطلب
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5 transition-transform duration-300 group-hover:-translate-x-1">
|
||||
<path d="M19 12H5m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="1.8" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
</Reveal>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
import { Pagination } from "@/components/shared/Pagination";
|
||||
import type { NewsItem } from "@/data/site";
|
||||
|
||||
const PAGE_SIZE = 9;
|
||||
|
||||
/**
|
||||
* Grid + pagination for a full news or blog archive. Used by both /news and
|
||||
* /blog so the two keep identical rules — only the item list, base path and
|
||||
* empty-state copy differ between them.
|
||||
*/
|
||||
export function NewsListing({
|
||||
items,
|
||||
page,
|
||||
basePath,
|
||||
emptyMessage,
|
||||
}: {
|
||||
items: NewsItem[];
|
||||
page: number;
|
||||
basePath: string;
|
||||
emptyMessage: string;
|
||||
}) {
|
||||
const totalPages = Math.max(1, Math.ceil(items.length / PAGE_SIZE));
|
||||
const currentPage = Math.min(totalPages, Math.max(1, page));
|
||||
const pageItems = items.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1400px] px-6 lg:px-12">
|
||||
{pageItems.length === 0 ? (
|
||||
<p className="py-16 text-center text-muted">{emptyMessage}</p>
|
||||
) : (
|
||||
<ul className="grid gap-8 md:grid-cols-3 lg:gap-10">
|
||||
{pageItems.map((item, i) => (
|
||||
<Reveal as="li" key={item.slug} delay={(i % 6) * 70}>
|
||||
<Link href={`${basePath}/${item.slug}`} className="group block h-full">
|
||||
<article className="flex h-full flex-col bg-paper">
|
||||
<div className="relative aspect-[16/11] overflow-hidden">
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={item.title}
|
||||
fill
|
||||
quality={80}
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
placeholder="blur"
|
||||
loading={i < 3 ? "eager" : "lazy"}
|
||||
className="object-cover transition-transform duration-[1100ms] ease-out-soft group-hover:scale-[1.07]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-ink/0 transition-colors duration-500 group-hover:bg-ink/20" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col p-7 lg:p-8">
|
||||
<time className="flex items-center gap-2.5 text-[0.8rem] text-muted">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5 text-gold">
|
||||
<rect x="3.5" y="5" width="17" height="15.5" rx="2" stroke="currentColor" strokeWidth="1.4" />
|
||||
<path d="M3.5 10h17M8 3.5V6M16 3.5V6" stroke="currentColor" strokeWidth="1.4" />
|
||||
</svg>
|
||||
{item.date}
|
||||
</time>
|
||||
|
||||
<h3 className="mt-4 text-[1.22rem] leading-[1.75] font-bold text-ink transition-colors duration-300 group-hover:text-gold">
|
||||
{item.title}
|
||||
</h3>
|
||||
|
||||
<span className="mt-auto flex items-center gap-2 pt-7 text-sm text-muted transition-colors duration-300 group-hover:text-gold">
|
||||
ادامه مطلب
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5 transition-transform duration-300 group-hover:-translate-x-1">
|
||||
<path d="M19 12H5m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="1.8" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
</Reveal>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Pagination
|
||||
page={currentPage}
|
||||
totalPages={totalPages}
|
||||
hrefFor={(p) => `${basePath}?page=${p}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
import { Pagination } from "@/components/shared/Pagination";
|
||||
import type { DisplayProject, PortfolioCategory } from "@/lib/api";
|
||||
|
||||
const ALL_ID = "__all__";
|
||||
const PAGE_SIZE = 9;
|
||||
|
||||
/**
|
||||
* Category tabs above a grid — no search, click a tab to filter in place.
|
||||
@@ -25,6 +27,7 @@ export function PortfolioBrowser({
|
||||
categories: PortfolioCategory[];
|
||||
}) {
|
||||
const [activeId, setActiveId] = useState(ALL_ID);
|
||||
const [page, setPage] = useState(1);
|
||||
const showTabs = categories.length > 1;
|
||||
|
||||
const filtered = useMemo(
|
||||
@@ -32,6 +35,12 @@ export function PortfolioBrowser({
|
||||
[projects, activeId],
|
||||
);
|
||||
|
||||
// Switching category with a stale page number could land on an empty page.
|
||||
useEffect(() => setPage(1), [activeId]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const pageItems = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showTabs && (
|
||||
@@ -84,8 +93,8 @@ export function PortfolioBrowser({
|
||||
پروژهای در این دسته یافت نشد.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mx-auto grid max-w-[1400px] gap-6 px-6 pb-24 sm:grid-cols-2 lg:grid-cols-3 lg:gap-8 lg:px-12 lg:pb-32">
|
||||
{filtered.map((project, i) => (
|
||||
<ul className="mx-auto grid max-w-[1400px] gap-6 px-6 sm:grid-cols-2 lg:grid-cols-3 lg:gap-8 lg:px-12">
|
||||
{pageItems.map((project, i) => (
|
||||
<Reveal as="li" key={project.slug} delay={(i % 6) * 70}>
|
||||
<Link href={`/portfolios/${project.slug}`} className="group block">
|
||||
<div className="relative aspect-[3/4] overflow-hidden bg-ink">
|
||||
@@ -126,6 +135,10 @@ export function PortfolioBrowser({
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="mx-auto max-w-[1400px] px-6 pb-24 lg:px-12 lg:pb-32">
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ export function ProjectGalleryLightbox({ images, alt }: { images: string[]; alt:
|
||||
showPrev();
|
||||
}}
|
||||
aria-label="تصویر قبلی"
|
||||
className="absolute end-3 top-1/2 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 hover:text-white sm:end-6"
|
||||
className="absolute start-3 top-1/2 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 hover:text-white sm:start-6"
|
||||
>
|
||||
<ChevronIcon direction="prev" className="h-6 w-6" />
|
||||
</button>
|
||||
@@ -126,7 +126,7 @@ export function ProjectGalleryLightbox({ images, alt }: { images: string[]; alt:
|
||||
showNext();
|
||||
}}
|
||||
aria-label="تصویر بعدی"
|
||||
className="absolute start-3 top-1/2 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 hover:text-white sm:start-6"
|
||||
className="absolute end-3 top-1/2 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 hover:text-white sm:end-6"
|
||||
>
|
||||
<ChevronIcon direction="next" className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import Link from "next/link";
|
||||
|
||||
function ChevronIcon({ direction, className }: { direction: "next" | "prev"; className?: string }) {
|
||||
const d = direction === "next" ? "M19 12H5m0 0 6-6m-6 6 6 6" : "M5 12h14m0 0-6-6m6 6-6 6";
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
|
||||
<path d={d} stroke="currentColor" strokeWidth="1.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type PaginationProps = {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
} & (
|
||||
| { hrefFor: (page: number) => string; onPageChange?: never }
|
||||
| { onPageChange: (page: number) => void; hrefFor?: never }
|
||||
);
|
||||
|
||||
/**
|
||||
* Numbered pagination, RTL-aware. Works two ways: give it `hrefFor` for a
|
||||
* plain server-rendered listing (real links, works without JS), or
|
||||
* `onPageChange` for a page that already manages its own list client-side
|
||||
* (e.g. alongside a category filter).
|
||||
*
|
||||
* Renders nothing when everything already fits on one page — there's no
|
||||
* reason to show page controls for content that doesn't need paging yet.
|
||||
*/
|
||||
export function Pagination({ page, totalPages, hrefFor, onPageChange }: PaginationProps) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const prevPage = Math.max(1, page - 1);
|
||||
const nextPage = Math.min(totalPages, page + 1);
|
||||
const pages = Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
|
||||
const controlClass = (current: boolean, disabled = false) =>
|
||||
`flex h-11 min-w-11 items-center justify-center rounded-full px-3 text-sm transition-colors duration-300 ${
|
||||
current
|
||||
? "bg-gold text-white"
|
||||
: disabled
|
||||
? "pointer-events-none text-muted/30"
|
||||
: "text-ink hover:bg-sand"
|
||||
}`;
|
||||
|
||||
const renderItem = (target: number, content: React.ReactNode, ariaLabel: string, current = false) => {
|
||||
if (hrefFor) {
|
||||
return (
|
||||
<Link
|
||||
key={ariaLabel}
|
||||
href={hrefFor(target)}
|
||||
aria-label={ariaLabel}
|
||||
aria-current={current}
|
||||
className={controlClass(current)}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={ariaLabel}
|
||||
type="button"
|
||||
onClick={() => onPageChange(target)}
|
||||
aria-label={ariaLabel}
|
||||
aria-current={current}
|
||||
className={controlClass(current)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav aria-label="صفحهبندی" className="mt-14 flex items-center justify-center gap-2 lg:mt-20">
|
||||
{page === 1 ? (
|
||||
<span className={controlClass(false, true)}>
|
||||
<ChevronIcon direction="prev" className="h-4 w-4" />
|
||||
</span>
|
||||
) : (
|
||||
renderItem(prevPage, <ChevronIcon direction="prev" className="h-4 w-4" />, "صفحه قبل")
|
||||
)}
|
||||
|
||||
{pages.map((p) => renderItem(p, p, `صفحه ${p}`, p === page))}
|
||||
|
||||
{page === totalPages ? (
|
||||
<span className={controlClass(false, true)}>
|
||||
<ChevronIcon direction="next" className="h-4 w-4" />
|
||||
</span>
|
||||
) : (
|
||||
renderItem(nextPage, <ChevronIcon direction="next" className="h-4 w-4" />, "صفحه بعد")
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
+95
-1
@@ -31,7 +31,7 @@ export const nav = [
|
||||
{ label: "صفحه اصلی", href: "/" },
|
||||
{ label: "خدمات ما", href: "/our-services" },
|
||||
{ label: "پروژه ها", href: "/portfolios" },
|
||||
{ label: "مقالات و اخبار", href: "/news" },
|
||||
{ label: "مقالات و اخبار", href: "/news-and-blog" },
|
||||
{ label: "درباره ما", href: "/aboutus" },
|
||||
{ label: "تماس با ما", href: "/contactus" },
|
||||
] as const;
|
||||
@@ -294,6 +294,97 @@ export const projectVideos: Record<string, string> = {
|
||||
"item-old-589": "https://www.aparat.com/v/dnem1b3",
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* News & blog
|
||||
*
|
||||
* The meshkee API has a real `/blogs` endpoint with a `type` filter
|
||||
* (news/article/blog), but it currently returns zero items and the docs
|
||||
* don't spell out the item's field names — nothing to verify a live
|
||||
* integration against yet. `news` below is real, published content (scraped
|
||||
* from the live site, not invented); `blog` is genuinely empty because the
|
||||
* client has no blog content anywhere yet, not because it was left out.
|
||||
* Wire both up to live `/blogs?type=...` fetches once there's real content
|
||||
* to check the field shape against.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
export const newsAndBlogPage = {
|
||||
hero: {
|
||||
caption: "NEWS & MEDIA",
|
||||
title: "مقالات و اخبار",
|
||||
breadcrumb: [
|
||||
{ label: "صفحه اصلی", href: "/" },
|
||||
{ label: "مقالات و اخبار", href: "/news-and-blog" },
|
||||
],
|
||||
},
|
||||
|
||||
overview: {
|
||||
caption: "مقالات و اخبار",
|
||||
captionLatin: "NEWS & MEDIA",
|
||||
title: "آخرین رویدادهای گروه رئوفی",
|
||||
body: [
|
||||
"از افتتاحیهی پروژهها تا مراسم و بروزرسانیهای ساختوساز؛ در ادامه تازهترین اخبار و مقالات منتشرشده را میبینید.",
|
||||
],
|
||||
},
|
||||
|
||||
news: {
|
||||
caption: "اخبار",
|
||||
captionLatin: "NEWS",
|
||||
action: "مشاهده همه اخبار",
|
||||
},
|
||||
|
||||
blog: {
|
||||
caption: "بلاگ",
|
||||
captionLatin: "BLOG",
|
||||
action: "مشاهده همه مقالات",
|
||||
},
|
||||
|
||||
emptyMessage: "هیچ مقاله یا خبری ثبت نشده است.",
|
||||
|
||||
cta: {
|
||||
caption: "STAY UPDATED",
|
||||
title: "از پروژههای تازهی رئوفی باخبر شوید",
|
||||
action: "تماس با ما",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const newsListingPage = {
|
||||
hero: {
|
||||
caption: "NEWS",
|
||||
title: "اخبار",
|
||||
breadcrumb: [
|
||||
{ label: "صفحه اصلی", href: "/" },
|
||||
{ label: "مقالات و اخبار", href: "/news-and-blog" },
|
||||
{ label: "اخبار", href: "/news" },
|
||||
],
|
||||
},
|
||||
overview: {
|
||||
caption: "اخبار",
|
||||
captionLatin: "NEWS",
|
||||
title: "آرشیو اخبار",
|
||||
body: ["فهرست کامل اخبار و رویدادهای منتشرشدهی گروه رئوفی."],
|
||||
},
|
||||
emptyMessage: "هنوز خبری منتشر نشده است.",
|
||||
} as const;
|
||||
|
||||
export const blogListingPage = {
|
||||
hero: {
|
||||
caption: "BLOG",
|
||||
title: "بلاگ",
|
||||
breadcrumb: [
|
||||
{ label: "صفحه اصلی", href: "/" },
|
||||
{ label: "مقالات و اخبار", href: "/news-and-blog" },
|
||||
{ label: "بلاگ", href: "/blog" },
|
||||
],
|
||||
},
|
||||
overview: {
|
||||
caption: "بلاگ",
|
||||
captionLatin: "BLOG",
|
||||
title: "آرشیو مقالات",
|
||||
body: ["فهرست کامل مقالات منتشرشدهی گروه رئوفی."],
|
||||
},
|
||||
emptyMessage: "هنوز مقالهای منتشر نشده است.",
|
||||
} as const;
|
||||
|
||||
export type NewsItem = {
|
||||
slug: string;
|
||||
title: string;
|
||||
@@ -321,3 +412,6 @@ export const news: NewsItem[] = [
|
||||
image: hayatFoundation,
|
||||
},
|
||||
];
|
||||
|
||||
/** No blog posts published anywhere yet — genuinely empty, not left out. */
|
||||
export const blog: NewsItem[] = [];
|
||||
|
||||
Reference in New Issue
Block a user