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 (
-
+
-
-
);
diff --git a/src/components/BlogPostActions.tsx b/src/components/BlogPostActions.tsx
new file mode 100644
index 0000000..ed6fa95
--- /dev/null
+++ b/src/components/BlogPostActions.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Bookmark, Heart, Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { useAuth } from "@/contexts/AuthContext";
+import { api } from "@/lib/api";
+import type * as Types from "@/lib/types";
+import { cn, toPersianDigits } from "@/lib/utils";
+
+type BlogPostActionsProps = {
+ slug: string;
+ initialLikes: number;
+ initialSaves: number;
+ initialComments: number;
+};
+
+export default function BlogPostActions({
+ slug,
+ initialLikes,
+ initialSaves,
+ initialComments,
+}: BlogPostActionsProps) {
+ const { isAuthenticated } = useAuth();
+ const [loadingAction, setLoadingAction] = useState<"like" | "save" | null>(null);
+ const [interaction, setInteraction] = useState
({
+ liked: false,
+ saved: false,
+ likes_count: initialLikes,
+ saves_count: initialSaves,
+ comments_count: initialComments,
+ });
+
+ useEffect(() => {
+ let mounted = true;
+ if (!isAuthenticated) return;
+
+ api.getBlogInteraction(slug)
+ .then((data) => {
+ if (mounted) setInteraction(data);
+ })
+ .catch(() => undefined);
+
+ return () => {
+ mounted = false;
+ };
+ }, [isAuthenticated, slug]);
+
+ const toggleLike = async () => {
+ if (!isAuthenticated || loadingAction) return;
+ setLoadingAction("like");
+ try {
+ setInteraction(await api.toggleLike(slug));
+ } finally {
+ setLoadingAction(null);
+ }
+ };
+
+ const toggleSave = async () => {
+ if (!isAuthenticated || loadingAction) return;
+ setLoadingAction("save");
+ try {
+ setInteraction(await api.toggleSave(slug));
+ } finally {
+ setLoadingAction(null);
+ }
+ };
+
+ return (
+
+
+ {loadingAction === "like" ? (
+
+ ) : (
+
+ )}
+ {toPersianDigits(interaction.likes_count)}
+
+
+ {loadingAction === "save" ? (
+
+ ) : (
+
+ )}
+ ذخیره
+
+ {!isAuthenticated ? (
+
+ برای پسندیدن یا ذخیره کردن وارد حساب کاربری شوید.
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/BlogPostInteractions.tsx b/src/components/BlogPostInteractions.tsx
index a0f6fa2..a42fd4d 100644
--- a/src/components/BlogPostInteractions.tsx
+++ b/src/components/BlogPostInteractions.tsx
@@ -1,22 +1,19 @@
"use client";
import { useEffect, useMemo, useState } from "react";
-import { Bookmark, Heart, Loader2, MessageSquare, Send, Trash2 } from "lucide-react";
+import { Loader2, MessageSquare, Reply, Send, Trash2 } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import type * as Types from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
-import { Badge } from "@/components/ui/badge";
import { Link } from "@/lib/router";
-import { formatJalali, resolveErrorMessage } from "@/lib/utils";
+import { formatJalaliDate, resolveErrorMessage, toPersianDigits } from "@/lib/utils";
import { useToast } from "@/hooks/use-toast";
type Props = {
slug: string;
- initialLikes: number;
- initialSaves: number;
initialComments: number;
};
@@ -24,22 +21,35 @@ function displayName(author: Types.CommentSchema["author"]) {
return [author.first_name, author.last_name].filter(Boolean).join(" ") || author.username;
}
+function avatarInitial(author: Types.CommentSchema["author"]) {
+ return displayName(author).trim()[0] || "ک";
+}
+
+function countComments(comments: Types.CommentSchema[]) {
+ return comments.reduce(
+ (total, comment) => total + 1 + countComments(comment.replies || []),
+ 0,
+ );
+}
+
+function findComment(comments: Types.CommentSchema[], id: number | null): Types.CommentSchema | undefined {
+ if (!id) return undefined;
+ for (const comment of comments) {
+ if (comment.id === id) return comment;
+ const reply = findComment(comment.replies || [], id);
+ if (reply) return reply;
+ }
+ return undefined;
+}
+
export default function BlogPostInteractions({
slug,
- initialLikes,
- initialSaves,
initialComments,
}: Props) {
const { user, isAuthenticated } = useAuth();
const { toast } = useToast();
const [comments, setComments] = useState([]);
- const [interaction, setInteraction] = useState({
- liked: false,
- saved: false,
- likes_count: initialLikes,
- saves_count: initialSaves,
- comments_count: initialComments,
- });
+ const [commentCount, setCommentCount] = useState(initialComments);
const [content, setContent] = useState("");
const [replyTo, setReplyTo] = useState(null);
const [loading, setLoading] = useState(true);
@@ -48,30 +58,23 @@ export default function BlogPostInteractions({
const canModerate = Boolean(user?.is_staff || user?.is_superuser || user?.can_review_blog_posts);
const replyTarget = useMemo(
- () => comments.find((comment) => comment.id === replyTo),
+ () => findComment(comments, replyTo),
[comments, replyTo],
);
const loadComments = async () => {
const data = await api.getComments(slug);
setComments(data);
- setInteraction((prev) => ({ ...prev, comments_count: data.length }));
+ setCommentCount(countComments(data));
};
useEffect(() => {
let mounted = true;
- Promise.all([
- api.getComments(slug),
- isAuthenticated ? api.getBlogInteraction(slug).catch(() => null) : Promise.resolve(null),
- ])
- .then(([commentData, interactionData]) => {
+ api.getComments(slug)
+ .then((data) => {
if (!mounted) return;
- setComments(commentData);
- if (interactionData) {
- setInteraction(interactionData);
- } else {
- setInteraction((prev) => ({ ...prev, comments_count: commentData.length }));
- }
+ setComments(data);
+ setCommentCount(countComments(data));
})
.finally(() => {
if (mounted) setLoading(false);
@@ -79,17 +82,7 @@ export default function BlogPostInteractions({
return () => {
mounted = false;
};
- }, [isAuthenticated, slug]);
-
- const toggleLike = async () => {
- if (!isAuthenticated) return;
- setInteraction(await api.toggleLike(slug));
- };
-
- const toggleSave = async () => {
- if (!isAuthenticated) return;
- setInteraction(await api.toggleSave(slug));
- };
+ }, [slug]);
const submitComment = async () => {
const trimmed = content.trim();
@@ -100,10 +93,10 @@ export default function BlogPostInteractions({
setContent("");
setReplyTo(null);
await loadComments();
- toast({ title: "نظر ثبت شد", variant: "success" });
+ toast({ title: "کامنت ثبت شد", variant: "success" });
} catch (error) {
toast({
- title: "ثبت نظر ناموفق بود",
+ title: "ثبت کامنت ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
@@ -116,10 +109,10 @@ export default function BlogPostInteractions({
try {
await api.hideComment(commentId);
await loadComments();
- toast({ title: "نظر مخفی شد", variant: "success" });
+ toast({ title: "کامنت مخفی شد", variant: "success" });
} catch (error) {
toast({
- title: "حذف نظر ناموفق بود",
+ title: "حذف کامنت ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
@@ -127,30 +120,50 @@ export default function BlogPostInteractions({
};
const renderComment = (comment: Types.CommentSchema, nested = false) => (
-
-
-
-
- {canModerate ? (
-
hideComment(comment.id)}>
-
-
- ) : null}
+
+
+
+ {avatarInitial(comment.author)}
+
+
+
+ {displayName(comment.author)}
+ {formatJalaliDate(comment.created_at)}
+
+
+
{isAuthenticated ? (
- setReplyTo(comment.id)}>
+ setReplyTo(comment.id)}
+ >
+
پاسخ
) : null}
-
-
-
{displayName(comment.author)}
-
{formatJalali(comment.created_at, false)}
+ {canModerate ? (
+
hideComment(comment.id)}
+ >
+
+ مخفیکردن
+
+ ) : null}
-
{comment.content}
{comment.replies?.length ? (
-
+
{comment.replies.map((reply) => renderComment(reply, true))}
) : null}
@@ -158,52 +171,28 @@ export default function BlogPostInteractions({
);
return (
-
+
-
-
-
-
- ذخیره
- {interaction.saves_count}
-
-
-
- پسندیدن
- {interaction.likes_count}
-
-
-
-
- دیدگاهها
-
-
-
- {interaction.comments_count} نظر برای این نوشته ثبت شده است.
-
-
-
+
+ کامنتها
+
+
+
+ {toPersianDigits(commentCount)} کامنت برای این نوشته ثبت شده است.
+
-
+
{!isAuthenticated ? (
-
- برای پسندیدن، ذخیرهکردن یا ثبت نظر باید وارد حساب شوید.
+
+ برای ثبت کامنت باید وارد حساب کاربری شوید.
ورود / ثبتنام
) : (
-
+
{replyTarget ? (
-
+
setReplyTo(null)}>
لغو پاسخ
@@ -213,13 +202,13 @@ export default function BlogPostInteractions({
@@ -230,10 +219,10 @@ export default function BlogPostInteractions({
) : comments.length ? (
-
{comments.map((comment) => renderComment(comment))}
+
{comments.map((comment) => renderComment(comment))}
) : (
-
- هنوز نظری ثبت نشده است.
+
+ هنوز کامنتی ثبت نشده است.
)}
diff --git a/src/lib/public-api.ts b/src/lib/public-api.ts
index 185293a..60570ee 100644
--- a/src/lib/public-api.ts
+++ b/src/lib/public-api.ts
@@ -89,6 +89,16 @@ export async function getPublicPost(slug: string) {
});
}
+export async function getRecommendedPosts(slug: string, limit = 3) {
+ return requestJson
(
+ `/api/blog/posts/${encodeURIComponent(slug)}/recommended`,
+ {
+ params: { limit },
+ revalidate: DEFAULT_REVALIDATE_SECONDS,
+ },
+ );
+}
+
export async function getPublicEvents(options?: { search?: string; limit?: number }) {
const search = options?.search?.trim();