From 7ddc6b158dbf971b67f1348061e3ec3bce14f42e Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Wed, 10 Jun 2026 11:56:21 +0330 Subject: [PATCH] feat(blog): redesign post detail experience --- src/app/blog/[slug]/page.tsx | 240 ++++++++++++++++++------ src/components/BlogPostActions.tsx | 120 ++++++++++++ src/components/BlogPostInteractions.tsx | 195 +++++++++---------- src/lib/public-api.ts | 10 + 4 files changed, 402 insertions(+), 163 deletions(-) create mode 100644 src/components/BlogPostActions.tsx diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index 0b97071..48c2752 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -1,15 +1,18 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import Markdown from "@/components/Markdown"; +import BlogPostActions from "@/components/BlogPostActions"; import BlogPostInteractions from "@/components/BlogPostInteractions"; +import BlogThumbnail from "@/components/BlogThumbnail"; +import Markdown from "@/components/Markdown"; import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Link } from "@/lib/router"; -import { PublicApiError, getPublicPost } from "@/lib/public-api"; -import { apiBaseUrl, siteUrl, toAbsoluteUrl } from "@/lib/site"; import { blogPostPath, blogPostUrl, normalizeBlogSlugParam } from "@/lib/blog-routes"; -import { formatJalali } from "@/lib/utils"; +import { extractMarkdownHeadings, type MarkdownHeading } from "@/lib/markdown-headings"; +import { PublicApiError, getPublicPost, getRecommendedPosts } from "@/lib/public-api"; +import { apiBaseUrl, siteUrl, toAbsoluteUrl } from "@/lib/site"; +import type * as Types from "@/lib/types"; +import { formatJalaliDate, getBlogCardImageUrl, getBlogHeroImageUrl } from "@/lib/utils"; type Params = Promise<{ slug: string }>; @@ -20,6 +23,10 @@ function cleanText(value?: string | null) { return value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); } +function authorName(post: Types.PostListSchema) { + return [post.author.first_name, post.author.last_name].filter(Boolean).join(" ") || post.author.username; +} + async function loadPost(slug: string) { try { return await getPublicPost(slug); @@ -31,6 +38,85 @@ async function loadPost(slug: string) { } } +async function loadRecommended(slug: string) { + try { + return await getRecommendedPosts(slug, 3); + } catch { + return []; + } +} + +function TableOfContents({ headings }: { headings: MarkdownHeading[] }) { + if (!headings.length) { + return

برای این نوشته فهرست تیترها ثبت نشده است.

; + } + + return ( + + ); +} + +function HashTags({ tags }: { tags: Types.PostListSchema["tags"] }) { + if (!tags.length) { + return

هشتگی برای این نوشته ثبت نشده است.

; + } + + return ( +
+ {tags.map((tag) => ( + + #{tag.name} + + ))} +
+ ); +} + +function RecommendedPosts({ posts }: { posts: Types.PostListSchema[] }) { + if (!posts.length) return null; + + return ( +
+
+

ادامه مطالعه

+

نوشته‌های پیشنهادی

+
+
+ {posts.map((post) => ( + + +
+

{post.title}

+ +
+ + ))} +
+
+ ); +} + export async function generateMetadata({ params, }: { @@ -43,7 +129,7 @@ export async function generateMetadata({ const metaDescription = post.seo_description || post.og_description || description; const canonical = post.canonical_url || blogPostPath(post.slug); const image = toAbsoluteUrl( - post.og_image_url || post.absolute_featured_image_url || post.featured_image, + post.og_image_url || getBlogHeroImageUrl(post), apiBaseUrl, ) ?? `${siteUrl}/favicon.ico`; @@ -56,7 +142,7 @@ export async function generateMetadata({ title: post.og_title || metaTitle, description: post.og_description || metaDescription, url: blogPostUrl(siteUrl, post.slug), - siteName: "انجمن علمی کامپیوتر شرق گیلان", + siteName: "انجمن علمی مهندسی کامپیوتر شرق گیلان", type: "article", images: [image], locale: "fa_IR", @@ -79,92 +165,126 @@ export default async function BlogDetailPage({ }) { const { slug } = await params; const post = await loadPost(normalizeBlogSlugParam(slug)); + const recommendedPosts = await loadRecommended(post.slug); + const headings = extractMarkdownHeadings(post.content); const description = cleanText(post.excerpt || post.content).slice(0, 160); const metaDescription = post.seo_description || post.og_description || description; - const image = toAbsoluteUrl( - post.og_image_url || post.absolute_featured_image_url || post.featured_image, - apiBaseUrl, - ) ?? `${siteUrl}/favicon.ico`; + const coverImage = toAbsoluteUrl(getBlogHeroImageUrl(post), apiBaseUrl); + const seoImage = toAbsoluteUrl(post.og_image_url || getBlogHeroImageUrl(post), apiBaseUrl) ?? `${siteUrl}/favicon.ico`; const structuredData = { "@context": "https://schema.org", "@type": "BlogPosting", headline: post.title, description: metaDescription, - image: [image], + image: [seoImage], datePublished: post.published_at || post.created_at, dateModified: post.updated_at, url: blogPostUrl(siteUrl, post.slug), author: { "@type": "Person", - name: [post.author.first_name, post.author.last_name].filter(Boolean).join(" ") || post.author.username, + name: authorName(post), }, publisher: { "@type": "Organization", - name: "انجمن علمی کامپیوتر شرق گیلان", + name: "انجمن علمی مهندسی کامپیوتر شرق گیلان", logo: { "@type": "ImageObject", url: `${siteUrl}/favicon.ico`, }, }, + keywords: post.tags.map((tag) => tag.name).join(", "), }; return ( -
+