feat: enhance blog functionality with new components and styles
- Added styles for WordPress table and button blocks in globals.css. - Updated layout.tsx to include a favicon. - Improved blog page layout and search functionality, including new search and filter icons. - Implemented related posts feature in blog post pages with new BlogBlockquote and BlogPullquote components. - Enhanced blog card display with a new variant for smaller cards. - Updated blog collapse sections for better user interaction. - Added new utility functions for extracting block data from WordPress content.
This commit is contained in:
@@ -2,14 +2,18 @@ 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 { getPostBySlug, getFeaturedImageUrl, getRelatedPosts } from "@/lib/wp-api";
|
||||
import { formatDateShamsi } from "@/lib/format-date";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { CollapseBlockPayload, BlockquoteBlockPayload } from "@/lib/blog-blocks";
|
||||
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 { BlogBlockquote } from "@/components/blog-blockquote";
|
||||
import { BlogPullquote } from "@/components/blog-pullquote";
|
||||
import { ScrollToTop } from "@/components/scroll-to-top";
|
||||
import { BlogPostCard } from "@/components/blog-post-card";
|
||||
|
||||
interface BlogPostPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
@@ -39,6 +43,13 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
|
||||
}
|
||||
if (!post) notFound();
|
||||
|
||||
let relatedPosts: Awaited<ReturnType<typeof getRelatedPosts>> = [];
|
||||
try {
|
||||
relatedPosts = await getRelatedPosts(post.id, post.categories ?? [], 3);
|
||||
} catch {
|
||||
// show no related section on API error
|
||||
}
|
||||
|
||||
const date = post.date ? formatDateShamsi(post.date) : null;
|
||||
const modifiedDate = post.modified
|
||||
? formatDateShamsi(post.modified)
|
||||
@@ -57,8 +68,8 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
|
||||
<>
|
||||
{/* تصویر شاخص تمامعرض بالای صفحه */}
|
||||
{thumbnailUrl && (
|
||||
<div className="pt-28 md:pt-36">
|
||||
<div className="relative w-full aspect-[21/5] min-h-[10rem] md:min-h-[10rem] bg-muted">
|
||||
<div className="pt-10 md:pt-24">
|
||||
<div className="relative container mx-auto max-w-6xl rounded-xl overflow-hidden shadow-sm w-full aspect-[5/2] min-h-[10rem] md:min-h-[10rem] bg-muted">
|
||||
<Image
|
||||
src={thumbnailUrl}
|
||||
alt={altText}
|
||||
@@ -114,11 +125,12 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
|
||||
) : seg.blockType === "collapse" ? (
|
||||
<BlogCollapseSection
|
||||
key={i}
|
||||
section={seg.payload}
|
||||
section={seg.payload as CollapseBlockPayload}
|
||||
idPrefix={`blog-${slug}-${i}`}
|
||||
/>
|
||||
) : seg.blockType === "blockquote" ? (
|
||||
<BlogBlockquote key={i} payload={seg.payload as BlockquoteBlockPayload} />
|
||||
) : (
|
||||
/* سایر بلاکها (blockquote, code, ...) بعداً با کامپوننت خودشان رندر میشوند */
|
||||
null
|
||||
)
|
||||
)}
|
||||
@@ -127,6 +139,22 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{relatedPosts.length > 0 && (
|
||||
<section className="bg-muted border-t border-b">
|
||||
<div className="mx-auto max-w-6xl p-4">
|
||||
<h2 className="text-xl text-center mb-6 md:mb-8">
|
||||
مقالات مرتبط
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{relatedPosts.map((p) => (
|
||||
<BlogPostCard key={p.id} post={p} variant="tiny" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<ScrollToTop />
|
||||
</>
|
||||
);
|
||||
|
||||
+29
-27
@@ -4,7 +4,7 @@ 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";
|
||||
import { ChevronRight, ChevronLeft, Search, X } from "lucide-react";
|
||||
|
||||
const PER_PAGE = 9;
|
||||
|
||||
@@ -37,7 +37,6 @@ export default async function BlogPage({ searchParams }: BlogPageProps) {
|
||||
getCategories(),
|
||||
]);
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
return (
|
||||
<section className="bg-background pt-32 pb-20 md:pt-40 md:pb-28">
|
||||
<div className="mx-auto max-w-4xl px-6 text-center">
|
||||
@@ -64,53 +63,56 @@ export default async function BlogPage({ searchParams }: BlogPageProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hero + search & filters in one section */}
|
||||
<section className="bg-background pt-32 pb-10 md:pt-40 md:pb-14">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<div className="text-center space-y-8">
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-4xl font-bold md:text-5xl lg:text-6xl leading-tight tracking-normal">
|
||||
{/* Hero + search & filters — compact */}
|
||||
<section className="bg-background pt-24 pb-6 md:pt-28 md:pb-8">
|
||||
<div className="mx-auto max-w-3xl px-4 sm:px-6">
|
||||
<div className="text-center space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<h1 className="text-3xl font-bold md:text-4xl tracking-tight">
|
||||
بلاگ
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl leading-relaxed text-muted-foreground max-w-2xl mx-auto">
|
||||
<p className="text-sm md:text-base text-muted-foreground max-w-xl mx-auto">
|
||||
مقالات و مطالب فیپ در زمینه یکپارچهسازی، اتوماسیون و بهرهوری سازمانی.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 max-w-2xl mx-auto">
|
||||
<div className="flex flex-col gap-4">
|
||||
<form
|
||||
method="get"
|
||||
action="/blog"
|
||||
className="flex flex-col sm:flex-row gap-2 sm:gap-2 w-full"
|
||||
className="flex gap-2 w-full"
|
||||
>
|
||||
<input type="hidden" name="category" value={categoryId ?? ""} />
|
||||
<div className="relative flex-1 flex items-center">
|
||||
<Search className="absolute right-3 size-4 text-muted-foreground pointer-events-none" aria-hidden />
|
||||
<Input
|
||||
type="search"
|
||||
name="search"
|
||||
placeholder="جستجو در مطالب..."
|
||||
defaultValue={search}
|
||||
className="flex-1 w-full sm:min-w-[14rem] text-base"
|
||||
className="w-full pr-10 pl-3 h-9 text-sm"
|
||||
aria-label="جستجو در مطالب"
|
||||
/>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<Button type="submit" size="default" className="flex-1 sm:flex-none">
|
||||
</div>
|
||||
<Button type="submit" size="default" className="h-9 px-4 shrink-0">
|
||||
جستجو
|
||||
</Button>
|
||||
{hasFilters && (
|
||||
<Link href="/blog" className="flex-1 sm:flex-none">
|
||||
<Button type="button" variant="outline" size="default" className="w-full">
|
||||
پاک کردن فیلتر
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{categories.length > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
{hasFilters && (
|
||||
<Link
|
||||
href="/blog"
|
||||
className={`text-sm rounded-full px-4 py-2 transition-colors ${!categoryId
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
پاک کردن فیلتر
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{categories.length > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-1.5">
|
||||
<Link
|
||||
href="/blog"
|
||||
className={`text-xs rounded-full px-3 py-1.5 transition-colors ${!categoryId
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "bg-muted/80 hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
@@ -121,7 +123,7 @@ export default async function BlogPage({ searchParams }: BlogPageProps) {
|
||||
<Link
|
||||
key={cat.id}
|
||||
href={`/blog${buildQuery({ search, category: String(cat.id), page: 1 })}`}
|
||||
className={`text-sm rounded-full px-4 py-2 transition-colors ${categoryId === cat.id
|
||||
className={`text-xs rounded-full px-3 py-1.5 transition-colors ${categoryId === cat.id
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "bg-muted/80 hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
|
||||
@@ -231,3 +231,68 @@
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* WordPress table block (figure.wp-block-table > table) */
|
||||
.wp-block-table {
|
||||
margin: 1.5rem 0;
|
||||
overflow-x: auto;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.wp-block-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9375rem;
|
||||
|
||||
}
|
||||
|
||||
.wp-block-table table.has-fixed-layout {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.wp-block-table th,
|
||||
.wp-block-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
text-align: right;
|
||||
min-width: 850px;
|
||||
}
|
||||
|
||||
.wp-block-table tbody tr:first-child th,
|
||||
.wp-block-table tbody tr:first-child td {
|
||||
background: var(--muted);
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.wp-block-table tbody tr:not(:first-child):hover td {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.wp-block-table thead th {
|
||||
background: var(--muted);
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
|
||||
.wp-block-button > .wp-element-button {
|
||||
@apply inline-flex bg-primary text-primary-foreground px-4 py-2 rounded-md hover:bg-primary/80 hover:text-primary-foreground/80 cursor-pointer no-underline;
|
||||
}
|
||||
|
||||
.wp-block-button {
|
||||
@apply inline-flex;
|
||||
}
|
||||
|
||||
.wp-block-button.is-style-outline > .wp-element-button {
|
||||
@apply bg-transparent text-foreground border border-2 border-primary hover:text-primary cursor-pointer no-underline;
|
||||
}
|
||||
|
||||
.cta-block {
|
||||
@apply flex flex-col gap-4 border rounded-md border-primary/50 p-4 items-center my-4 shadow-sm bg-primary/5 text-primary;
|
||||
}
|
||||
|
||||
.wp-block-image {
|
||||
@apply rounded-md overflow-hidden shadow-sm;
|
||||
}
|
||||
@@ -8,6 +8,9 @@ export const metadata: Metadata = {
|
||||
title: "فیپ",
|
||||
description:
|
||||
"فیپ یک سامانه برای ایجاد و طراحی راهکارهای مناسب برای کسب و کار شما است.",
|
||||
icons: {
|
||||
icon: "/assets/feep-Logo.svg",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import type { BlockquoteBlockPayload } from "@/lib/blog-blocks";
|
||||
import { Quote } from "lucide-react";
|
||||
|
||||
interface BlogBlockquoteProps {
|
||||
payload: BlockquoteBlockPayload;
|
||||
}
|
||||
|
||||
export function BlogBlockquote({ payload }: BlogBlockquoteProps) {
|
||||
const { content, citation } = payload;
|
||||
if (!content.trim()) return null;
|
||||
|
||||
return (
|
||||
<figure className="my-8 not-prose">
|
||||
<blockquote className="relative rounded-xl border border-border bg-muted/40 px-6 py-2 pr-14 text-foreground shadow-sm">
|
||||
<Quote
|
||||
className="absolute right-4 top-4 size-8 text-muted-foreground/40"
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="blog-blockquote-content text-lg leading-relaxed text-justify [&_p]:my-2 [&_a]:text-primary [&_a]:underline hover:[&_a]:no-underline"
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
{citation ? (
|
||||
<footer className="mt-4 border-t border-border/60 pt-4">
|
||||
<cite className="text-sm text-muted-foreground not-italic">
|
||||
— {citation}
|
||||
</cite>
|
||||
</footer>
|
||||
) : null}
|
||||
</blockquote>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export function BlogCard({
|
||||
<Link href={`/blog/${slug}`} className="group block h-full">
|
||||
<Card className="h-full flex flex-col overflow-hidden transition-all duration-300 hover:shadow-lg hover:-translate-y-1 dark:border-transparent">
|
||||
{/* Image with 3:2 aspect ratio */}
|
||||
<div className="relative aspect-[3/2] w-full overflow-hidden bg-muted">
|
||||
<div className="relative aspect-[5/3] w-full overflow-hidden bg-muted">
|
||||
<Image
|
||||
src={image}
|
||||
alt={title}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import type { CollapseBlockPayload } from "@/lib/blog-blocks";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface BlogCollapseSectionProps {
|
||||
section: CollapseBlockPayload;
|
||||
@@ -14,6 +15,7 @@ interface BlogCollapseSectionProps {
|
||||
idPrefix?: string;
|
||||
}
|
||||
|
||||
|
||||
export function BlogCollapseSection({
|
||||
section,
|
||||
idPrefix = "collapse",
|
||||
@@ -21,24 +23,34 @@ export function BlogCollapseSection({
|
||||
if (!section.items.length) return null;
|
||||
|
||||
return (
|
||||
<div className="my-8 space-y-4 border rounded-md p-6">
|
||||
<div className="my-8 space-y-4 border rounded-xl p-6">
|
||||
{section.title ? (
|
||||
<h2 className="text-2xl font-bold !mt-0 mb-4 scroll-mt-24">
|
||||
{section.title}
|
||||
</h2>
|
||||
) : null}
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<Accordion type="single" collapsible className="w-full gap-1">
|
||||
{section.items.map((item, i) => (
|
||||
<AccordionItem
|
||||
key={i}
|
||||
value={`${idPrefix}-${i}`}
|
||||
className="border-b last:border-b-0 !m-0"
|
||||
className="border-none !m-0 hover:bg-muted has-[[data-state=open]]:bg-muted px-4 rounded-md"
|
||||
>
|
||||
<AccordionTrigger className="text-right hover:no-underline py-4 [&>svg]:shrink-0">
|
||||
<AccordionTrigger className="group/trigger text-right hover:no-underline cursor-pointer py-4 [&>svg]:shrink-0">
|
||||
<span className="flex gap-1 items-center">
|
||||
<Icon
|
||||
icon="lucide:circle-small"
|
||||
className="size-5 group-data-[state=open]/trigger:hidden"
|
||||
/>
|
||||
<Icon
|
||||
icon="lucide:dot"
|
||||
className="size-5 hidden group-data-[state=open]/trigger:inline-block"
|
||||
/>
|
||||
{item.question}
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground leading-relaxed text-justify">
|
||||
{item.answer}
|
||||
<div dangerouslySetInnerHTML={{ __html: item.answer }} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
|
||||
@@ -9,12 +9,67 @@ function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, "").trim();
|
||||
}
|
||||
|
||||
export function BlogPostCard({ post }: { post: WpPost }) {
|
||||
type BlogPostCardVariant = "default" | "tiny";
|
||||
|
||||
export function BlogPostCard({
|
||||
post,
|
||||
variant = "default",
|
||||
}: {
|
||||
post: WpPost;
|
||||
variant?: BlogPostCardVariant;
|
||||
}) {
|
||||
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");
|
||||
|
||||
if (variant === "tiny") {
|
||||
return (
|
||||
<Link href={`/blog/${encodeURIComponent(post.slug)}`} className="block h-full">
|
||||
<Card className="h-full overflow-hidden transition-all rounded-md duration-300 hover:shadow-md flex flex-col p-2">
|
||||
<CardContent className="p-2 grid grid-cols-3 gap-4">
|
||||
{thumbnailUrl ? (
|
||||
<div className="relative rounded-md aspect-[4/3] w-full overflow-hidden bg-muted shrink-0 col-span-1">
|
||||
<Image
|
||||
src={thumbnailUrl}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative rounded-md flex aspect-[4/3] w-full items-center justify-center overflow-hidden bg-muted shrink-0"
|
||||
aria-hidden
|
||||
>
|
||||
<ImageIcon className="size-8 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
<div className="col-span-2">
|
||||
<h4 className="text-sm font-semibold leading-snug line-clamp-2">
|
||||
{post.title.rendered}
|
||||
</h4>
|
||||
<div className="flex flex-wrap w-full items-center gap-x-4 gap-y-1 text-xs text-muted-foreground bg-muted inline-flex p-1 mt-2 rounded-sm">
|
||||
{authorName && (
|
||||
<span className="inline-flex flex-1 items-center gap-1.5">
|
||||
<User className="size-3.5 shrink-0" aria-hidden />
|
||||
{authorName}
|
||||
</span>
|
||||
)}
|
||||
{modifiedDate && (
|
||||
<time dateTime={post.modified}>
|
||||
{modifiedDate}
|
||||
</time>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={`/blog/${encodeURIComponent(post.slug)}`} className="block h-full">
|
||||
<Card className="h-full overflow-hidden transition-all duration-300 hover:-translate-y-1 hover:shadow-lg pt-0 gap-1">
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import type { BlockquoteBlockPayload } from "@/lib/blog-blocks";
|
||||
|
||||
interface BlogPullquoteProps {
|
||||
payload: BlockquoteBlockPayload;
|
||||
}
|
||||
|
||||
export function BlogPullquote({ payload }: BlogPullquoteProps) {
|
||||
const { content, citation } = payload;
|
||||
if (!content.trim()) return null;
|
||||
|
||||
return (
|
||||
<figure className="my-10 not-prose">
|
||||
<blockquote className="relative rounded-xl border border-border bg-muted/40 px-6 py-5 text-center text-foreground shadow-sm">
|
||||
<div
|
||||
className=""
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
{citation ? (
|
||||
<footer className="mt-4">
|
||||
<cite className="text-sm text-muted-foreground not-italic">
|
||||
— {citation}
|
||||
</cite>
|
||||
</footer>
|
||||
) : null}
|
||||
</blockquote>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -176,7 +176,7 @@ export function BlogToc({ items, className }: BlogTocProps) {
|
||||
<aside
|
||||
className={cn(
|
||||
"hidden lg:block shrink-0",
|
||||
"sticky top-24 self-start max-h-[calc(100vh-6rem)]",
|
||||
"sticky top-24 self-start",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
+4
-14
@@ -10,11 +10,10 @@ import { ThemeToggle } from '@/components/theme-toggle'
|
||||
|
||||
const menuItems = [
|
||||
{ name: 'صفحه اصلی', href: '/' },
|
||||
{ name: 'درباره ما', href: '/about' },
|
||||
{ name: 'راه حلها', href: '/solutions' },
|
||||
{ name: 'بلاگ', href: '/blog' },
|
||||
{ name: 'قیمتگذاری', href: '/pricing' },
|
||||
{ name: 'وبلاگ', href: '/blog' },
|
||||
{ name: 'راه حلها', href: '/solutions' },
|
||||
// { name: 'قیمتگذاری', href: '/pricing' },
|
||||
{ name: 'درباره ما', href: '/about' },
|
||||
]
|
||||
|
||||
function isLinkActive(pathname: string, href: string): boolean {
|
||||
@@ -104,16 +103,7 @@ export const HeroHeader = () => {
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col space-y-3 sm:flex-row sm:gap-3 sm:space-y-0 md:w-fit">
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
className={cn(isScrolled ? 'lg:inline-flex' : 'hidden', 'transition-all duration-300 hover:scale-105')}>
|
||||
<Link href="tel:09162949770">
|
||||
<span>تماس با فیپ</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+81
-8
@@ -12,7 +12,7 @@ import type { Element } from "domhandler";
|
||||
// ─── انواع بلاک (برای گسترش بعدی) ───────────────────────────────────────────
|
||||
|
||||
/** نام نوع بلاک؛ با افزودن بلاک جدید این یونیون را گسترش دهید. */
|
||||
export type BlockType = "collapse" | "blockquote" | "code";
|
||||
export type BlockType = "collapse" | "blockquote" | "pullquote" | "code";
|
||||
|
||||
/** Payload بلاک collapse (سوالات متداول / accordion). */
|
||||
export interface CollapseBlockPayload {
|
||||
@@ -20,14 +20,21 @@ export interface CollapseBlockPayload {
|
||||
items: { question: string; answer: string }[];
|
||||
}
|
||||
|
||||
/** Payload بلاک blockquote — بعداً پیادهسازی میشود. */
|
||||
// export interface BlockquoteBlockPayload { ... }
|
||||
/** Payload بلاک blockquote (نقلقول و منبع). content به صورت HTML است و باید با dangerouslySetInnerHTML رندر شود. */
|
||||
export interface BlockquoteBlockPayload {
|
||||
/** HTML محتوای نقلقول (بدون تگ cite). */
|
||||
content: string;
|
||||
citation?: string;
|
||||
}
|
||||
|
||||
/** Payload بلاک code — بعداً پیادهسازی میشود. */
|
||||
// export interface CodeBlockPayload { ... }
|
||||
|
||||
/** یونیون همهٔ payloadها برای تایپامن رندر. */
|
||||
export type BlockPayload = CollapseBlockPayload; // | BlockquoteBlockPayload | CodeBlockPayload
|
||||
export type BlockPayload =
|
||||
| CollapseBlockPayload
|
||||
| BlockquoteBlockPayload;
|
||||
// | CodeBlockPayload
|
||||
|
||||
// ─── سگمنت خروجی برای لایه رندر ─────────────────────────────────────────────
|
||||
|
||||
@@ -84,6 +91,58 @@ function extractCollapsePayload(
|
||||
return { title, items };
|
||||
}
|
||||
|
||||
/**
|
||||
* استخراج از بلاک آکاردئون وردپرس (core/accordion):
|
||||
* div.wp-block-accordion > div.wp-block-accordion-item با
|
||||
* .wp-block-accordion-heading__toggle-title (سؤال) و .wp-block-accordion-panel (جواب به صورت HTML).
|
||||
*/
|
||||
function extractAccordionPayload(
|
||||
$: cheerio.CheerioAPI,
|
||||
el: Element
|
||||
): CollapseBlockPayload {
|
||||
const $el = $(el);
|
||||
const items: { question: string; answer: string }[] = [];
|
||||
|
||||
$el.find(".wp-block-accordion-item").each((_: number, itemEl: Element) => {
|
||||
const $item = $(itemEl);
|
||||
const question = $item.find(".wp-block-accordion-heading__toggle-title").first().text().trim();
|
||||
const $panel = $item.find(".wp-block-accordion-panel").first();
|
||||
const answer = ($panel.html() ?? $panel.text() ?? "").trim();
|
||||
if (question || answer) items.push({ question, answer });
|
||||
});
|
||||
|
||||
return { title: "", items };
|
||||
}
|
||||
|
||||
function extractBlockquotePayload(
|
||||
$: cheerio.CheerioAPI,
|
||||
el: Element
|
||||
): BlockquoteBlockPayload {
|
||||
const $el = $(el);
|
||||
const citation = $el.find("cite").first().text().trim() || undefined;
|
||||
const $body = $el.clone();
|
||||
$body.find("cite").remove();
|
||||
const content = ($body.html() ?? "").trim();
|
||||
return { content, citation };
|
||||
}
|
||||
|
||||
/**
|
||||
* استخراج از بلاک pullquote وردپرس: figure.wp-block-pullquote > blockquote > p + cite.
|
||||
* همان شکل blockquote (content HTML + citation).
|
||||
*/
|
||||
function extractPullquotePayload(
|
||||
$: cheerio.CheerioAPI,
|
||||
el: Element
|
||||
): BlockquoteBlockPayload {
|
||||
const $el = $(el);
|
||||
const $blockquote = $el.find("blockquote").first();
|
||||
const citation = $blockquote.find("cite").first().text().trim() || undefined;
|
||||
const $body = $blockquote.clone();
|
||||
$body.find("cite").remove();
|
||||
const content = ($body.html() ?? "").trim();
|
||||
return { content, citation };
|
||||
}
|
||||
|
||||
/**
|
||||
* ثبت همهٔ بلاکهای شناختهشده.
|
||||
* برای بلاکهای جدید (مثلاً blockquote, code) فقط یک مورد جدید به این آرایه اضافه کنید.
|
||||
@@ -91,11 +150,24 @@ function extractCollapsePayload(
|
||||
export const BLOCK_DEFINITIONS: BlockDefinition[] = [
|
||||
{
|
||||
blockType: "collapse",
|
||||
selector: ".collpase-section",
|
||||
selector: "div.wp-block-group.collapse-section",
|
||||
extract: extractCollapsePayload as BlockDefinition["extract"],
|
||||
},
|
||||
// بعداً:
|
||||
// { blockType: "blockquote", selector: "blockquote.wp-block-quote", extract: extractBlockquotePayload },
|
||||
{
|
||||
blockType: "collapse",
|
||||
selector: "div.wp-block-accordion",
|
||||
extract: extractAccordionPayload as BlockDefinition["extract"],
|
||||
},
|
||||
{
|
||||
blockType: "blockquote",
|
||||
selector: "blockquote.wp-block-quote",
|
||||
extract: extractBlockquotePayload as BlockDefinition["extract"],
|
||||
},
|
||||
{
|
||||
blockType: "pullquote",
|
||||
selector: "figure.wp-block-pullquote",
|
||||
extract: extractPullquotePayload as BlockDefinition["extract"],
|
||||
},
|
||||
// { blockType: "code", selector: "pre.wp-block-code", extract: extractCodePayload },
|
||||
];
|
||||
|
||||
@@ -142,11 +214,12 @@ export function discoverBlocks(html: string): DiscoveredContent {
|
||||
const blocksByType: Record<string, BlockPayload[]> = {
|
||||
collapse: [],
|
||||
blockquote: [],
|
||||
pullquote: [],
|
||||
code: [],
|
||||
};
|
||||
|
||||
for (const def of BLOCK_DEFINITIONS) {
|
||||
const elements = $('.collpase-section').toArray();
|
||||
const elements = $(def.selector).toArray().filter(isElement);
|
||||
for (const el of elements) {
|
||||
const payload = def.extract($, el) as BlockPayload;
|
||||
const blockId = blocksByType[def.blockType].length;
|
||||
|
||||
@@ -132,6 +132,30 @@ export async function getPostBySlug(slug: string): Promise<WpPost | null> {
|
||||
return posts.length > 0 ? posts[0] : null;
|
||||
}
|
||||
|
||||
export async function getRelatedPosts(
|
||||
postId: number,
|
||||
categoryIds: number[],
|
||||
limit = 3
|
||||
): Promise<WpPost[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.set("exclude", String(postId));
|
||||
params.set("per_page", String(limit));
|
||||
params.set("_embed", "1");
|
||||
if (categoryIds.length > 0) {
|
||||
params.set("categories", String(categoryIds[0]));
|
||||
}
|
||||
|
||||
const url = `${WP_API_BASE}/posts?${params.toString()}`;
|
||||
const res = await fetchWithTimeout(url);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`WP API error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const posts: WpPost[] = await res.json();
|
||||
return posts;
|
||||
}
|
||||
|
||||
export async function getCategories(): Promise<WpCategory[]> {
|
||||
const url = `${WP_API_BASE}/categories?per_page=100`;
|
||||
const res = await fetchWithTimeout(url);
|
||||
|
||||
Reference in New Issue
Block a user