From 6dc068076d359e9fcf5ee56ef26885d79b241a07 Mon Sep 17 00:00:00 2001 From: "ar.azizan" Date: Thu, 5 Feb 2026 14:54:19 +0330 Subject: [PATCH 1/6] feat: blog section --- .vscode/settings.json | 1 + app/blog/[slug]/page.tsx | 133 +++++++ app/blog/page.tsx | 202 +++++++++++ app/globals.css | 3 + components/blog-collapse-section.tsx | 48 +++ components/blog-post-card.tsx | 65 ++++ components/blog-post-meta.tsx | 182 ++++++++++ components/blog-toc.tsx | 222 +++++++++++ components/footer.tsx | 4 +- components/header.tsx | 74 ++-- components/scroll-to-top.tsx | 54 +++ lib/blog-blocks.ts | 235 ++++++++++++ lib/blog-utils.ts | 96 +++++ lib/format-date.ts | 15 + lib/wp-api.ts | 145 ++++++++ next.config.ts | 4 + package.json | 2 + yarn.lock | 525 +++++++++++++++++++++++---- 18 files changed, 1917 insertions(+), 93 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 app/blog/[slug]/page.tsx create mode 100644 app/blog/page.tsx create mode 100644 components/blog-collapse-section.tsx create mode 100644 components/blog-post-card.tsx create mode 100644 components/blog-post-meta.tsx create mode 100644 components/blog-toc.tsx create mode 100644 components/scroll-to-top.tsx create mode 100644 lib/blog-blocks.ts create mode 100644 lib/blog-utils.ts create mode 100644 lib/format-date.ts create mode 100644 lib/wp-api.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..38daa2b --- /dev/null +++ b/app/blog/[slug]/page.tsx @@ -0,0 +1,133 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import Image from "next/image"; +import { notFound } from "next/navigation"; +import { getPostBySlug, getFeaturedImageUrl } from "@/lib/wp-api"; +import { formatDateShamsi } from "@/lib/format-date"; +import { ChevronRight } from "lucide-react"; +import { parseContentHeadings, getReadingTimeMinutes } from "@/lib/blog-utils"; +import { BlogToc } from "@/components/blog-toc"; +import { BlogPostMeta } from "@/components/blog-post-meta"; +import { BlogCollapseSection } from "@/components/blog-collapse-section"; +import { ScrollToTop } from "@/components/scroll-to-top"; + +interface BlogPostPageProps { + params: Promise<{ slug: string }>; +} + +export async function generateMetadata({ + params, +}: BlogPostPageProps): Promise { + const { slug } = await params; + const post = await getPostBySlug(slug); + if (!post) return { title: "پست یافت نشد" }; + const title = post.title?.rendered ?? "پست"; + const excerpt = post.excerpt?.rendered?.replace(/<[^>]*>/g, "").trim().slice(0, 160) ?? ""; + return { + title, + description: excerpt, + }; +} + +export default async function BlogPostPage({ params }: BlogPostPageProps) { + const { slug } = await params; + let post; + try { + post = await getPostBySlug(slug); + } catch { + notFound(); + } + if (!post) notFound(); + + const date = post.date ? formatDateShamsi(post.date) : null; + const modifiedDate = post.modified + ? formatDateShamsi(post.modified) + : null; + const authorName = post._embedded?.author?.[0]?.name ?? null; + const thumbnailUrl = getFeaturedImageUrl(post, "full"); + const media = post._embedded?.["wp:featuredmedia"]?.[0]; + const altText = media?.alt_text || post.title?.rendered?.replace(/<[^>]*>/g, "") || ""; + + const { toc, segments } = parseContentHeadings(post.content.rendered); + const readingTimeMinutes = getReadingTimeMinutes(post.content.rendered); + const postTitle = post.title?.rendered?.replace(/<[^>]*>/g, "") ?? ""; + const postPath = `/blog/${slug}`; + + return ( + <> + {/* تصویر شاخص تمام‌عرض بالای صفحه */} + {thumbnailUrl && ( +
+
+ {altText} +
+
+ )} + +
+
+ {/* Single grid so sticky TOC stays for whole page scroll */} +
+ + +
+
+ +

+ +

+ +
+ {segments.map((seg, i) => + seg.type === "html" ? ( +
+ ) : seg.blockType === "collapse" ? ( + + ) : ( + /* سایر بلاک‌ها (blockquote, code, ...) بعداً با کامپوننت خودشان رندر می‌شوند */ + null + ) + )} +
+
+
+
+
+ + + ); +} \ No newline at end of file diff --git a/app/blog/page.tsx b/app/blog/page.tsx new file mode 100644 index 0000000..f1f5a47 --- /dev/null +++ b/app/blog/page.tsx @@ -0,0 +1,202 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { getPosts, getCategories } from "@/lib/wp-api"; +import { BlogPostCard } from "@/components/blog-post-card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ChevronRight, ChevronLeft } from "lucide-react"; + +const PER_PAGE = 9; + +export const metadata: Metadata = { + title: "بلاگ", + description: "مقالات و مطالب فیپ در زمینه یکپارچه‌سازی، اتوماسیون و بهره‌وری سازمانی.", +}; + +interface BlogPageProps { + searchParams: Promise<{ search?: string; category?: string; page?: string }>; +} + +export default async function BlogPage({ searchParams }: BlogPageProps) { + const params = await searchParams; + const search = params.search ?? ""; + const categoryId = params.category ? parseInt(params.category, 10) : undefined; + const page = Math.max(1, parseInt(params.page ?? "1", 10) || 1); + + let postsResult; + let categories: Awaited> = []; + + try { + [postsResult, categories] = await Promise.all([ + getPosts({ + page, + perPage: PER_PAGE, + search: search || undefined, + categoryId: Number.isFinite(categoryId) ? categoryId : undefined, + }), + getCategories(), + ]); + } catch (e) { + console.log(e) + return ( +
+
+

بلاگ

+

+ در حال حاضر امکان بارگذاری مطالب وجود ندارد. لطفاً بعداً تلاش کنید. +

+
+
+ ); + } + + const { posts, total, totalPages } = postsResult; + const hasFilters = search || categoryId; + + function buildQuery(updates: { search?: string; category?: string; page?: number }) { + const q = new URLSearchParams(); + if (updates.search !== undefined && updates.search !== "") q.set("search", updates.search); + if (updates.category !== undefined && updates.category !== "") q.set("category", updates.category); + if (updates.page !== undefined && updates.page > 1) q.set("page", String(updates.page)); + const s = q.toString(); + return s ? `?${s}` : ""; + } + + return ( + <> + {/* Hero + search & filters in one section */} +
+
+
+
+

+ بلاگ +

+

+ مقالات و مطالب فیپ در زمینه یکپارچه‌سازی، اتوماسیون و بهره‌وری سازمانی. +

+
+ +
+
+ + +
+ + {hasFilters && ( + + + + )} +
+
+ + {categories.length > 0 && ( +
+ + همه + + {categories.map((cat) => ( + + {cat.name} + + ))} +
+ )} +
+
+
+
+ +
+
+ {posts.length === 0 ? ( +
+

مطلبی یافت نشد.

+ + + +
+ ) : ( + <> +
    + {posts.map((post) => ( +
  • + +
  • + ))} +
+ + {totalPages > 1 && ( + + )} + + )} +
+
+ + ); +} diff --git a/app/globals.css b/app/globals.css index ca42454..8f4b920 100644 --- a/app/globals.css +++ b/app/globals.css @@ -190,6 +190,9 @@ } @layer base { + html { + scroll-behavior: smooth; + } * { @apply border-border outline-ring/50; } diff --git a/components/blog-collapse-section.tsx b/components/blog-collapse-section.tsx new file mode 100644 index 0000000..7de8065 --- /dev/null +++ b/components/blog-collapse-section.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import type { CollapseBlockPayload } from "@/lib/blog-blocks"; + +interface BlogCollapseSectionProps { + section: CollapseBlockPayload; + /** Optional prefix for accordion item ids (e.g. for multiple sections on one page). */ + idPrefix?: string; +} + +export function BlogCollapseSection({ + section, + idPrefix = "collapse", +}: BlogCollapseSectionProps) { + if (!section.items.length) return null; + + return ( +
+ {section.title ? ( +

+ {section.title} +

+ ) : null} + + {section.items.map((item, i) => ( + + + {item.question} + + + {item.answer} + + + ))} + +
+ ); +} diff --git a/components/blog-post-card.tsx b/components/blog-post-card.tsx new file mode 100644 index 0000000..a11ef9f --- /dev/null +++ b/components/blog-post-card.tsx @@ -0,0 +1,65 @@ +import Link from "next/link"; +import Image from "next/image"; +import { ImageIcon, User } from "lucide-react"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { getFeaturedImageUrl, type WpPost } from "@/lib/wp-api"; +import { formatDateShamsi } from "@/lib/format-date"; + +function stripHtml(html: string): string { + return html.replace(/<[^>]*>/g, "").trim(); +} + +export function BlogPostCard({ post }: { post: WpPost }) { + const excerpt = stripHtml(post.excerpt.rendered) || stripHtml(post.content.rendered).slice(0, 160); + const modifiedDate = post.modified ? formatDateShamsi(post.modified) : null; + const authorName = post._embedded?.author?.[0]?.name ?? null; + const thumbnailUrl = getFeaturedImageUrl(post, "medium"); + + return ( + + + {thumbnailUrl ? ( +
+ +
+ ) : ( +
+ +
+ )} + + + {post.title.rendered} + +
+ {authorName && ( + + + {authorName} + + )} + {modifiedDate && ( + + )} +
+
+ +

+ {excerpt} +

+
+
+ + ); +} diff --git a/components/blog-post-meta.tsx b/components/blog-post-meta.tsx new file mode 100644 index 0000000..00a956e --- /dev/null +++ b/components/blog-post-meta.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { + Link2, + Clock, + Calendar, + User, + Share2, + Check, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +export interface BlogPostMetaProps { + /** Path (e.g. /blog/slug). Full URL is built on client for share/copy. */ + url: string; + title: string; + readingTimeMinutes: number; + /** Shamsi publish date (e.g. "۱۴۰۳/۰۸/۱۵") */ + date?: string | null; + /** ISO date for