diff --git a/src/app/admin/blog/[id]/edit/page.tsx b/src/app/admin/blog/[id]/edit/page.tsx new file mode 100644 index 0000000..914d0aa --- /dev/null +++ b/src/app/admin/blog/[id]/edit/page.tsx @@ -0,0 +1,8 @@ +import AdminBlogEditor from "@/views/AdminBlogEditor"; + +type Params = Promise<{ id: string }>; + +export default async function AdminBlogEditorPage({ params }: { params: Params }) { + const { id } = await params; + return ; +} diff --git a/src/app/admin/blog/page.tsx b/src/app/admin/blog/page.tsx new file mode 100644 index 0000000..46b2a71 --- /dev/null +++ b/src/app/admin/blog/page.tsx @@ -0,0 +1,5 @@ +import AdminBlog from "@/views/AdminBlog"; + +export default function AdminBlogPage() { + return ; +} diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index 4c2561e..82e2d15 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import Markdown from "@/components/Markdown"; +import BlogPostInteractions from "@/components/BlogPostInteractions"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -35,18 +36,22 @@ export async function generateMetadata({ const { slug } = await params; const post = await loadPost(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 || `/blog/${post.slug}`; const image = toAbsoluteUrl( - post.absolute_featured_image_url || post.featured_image, + post.og_image_url || post.absolute_featured_image_url || post.featured_image, apiBaseUrl, ) ?? `${siteUrl}/favicon.ico`; return { - title: post.title, - description, - alternates: { canonical: `/blog/${post.slug}` }, + title: metaTitle, + description: metaDescription, + alternates: { canonical }, + robots: post.noindex ? { index: false, follow: true } : undefined, openGraph: { - title: post.title, - description, + title: post.og_title || metaTitle, + description: post.og_description || metaDescription, url: `${siteUrl}/blog/${post.slug}`, siteName: "انجمن علمی کامپیوتر شرق گیلان", type: "article", @@ -57,8 +62,8 @@ export async function generateMetadata({ }, twitter: { card: "summary_large_image", - title: post.title, - description, + title: post.og_title || metaTitle, + description: post.og_description || metaDescription, images: [image], }, }; @@ -72,8 +77,9 @@ export default async function BlogDetailPage({ const { slug } = await params; const post = await loadPost(slug); const description = cleanText(post.excerpt || post.content).slice(0, 160); + const metaDescription = post.seo_description || post.og_description || description; const image = toAbsoluteUrl( - post.absolute_featured_image_url || post.featured_image, + post.og_image_url || post.absolute_featured_image_url || post.featured_image, apiBaseUrl, ) ?? `${siteUrl}/favicon.ico`; @@ -81,7 +87,7 @@ export default async function BlogDetailPage({ "@context": "https://schema.org", "@type": "BlogPosting", headline: post.title, - description, + description: metaDescription, image: [image], datePublished: post.published_at || post.created_at, dateModified: post.updated_at, @@ -150,6 +156,12 @@ export default async function BlogDetailPage({ + ); diff --git a/src/components/BlogPostInteractions.tsx b/src/components/BlogPostInteractions.tsx new file mode 100644 index 0000000..a0f6fa2 --- /dev/null +++ b/src/components/BlogPostInteractions.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Bookmark, Heart, Loader2, MessageSquare, 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 { useToast } from "@/hooks/use-toast"; + +type Props = { + slug: string; + initialLikes: number; + initialSaves: number; + initialComments: number; +}; + +function displayName(author: Types.CommentSchema["author"]) { + return [author.first_name, author.last_name].filter(Boolean).join(" ") || author.username; +} + +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 [content, setContent] = useState(""); + const [replyTo, setReplyTo] = useState(null); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + + const canModerate = Boolean(user?.is_staff || user?.is_superuser || user?.can_review_blog_posts); + + const replyTarget = useMemo( + () => comments.find((comment) => comment.id === replyTo), + [comments, replyTo], + ); + + const loadComments = async () => { + const data = await api.getComments(slug); + setComments(data); + setInteraction((prev) => ({ ...prev, comments_count: data.length })); + }; + + useEffect(() => { + let mounted = true; + Promise.all([ + api.getComments(slug), + isAuthenticated ? api.getBlogInteraction(slug).catch(() => null) : Promise.resolve(null), + ]) + .then(([commentData, interactionData]) => { + if (!mounted) return; + setComments(commentData); + if (interactionData) { + setInteraction(interactionData); + } else { + setInteraction((prev) => ({ ...prev, comments_count: commentData.length })); + } + }) + .finally(() => { + if (mounted) setLoading(false); + }); + 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)); + }; + + const submitComment = async () => { + const trimmed = content.trim(); + if (!trimmed) return; + try { + setSubmitting(true); + await api.createComment(slug, { content: trimmed, parent_id: replyTo ?? undefined }); + setContent(""); + setReplyTo(null); + await loadComments(); + toast({ title: "نظر ثبت شد", variant: "success" }); + } catch (error) { + toast({ + title: "ثبت نظر ناموفق بود", + description: resolveErrorMessage(error, "دوباره تلاش کنید"), + variant: "destructive", + }); + } finally { + setSubmitting(false); + } + }; + + const hideComment = async (commentId: number) => { + try { + await api.hideComment(commentId); + await loadComments(); + toast({ title: "نظر مخفی شد", variant: "success" }); + } catch (error) { + toast({ + title: "حذف نظر ناموفق بود", + description: resolveErrorMessage(error, "دوباره تلاش کنید"), + variant: "destructive", + }); + } + }; + + const renderComment = (comment: Types.CommentSchema, nested = false) => ( +
+
+
+
+ {canModerate ? ( + + ) : null} + {isAuthenticated ? ( + + ) : null} +
+
+

{displayName(comment.author)}

+

{formatJalali(comment.created_at, false)}

+
+
+

{comment.content}

+
+ {comment.replies?.length ? ( +
+ {comment.replies.map((reply) => renderComment(reply, true))} +
+ ) : null} +
+ ); + + return ( + + +
+
+ + +
+
+ + دیدگاه‌ها + + + + {interaction.comments_count} نظر برای این نوشته ثبت شده است. + +
+
+
+ + {!isAuthenticated ? ( +
+ برای پسندیدن، ذخیره‌کردن یا ثبت نظر باید وارد حساب شوید. + +
+ ) : ( +
+ {replyTarget ? ( +
+ + پاسخ به {displayName(replyTarget.author)} +
+ ) : null} +