Files
guilan-ace-frontend/src/app/blog/[slug]/page.tsx

330 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 { CalendarDays, Clock3, Hash, ListTree } from "lucide-react";
import BlogPostActions from "@/components/BlogPostActions";
import BlogPostInteractions from "@/components/BlogPostInteractions";
import BlogTableOfContents from "@/components/BlogTableOfContents";
import BlogThumbnail from "@/components/BlogThumbnail";
import Markdown from "@/components/Markdown";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Link } from "@/lib/router";
import { blogPostPath, blogPostUrl, normalizeBlogSlugParam } from "@/lib/blog-routes";
import { extractMarkdownHeadings } 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, toPersianDigits } from "@/lib/utils";
type Params = Promise<{ slug: string }>;
type Writer = NonNullable<Types.PostListSchema["writers"]>[number];
export const dynamic = "force-dynamic";
function cleanText(value?: string | null) {
if (!value) return "";
return value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
function personName(person: { first_name: string; last_name: string; username: string }) {
return [person.first_name, person.last_name].filter(Boolean).join(" ") || person.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 Topics({ tags }: { tags: Types.PostListSchema["tags"] }) {
if (!tags.length) {
return null;
}
return (
<div className="flex flex-wrap items-center justify-start gap-2" aria-label="موضوعات نوشته">
{tags.map((tag) => (
<Link key={tag.id} to={`/blog?tag=${encodeURIComponent(tag.slug)}`}>
<Badge variant="outline" className="rounded-full px-3 py-1 transition bg-slate-200 dark:bg-slate-600 hover:border-primary hover:text-primary">
<Hash className="h-3 w-3 text-primary" />
{tag.name}
</Badge>
</Link>
))}
</div>
);
}
function Breadcrumbs({ post }: { post: Types.PostDetailSchema }) {
const crumbs = post.category_path || [];
return (
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground" aria-label="مسیر نوشته">
<Link to="/blog" className="transition hover:text-primary">
بلاگ
</Link>
{crumbs.map((category) => (
<span key={category.id} className="flex items-center gap-2">
<span>/</span>
<Link to={`/blog?category=${encodeURIComponent(category.slug)}`} className="transition hover:text-primary">
{category.name}
</Link>
</span>
))}
</nav>
);
}
function WriterCards({ post }: { post: Types.PostDetailSchema }) {
const writers = post.writers?.length ? post.writers : [post.author];
return (
<section className="mt-8 rounded-[2rem] border border-border/70 bg-card/90 p-5 shadow-sm">
{/* <div className="mb-4 text-right">
<p className="text-sm font-medium text-primary">نویسندگان</p>
</div> */}
<h2 className="mb-4 text-2xl font-bold">درباره نویسندگان این مقاله</h2>
<div className="grid gap-4 md:grid-cols-2">
{writers.map((writer: Writer) => {
const image = writer.profile_picture_preview_url || writer.profile_picture_thumbnail_url || writer.profile_picture;
return (
<Link
key={writer.id}
to={`/blog?author=${encodeURIComponent(writer.username)}`}
className="flex items-start gap-4 rounded-3xl border border-border/70 bg-background/80 p-4 text-right transition hover:-translate-y-0.5 hover:border-primary/50 hover:shadow-lg"
>
<Avatar className="h-14 w-14">
<AvatarImage src={image || undefined} alt={personName(writer)} />
<AvatarFallback>{personName(writer)[0] || "ن"}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<h3 className="font-bold">{personName(writer)}</h3>
<p className="mt-2 line-clamp-4 text-sm leading-7 text-muted-foreground">
{writer.bio || "توضیحی برای این نویسنده ثبت نشده است."}
</p>
</div>
</Link>
);
})}
</div>
</section>
);
}
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">
<h2 className="mb-4 text-2xl font-bold">مقالات پیشنهادی</h2>
<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 writers = post.writers?.length ? post.writers : [post.author];
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: writers.map((writer) => ({
"@type": "Person",
name: personName(writer),
})),
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="gap-8 xl:flex xl:items-start">
<aside className="sticky top-24 max-h-[calc(100vh-7rem)] space-y-4 overflow-y-auto pr-1 hidden w-72 shrink-0 xl:block">
<section className="rounded-[1.75rem] border border-border/70 bg-card/90 p-4 shadow-sm backdrop-blur">
<h2 className="mb-3 flex items-center justify-start gap-2 text-right font-bold">
<ListTree className="h-4 w-4 text-primary" />
فهرست محتوا
</h2>
<BlogTableOfContents headings={headings} />
</section>
</aside>
<main className="min-w-0 flex-1">
<article className="overflow-hidden rounded-[2.5rem] border border-border/70 bg-card/95">
<header className="space-y-6 p-5 text-right md:p-8">
<Breadcrumbs post={post} />
<div className="space-y-4">
<h1 className="text-3xl font-black leading-[1.35] tracking-tight md:text-5xl md:leading-[1.45]">
{post.title}
</h1>
{post.excerpt ? (
<p className="text-base leading-8 text-muted-foreground md:text-lg">
{post.excerpt}
</p>
) : null}
</div>
<div className="flex flex-wrap gap-3 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1">
<Clock3 className="h-4 w-4 text-primary" />
{toPersianDigits(post.reading_time ?? 1)} دقیقه مطالعه
</span>
<time
className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1"
dateTime={post.published_at || post.created_at}
>
<CalendarDays className="h-4 w-4 text-primary" />
{formatJalaliDate(post.published_at || post.created_at)}
</time>
</div>
</header>
<div className="px-5 md:px-8">
<BlogThumbnail
post={post}
imageUrl={coverImage}
className="aspect-[16/9] rounded-[2rem]"
priority
/>
</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 flex items-center justify-start gap-2 text-right font-bold">
<ListTree className="h-4 w-4 text-primary" />
فهرست محتوا
</h2>
<BlogTableOfContents headings={headings} />
</section>
</div>
<div className="space-y-8 px-5 pb-8 pt-6 md:px-8 md:pb-10">
<Markdown content={post.content} justify size="base" className="mx-auto max-w-4xl" />
<div className="mx-auto max-w-4xl">
<Topics tags={post.tags} />
</div>
<BlogPostActions
slug={post.slug}
initialLikes={post.likes_count ?? 0}
initialSaves={post.saves_count ?? 0}
/>
</div>
</article>
<WriterCards post={post} />
<RecommendedPosts posts={recommendedPosts} />
<BlogPostInteractions
slug={post.slug}
initialComments={post.comments_count ?? 0}
/>
</main>
</div>
</div>
</div>
);
}