Files
guilan-ace-frontend/src/app/blog/[slug]/page.tsx
Amirhossein Khalili 7ddc6b158d
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
feat(blog): redesign post detail experience
2026-06-10 11:56:21 +03:30

292 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Metadata } from "next";
import { notFound } from "next/navigation";
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 { Button } from "@/components/ui/button";
import { Link } from "@/lib/router";
import { blogPostPath, blogPostUrl, normalizeBlogSlugParam } from "@/lib/blog-routes";
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 }>;
export const dynamic = "force-dynamic";
function cleanText(value?: string | null) {
if (!value) return "";
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);
} catch (error) {
if (error instanceof PublicApiError && error.status === 404) {
notFound();
}
throw error;
}
}
async function loadRecommended(slug: string) {
try {
return await getRecommendedPosts(slug, 3);
} catch {
return [];
}
}
function TableOfContents({ headings }: { headings: MarkdownHeading[] }) {
if (!headings.length) {
return <p className="text-sm leading-7 text-muted-foreground">برای این نوشته فهرست تیترها ثبت نشده است.</p>;
}
return (
<nav className="space-y-1 text-sm">
{headings.map((heading) => (
<a
key={heading.id}
href={`#${heading.id}`}
className="block rounded-2xl px-3 py-2 leading-6 text-muted-foreground transition hover:bg-primary/10 hover:text-primary"
style={{ paddingRight: `${(heading.level - 1) * 0.85 + 0.75}rem` }}
>
{heading.text}
</a>
))}
</nav>
);
}
function HashTags({ tags }: { tags: Types.PostListSchema["tags"] }) {
if (!tags.length) {
return <p className="text-sm text-muted-foreground">هشتگی برای این نوشته ثبت نشده است.</p>;
}
return (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Badge key={tag.id} variant="outline" className="rounded-full px-3 py-1">
#{tag.name}
</Badge>
))}
</div>
);
}
function RecommendedPosts({ posts }: { posts: Types.PostListSchema[] }) {
if (!posts.length) return null;
return (
<section className="mt-10 rounded-[2rem] border border-border/70 bg-card/90 p-5 shadow-sm">
<div className="mb-5 text-right">
<p className="text-sm font-medium text-primary">ادامه مطالعه</p>
<h2 className="mt-1 text-2xl font-bold">نوشتههای پیشنهادی</h2>
</div>
<div className="grid gap-4 md:grid-cols-3">
{posts.map((post) => (
<Link
key={post.id}
to={blogPostPath(post.slug)}
className="group overflow-hidden rounded-3xl border border-border/70 bg-background transition hover:-translate-y-1 hover:shadow-lg"
>
<BlogThumbnail
post={post}
imageUrl={toAbsoluteUrl(getBlogCardImageUrl(post), apiBaseUrl)}
className="aspect-[16/10]"
/>
<div className="space-y-2 p-4 text-right">
<h3 className="line-clamp-2 font-semibold leading-7 group-hover:text-primary">{post.title}</h3>
<time className="text-xs text-muted-foreground" dateTime={post.published_at || post.created_at}>
{formatJalaliDate(post.published_at || post.created_at)}
</time>
</div>
</Link>
))}
</div>
</section>
);
}
export async function generateMetadata({
params,
}: {
params: Params;
}): Promise<Metadata> {
const { slug } = await params;
const post = await loadPost(normalizeBlogSlugParam(slug));
const description = cleanText(post.excerpt || post.content).slice(0, 160);
const metaTitle = post.seo_title || post.og_title || post.title;
const metaDescription = post.seo_description || post.og_description || description;
const canonical = post.canonical_url || blogPostPath(post.slug);
const image = toAbsoluteUrl(
post.og_image_url || getBlogHeroImageUrl(post),
apiBaseUrl,
) ?? `${siteUrl}/favicon.ico`;
return {
title: metaTitle,
description: metaDescription,
alternates: { canonical },
robots: post.noindex ? { index: false, follow: true } : undefined,
openGraph: {
title: post.og_title || metaTitle,
description: post.og_description || metaDescription,
url: blogPostUrl(siteUrl, post.slug),
siteName: "انجمن علمی مهندسی کامپیوتر شرق گیلان",
type: "article",
images: [image],
locale: "fa_IR",
publishedTime: post.published_at || post.created_at,
modifiedTime: post.updated_at,
},
twitter: {
card: "summary_large_image",
title: post.og_title || metaTitle,
description: post.og_description || metaDescription,
images: [image],
},
};
}
export default async function BlogDetailPage({
params,
}: {
params: Params;
}) {
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 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: [seoImage],
datePublished: post.published_at || post.created_at,
dateModified: post.updated_at,
url: blogPostUrl(siteUrl, post.slug),
author: {
"@type": "Person",
name: authorName(post),
},
publisher: {
"@type": "Organization",
name: "انجمن علمی مهندسی کامپیوتر شرق گیلان",
logo: {
"@type": "ImageObject",
url: `${siteUrl}/favicon.ico`,
},
},
keywords: post.tags.map((tag) => tag.name).join(", "),
};
return (
<div className="min-h-screen bg-[linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.28))]" dir="rtl">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
<div className="container mx-auto px-4 py-8">
<div className="mb-6 flex justify-start">
<Button variant="outline" asChild className="rounded-full">
<Link to="/blog">بازگشت به بلاگ</Link>
</Button>
</div>
<div className="gap-8 xl:flex xl:items-start">
<aside className="hidden w-72 shrink-0 xl:block">
<div className="sticky top-24 space-y-4">
<section className="rounded-[1.75rem] border border-border/70 bg-card/90 p-4 shadow-sm backdrop-blur">
<h2 className="mb-3 text-right font-bold">فهرست نوشته</h2>
<TableOfContents headings={headings} />
</section>
<section className="rounded-[1.75rem] border border-border/70 bg-card/90 p-4 shadow-sm backdrop-blur">
<h2 className="mb-3 text-right font-bold">هشتگها</h2>
<HashTags tags={post.tags} />
</section>
</div>
</aside>
<main className="min-w-0 flex-1">
<article className="overflow-hidden rounded-[2.5rem] border border-border/70 bg-card/95 shadow-xl shadow-primary/5">
<header className="space-y-6 p-5 text-right md:p-8">
<div className="flex flex-wrap gap-2">
{post.category?.name ? <Badge className="rounded-full">{post.category.name}</Badge> : null}
<Badge variant="outline" className="rounded-full">
{formatJalaliDate(post.published_at || post.created_at)}
</Badge>
</div>
<div className="max-w-4xl space-y-4">
<h1 className="text-3xl font-black leading-[1.7] tracking-tight md:text-5xl">
{post.title}
</h1>
<p className="text-sm text-muted-foreground">
نوشته {authorName(post)}
</p>
{post.excerpt ? (
<p className="max-w-3xl text-base leading-8 text-muted-foreground md:text-lg">
{post.excerpt}
</p>
) : null}
</div>
</header>
<div className="px-5 md:px-8">
<BlogThumbnail
post={post}
imageUrl={coverImage}
className="aspect-[16/9] rounded-[2rem]"
priority
/>
<BlogPostActions
slug={post.slug}
initialLikes={post.likes_count ?? 0}
initialSaves={post.saves_count ?? 0}
initialComments={post.comments_count ?? 0}
/>
</div>
<div className="space-y-5 p-5 md:p-8 xl:hidden">
<section className="rounded-[1.5rem] border border-border/70 bg-muted/20 p-4">
<h2 className="mb-3 text-right font-bold">فهرست نوشته</h2>
<TableOfContents headings={headings} />
</section>
<section className="rounded-[1.5rem] border border-border/70 bg-muted/20 p-4">
<h2 className="mb-3 text-right font-bold">هشتگها</h2>
<HashTags tags={post.tags} />
</section>
</div>
<div className="px-5 pb-8 pt-4 md:px-8 md:pb-10">
<Markdown content={post.content} justify size="base" className="mx-auto max-w-4xl" />
</div>
</article>
<RecommendedPosts posts={recommendedPosts} />
<BlogPostInteractions
slug={post.slug}
initialComments={post.comments_count ?? 0}
/>
</main>
</div>
</div>
</div>
);
}