feat(frontend): add blog editor and interactions
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-06-08 21:31:07 +03:30
parent f2b4cfce1a
commit 49dcb1dd1b
10 changed files with 1019 additions and 35 deletions

View File

@@ -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 <AdminBlogEditor postId={id === "new" ? null : Number(id)} />;
}

View File

@@ -0,0 +1,5 @@
import AdminBlog from "@/views/AdminBlog";
export default function AdminBlogPage() {
return <AdminBlog />;
}

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Markdown from "@/components/Markdown"; import Markdown from "@/components/Markdown";
import BlogPostInteractions from "@/components/BlogPostInteractions";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -35,18 +36,22 @@ export async function generateMetadata({
const { slug } = await params; const { slug } = await params;
const post = await loadPost(slug); const post = await loadPost(slug);
const description = cleanText(post.excerpt || post.content).slice(0, 160); 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( const image = toAbsoluteUrl(
post.absolute_featured_image_url || post.featured_image, post.og_image_url || post.absolute_featured_image_url || post.featured_image,
apiBaseUrl, apiBaseUrl,
) ?? `${siteUrl}/favicon.ico`; ) ?? `${siteUrl}/favicon.ico`;
return { return {
title: post.title, title: metaTitle,
description, description: metaDescription,
alternates: { canonical: `/blog/${post.slug}` }, alternates: { canonical },
robots: post.noindex ? { index: false, follow: true } : undefined,
openGraph: { openGraph: {
title: post.title, title: post.og_title || metaTitle,
description, description: post.og_description || metaDescription,
url: `${siteUrl}/blog/${post.slug}`, url: `${siteUrl}/blog/${post.slug}`,
siteName: "انجمن علمی کامپیوتر شرق گیلان", siteName: "انجمن علمی کامپیوتر شرق گیلان",
type: "article", type: "article",
@@ -57,8 +62,8 @@ export async function generateMetadata({
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: post.title, title: post.og_title || metaTitle,
description, description: post.og_description || metaDescription,
images: [image], images: [image],
}, },
}; };
@@ -72,8 +77,9 @@ export default async function BlogDetailPage({
const { slug } = await params; const { slug } = await params;
const post = await loadPost(slug); const post = await loadPost(slug);
const description = cleanText(post.excerpt || post.content).slice(0, 160); const description = cleanText(post.excerpt || post.content).slice(0, 160);
const metaDescription = post.seo_description || post.og_description || description;
const image = toAbsoluteUrl( const image = toAbsoluteUrl(
post.absolute_featured_image_url || post.featured_image, post.og_image_url || post.absolute_featured_image_url || post.featured_image,
apiBaseUrl, apiBaseUrl,
) ?? `${siteUrl}/favicon.ico`; ) ?? `${siteUrl}/favicon.ico`;
@@ -81,7 +87,7 @@ export default async function BlogDetailPage({
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "BlogPosting", "@type": "BlogPosting",
headline: post.title, headline: post.title,
description, description: metaDescription,
image: [image], image: [image],
datePublished: post.published_at || post.created_at, datePublished: post.published_at || post.created_at,
dateModified: post.updated_at, dateModified: post.updated_at,
@@ -150,6 +156,12 @@ export default async function BlogDetailPage({
<Markdown content={post.content} justify size="base" /> <Markdown content={post.content} justify size="base" />
</CardContent> </CardContent>
</Card> </Card>
<BlogPostInteractions
slug={post.slug}
initialLikes={post.likes_count ?? 0}
initialSaves={post.saves_count ?? 0}
initialComments={post.comments_count ?? 0}
/>
</div> </div>
</div> </div>
); );

View File

@@ -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<Types.CommentSchema[]>([]);
const [interaction, setInteraction] = useState<Types.BlogInteractionSchema>({
liked: false,
saved: false,
likes_count: initialLikes,
saves_count: initialSaves,
comments_count: initialComments,
});
const [content, setContent] = useState("");
const [replyTo, setReplyTo] = useState<number | null>(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) => (
<div key={comment.id} className={nested ? "mr-6 border-r pr-4" : ""}>
<div className="rounded-2xl border border-border/70 bg-background/80 p-4 text-right">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
{canModerate ? (
<Button variant="ghost" size="sm" onClick={() => hideComment(comment.id)}>
<Trash2 className="h-4 w-4" />
</Button>
) : null}
{isAuthenticated ? (
<Button variant="ghost" size="sm" onClick={() => setReplyTo(comment.id)}>
پاسخ
</Button>
) : null}
</div>
<div>
<p className="font-medium">{displayName(comment.author)}</p>
<p className="text-xs text-muted-foreground">{formatJalali(comment.created_at, false)}</p>
</div>
</div>
<p className="mt-3 whitespace-pre-wrap leading-7 text-sm">{comment.content}</p>
</div>
{comment.replies?.length ? (
<div className="mt-3 space-y-3">
{comment.replies.map((reply) => renderComment(reply, true))}
</div>
) : null}
</div>
);
return (
<Card className="mt-8 rounded-[1.75rem] border border-border/70 shadow-sm" dir="rtl">
<CardHeader className="text-right">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
<Button
variant={interaction.saved ? "default" : "outline"}
onClick={toggleSave}
disabled={!isAuthenticated}
>
<Bookmark className="ml-2 h-4 w-4" />
ذخیره
<Badge className="mr-2" variant="secondary">{interaction.saves_count}</Badge>
</Button>
<Button
variant={interaction.liked ? "default" : "outline"}
onClick={toggleLike}
disabled={!isAuthenticated}
>
<Heart className="ml-2 h-4 w-4" />
پسندیدن
<Badge className="mr-2" variant="secondary">{interaction.likes_count}</Badge>
</Button>
</div>
<div>
<CardTitle className="flex items-center justify-end gap-2">
دیدگاهها
<MessageSquare className="h-5 w-5 text-primary" />
</CardTitle>
<CardDescription className="mt-2">
{interaction.comments_count} نظر برای این نوشته ثبت شده است.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-5">
{!isAuthenticated ? (
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/20 p-5 text-center text-sm text-muted-foreground">
برای پسندیدن، ذخیرهکردن یا ثبت نظر باید وارد حساب شوید.
<Button asChild className="mr-3" size="sm">
<Link to="/auth">ورود / ثبتنام</Link>
</Button>
</div>
) : (
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
{replyTarget ? (
<div className="mb-3 flex items-center justify-between rounded-xl bg-background px-3 py-2 text-sm">
<Button variant="ghost" size="sm" onClick={() => setReplyTo(null)}>
لغو پاسخ
</Button>
<span>پاسخ به {displayName(replyTarget.author)}</span>
</div>
) : null}
<Textarea
value={content}
onChange={(event) => setContent(event.target.value)}
placeholder="نظر خود را بنویسید..."
className="min-h-28 text-right"
/>
<div className="mt-3 flex justify-end">
<Button onClick={submitComment} disabled={submitting || !content.trim()}>
{submitting ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <Send className="ml-2 h-4 w-4" />}
ثبت نظر
</Button>
</div>
</div>
)}
{loading ? (
<div className="flex justify-center py-8 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : comments.length ? (
<div className="space-y-4">{comments.map((comment) => renderComment(comment))}</div>
) : (
<div className="rounded-2xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
هنوز نظری ثبت نشده است.
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -426,19 +426,90 @@ class ApiClient {
} }
async createPost(data: Types.PostCreateSchema) { async createPost(data: Types.PostCreateSchema) {
return this.request<Types.PostDetailSchema>('/api/blog/posts', { return this.request<Types.PostDetailSchema>('/api/blog/admin/posts', {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
} }
async updatePost(slug: string, data: Types.PostCreateSchema) { async updatePost(postId: number, data: Types.PostCreateSchema) {
return this.request<Types.PostDetailSchema>(`/api/blog/posts/${slug}`, { return this.request<Types.PostDetailSchema>(`/api/blog/admin/posts/${postId}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
} }
async listAdminBlogPosts(params?: {
status?: string;
search?: string;
mine?: boolean;
limit?: number;
offset?: number;
}) {
const query = new URLSearchParams();
if (params?.status) query.set('status', params.status);
if (params?.search) query.set('search', params.search);
if (params?.mine != null) query.set('mine', String(params.mine));
if (params?.limit != null) query.set('limit', String(params.limit));
if (params?.offset != null) query.set('offset', String(params.offset));
return this.request<Types.PostListSchema[]>(`/api/blog/admin/posts${query.toString() ? `?${query.toString()}` : ''}`);
}
async getAdminBlogPost(postId: number) {
return this.request<Types.PostDetailSchema>(`/api/blog/admin/posts/${postId}`);
}
async submitBlogPost(postId: number) {
return this.request<Types.PostDetailSchema>(`/api/blog/admin/posts/${postId}/submit`, {
method: 'POST',
});
}
async reviewBlogPost(postId: number, data: Types.PostReviewSchema) {
return this.request<Types.PostDetailSchema>(`/api/blog/admin/posts/${postId}/review`, {
method: 'POST',
body: JSON.stringify(data),
});
}
async listBlogPostAssets(postId: number) {
return this.request<Types.PostAssetSchema[]>(`/api/blog/admin/posts/${postId}/assets`);
}
async uploadBlogPostAsset(
postId: number,
file: File,
data: { title?: string; alt_text?: string; caption?: string } = {},
) {
const formData = new FormData();
formData.append('file', file);
formData.append('title', data.title ?? '');
formData.append('alt_text', data.alt_text ?? '');
formData.append('caption', data.caption ?? '');
const token = this.getStorageValue('access_token');
const response = await fetch(`${this.baseUrl}/api/blog/admin/posts/${postId}/assets`, {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: formData,
});
if (!response.ok) {
const error = (await response.json().catch(() => ({}))) as ApiErrorBody;
throw new Error(error.error || error.detail || 'Asset upload failed');
}
return response.json() as Promise<Types.PostAssetSchema>;
}
async deleteBlogPostAsset(postId: number, assetId: number) {
return this.request<Types.MessageSchema>(`/api/blog/admin/posts/${postId}/assets/${assetId}`, {
method: 'DELETE',
});
}
async deletePost(slug: string) { async deletePost(slug: string) {
return this.request<Types.MessageSchema>(`/api/blog/posts/${slug}`, { return this.request<Types.MessageSchema>(`/api/blog/posts/${slug}`, {
method: 'DELETE', method: 'DELETE',
@@ -467,6 +538,13 @@ class ApiClient {
}); });
} }
async hideComment(commentId: number, note?: string) {
return this.request<Types.MessageSchema>(`/api/blog/comments/${commentId}/hide`, {
method: 'POST',
body: JSON.stringify({ note: note ?? '' }),
});
}
async listDeletedComments() { async listDeletedComments() {
return this.request<Types.CommentSchema[]>('/api/blog/deleted/comments'); return this.request<Types.CommentSchema[]>('/api/blog/deleted/comments');
} }
@@ -479,15 +557,29 @@ class ApiClient {
// Likes // Likes
async toggleLike(slug: string) { async toggleLike(slug: string) {
return this.request<Types.MessageSchema>(`/api/blog/posts/${slug}/like`, { return this.request<Types.BlogInteractionSchema>(`/api/blog/posts/${slug}/like`, {
method: 'POST', method: 'POST',
}); });
} }
async toggleSave(slug: string) {
return this.request<Types.BlogInteractionSchema>(`/api/blog/posts/${slug}/save`, {
method: 'POST',
});
}
async getBlogInteraction(slug: string) {
return this.request<Types.BlogInteractionSchema>(`/api/blog/posts/${slug}/interaction`);
}
async getLikesCount(slug: string) { async getLikesCount(slug: string) {
return this.request<Types.MessageSchema>(`/api/blog/posts/${slug}/likes`); return this.request<Types.MessageSchema>(`/api/blog/posts/${slug}/likes`);
} }
async getMyBlogActivity() {
return this.request<Types.BlogProfileActivitySchema>('/api/blog/me/activity');
}
// Categories // Categories
async getCategories() { async getCategories() {
return this.request<Types.CategorySchema[]>('/api/blog/categories'); return this.request<Types.CategorySchema[]>('/api/blog/categories');

View File

@@ -47,6 +47,9 @@ export interface UserProfileSchema {
is_committee?: boolean; is_committee?: boolean;
is_deleted?: boolean; is_deleted?: boolean;
deleted_at?: string | null; deleted_at?: string | null;
can_access_blog_admin?: boolean;
can_write_blog_posts?: boolean;
can_review_blog_posts?: boolean;
} }
export interface UserListSchema { export interface UserListSchema {
@@ -246,24 +249,70 @@ export interface PostListSchema {
created_at: string; created_at: string;
is_featured: boolean; is_featured: boolean;
reading_time?: number; reading_time?: number;
updated_at: string;
seo_title?: string;
seo_description?: string;
canonical_url?: string;
og_title?: string;
og_description?: string;
noindex?: boolean;
focus_keyword?: string;
likes_count?: number;
saves_count?: number;
comments_count?: number;
} }
export interface PostDetailSchema extends PostListSchema { export interface PostDetailSchema extends PostListSchema {
content: string; content: string;
content_html?: string; content_html?: string;
updated_at: string; og_image_url?: string | null;
views_count?: number; views_count?: number;
assets?: PostAssetSchema[];
} }
export interface PostCreateSchema { export interface PostCreateSchema {
title: string; title: string;
content: string; content: string;
summary: string; excerpt?: string;
category_id?: number; category_id?: number | null;
tag_ids?: number[]; tag_ids?: number[];
featured_image?: string;
is_featured?: boolean; is_featured?: boolean;
status?: 'draft' | 'published'; status?: 'draft' | 'submitted' | 'changes_requested' | 'published' | 'archived';
seo_title?: string;
seo_description?: string;
canonical_url?: string;
og_title?: string;
og_description?: string;
noindex?: boolean;
focus_keyword?: string;
}
export interface PostReviewSchema {
action: 'publish' | 'approve' | 'request_changes' | 'changes_requested' | 'archive';
note?: string;
}
export interface PostAssetSchema {
id: number;
file_type: 'image' | 'video' | 'document' | 'archive' | 'other';
title: string;
alt_text?: string;
caption?: string;
size: number;
mime_type?: string;
created_at: string;
absolute_file_url?: string | null;
absolute_thumbnail_url?: string | null;
absolute_preview_url?: string | null;
absolute_blur_url?: string | null;
markdown_image?: string | null;
markdown_link?: string | null;
uploaded_by: {
id: number;
username: string;
first_name: string;
last_name: string;
};
} }
export interface CommentSchema { export interface CommentSchema {
@@ -281,6 +330,8 @@ export interface CommentSchema {
parent_id?: number; parent_id?: number;
created_at: string; created_at: string;
is_approved: boolean; is_approved: boolean;
hidden_at?: string | null;
replies?: CommentSchema[];
} }
export interface CommentCreateSchema { export interface CommentCreateSchema {
@@ -288,6 +339,21 @@ export interface CommentCreateSchema {
parent_id?: number; parent_id?: number;
} }
export interface BlogInteractionSchema {
liked: boolean;
saved: boolean;
likes_count: number;
saves_count: number;
comments_count: number;
}
export interface BlogProfileActivitySchema {
liked_posts: PostListSchema[];
saved_posts: PostListSchema[];
comments: CommentSchema[];
replies: CommentSchema[];
}
export interface CategorySchema { export interface CategorySchema {
id: number; id: number;
name: string; name: string;

186
src/views/AdminBlog.tsx Normal file
View File

@@ -0,0 +1,186 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { BookOpenText, CheckCircle2, Clock3, Edit, Eye, Loader2, Plus, Send, XCircle } from "lucide-react";
import { Link } from "@/lib/router";
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 { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { formatJalali, resolveErrorMessage } from "@/lib/utils";
const statusLabels: Record<string, string> = {
draft: "پیش‌نویس",
submitted: "در انتظار بررسی",
changes_requested: "نیازمند اصلاح",
published: "منتشر شده",
archived: "آرشیو شده",
};
export default function AdminBlog() {
const { user } = useAuth();
const { toast } = useToast();
const [posts, setPosts] = useState<Types.PostListSchema[]>([]);
const [status, setStatus] = useState("all");
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [actingId, setActingId] = useState<number | null>(null);
const canReview = Boolean(user?.is_staff || user?.is_superuser || user?.can_review_blog_posts);
const loadPosts = useCallback(async () => {
setLoading(true);
try {
const data = await api.listAdminBlogPosts({
status: status === "all" ? undefined : status,
search: search.trim() || undefined,
limit: 100,
});
setPosts(data);
} catch (error) {
toast({
title: "دریافت نوشته‌ها ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
} finally {
setLoading(false);
}
}, [search, status, toast]);
useEffect(() => {
loadPosts();
}, [loadPosts]);
const stats = useMemo(() => {
return posts.reduce<Record<string, number>>((acc, post) => {
acc[post.status] = (acc[post.status] ?? 0) + 1;
return acc;
}, {});
}, [posts]);
const submitPost = async (postId: number) => {
setActingId(postId);
try {
await api.submitBlogPost(postId);
await loadPosts();
toast({ title: "نوشته برای بررسی ارسال شد", variant: "success" });
} catch (error) {
toast({ title: "ارسال ناموفق بود", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" });
} finally {
setActingId(null);
}
};
const reviewPost = async (postId: number, action: Types.PostReviewSchema["action"]) => {
setActingId(postId);
try {
await api.reviewBlogPost(postId, { action });
await loadPosts();
toast({ title: action === "publish" ? "نوشته منتشر شد" : "درخواست اصلاح ثبت شد", variant: "success" });
} catch (error) {
toast({ title: "عملیات ناموفق بود", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" });
} finally {
setActingId(null);
}
};
return (
<div className="space-y-6" dir="rtl">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<Button asChild>
<Link to="/admin/blog/new/edit">
<Plus className="ml-2 h-4 w-4" />
نوشته جدید
</Link>
</Button>
<div className="text-right">
<h2 className="text-2xl font-bold">مدیریت بلاگ</h2>
<p className="mt-1 text-sm text-muted-foreground">
پیشنویسها، صف بررسی، انتشار و اصلاح نوشتهها.
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Card><CardContent className="flex items-center justify-between p-4"><BookOpenText className="h-5 w-5 text-primary" /><span>کل: {posts.length}</span></CardContent></Card>
<Card><CardContent className="flex items-center justify-between p-4"><Clock3 className="h-5 w-5 text-amber-600" /><span>بررسی: {stats.submitted ?? 0}</span></CardContent></Card>
<Card><CardContent className="flex items-center justify-between p-4"><CheckCircle2 className="h-5 w-5 text-emerald-600" /><span>منتشر: {stats.published ?? 0}</span></CardContent></Card>
<Card><CardContent className="flex items-center justify-between p-4"><XCircle className="h-5 w-5 text-rose-600" /><span>اصلاح: {stats.changes_requested ?? 0}</span></CardContent></Card>
</div>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex gap-2">
<Button variant="outline" onClick={loadPosts}>جستجو</Button>
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="جستجو..." className="w-64 text-right" />
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-48"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">همه وضعیتها</SelectItem>
<SelectItem value="draft">پیشنویس</SelectItem>
<SelectItem value="submitted">در انتظار بررسی</SelectItem>
<SelectItem value="changes_requested">نیازمند اصلاح</SelectItem>
<SelectItem value="published">منتشر شده</SelectItem>
<SelectItem value="archived">آرشیو شده</SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-right">
<CardTitle>نوشتهها</CardTitle>
<CardDescription>دسترسی نویسندهها به نوشتههای خودشان محدود میشود.</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{loading ? (
<div className="flex justify-center py-10"><Loader2 className="h-5 w-5 animate-spin" /></div>
) : posts.length ? (
posts.map((post) => (
<div key={post.id} className="flex flex-col gap-3 rounded-2xl border p-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link to={`/blog/${post.slug}`}><Eye className="ml-2 h-4 w-4" />مشاهده</Link>
</Button>
<Button variant="secondary" size="sm" asChild>
<Link to={`/admin/blog/${post.id}/edit`}><Edit className="ml-2 h-4 w-4" />ویرایش</Link>
</Button>
{post.status === "draft" || post.status === "changes_requested" ? (
<Button size="sm" onClick={() => submitPost(post.id)} disabled={actingId === post.id}>
<Send className="ml-2 h-4 w-4" />ارسال برای بررسی
</Button>
) : null}
{canReview && post.status === "submitted" ? (
<>
<Button size="sm" onClick={() => reviewPost(post.id, "publish")} disabled={actingId === post.id}>انتشار</Button>
<Button size="sm" variant="outline" onClick={() => reviewPost(post.id, "request_changes")} disabled={actingId === post.id}>درخواست اصلاح</Button>
</>
) : null}
</div>
<div className="text-right">
<div className="flex flex-wrap items-center justify-end gap-2">
<Badge variant={post.status === "published" ? "default" : "secondary"}>{statusLabels[post.status] ?? post.status}</Badge>
<h3 className="font-semibold">{post.title}</h3>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{post.updated_at ? formatJalali(post.updated_at, false) : ""}
</p>
</div>
</div>
))
) : (
<div className="rounded-2xl border border-dashed p-8 text-center text-muted-foreground">
نوشتهای پیدا نشد.
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,350 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowRight, Copy, Loader2, Save, Send, UploadCloud } from "lucide-react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import type * as Types from "@/lib/types";
import Markdown from "@/components/Markdown";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/hooks/use-toast";
import { resolveErrorMessage } from "@/lib/utils";
type Props = {
postId: number | null;
};
const emptyForm: Types.PostCreateSchema = {
title: "",
content: "",
excerpt: "",
category_id: null,
tag_ids: [],
status: "draft",
is_featured: false,
seo_title: "",
seo_description: "",
canonical_url: "",
og_title: "",
og_description: "",
noindex: false,
focus_keyword: "",
};
export default function AdminBlogEditor({ postId }: Props) {
const router = useRouter();
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [form, setForm] = useState<Types.PostCreateSchema>(emptyForm);
const [post, setPost] = useState<Types.PostDetailSchema | null>(null);
const [categories, setCategories] = useState<Types.CategorySchema[]>([]);
const [tags, setTags] = useState<Types.TagSchema[]>([]);
const [assets, setAssets] = useState<Types.PostAssetSchema[]>([]);
const [loading, setLoading] = useState(Boolean(postId));
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const isNew = postId == null;
useEffect(() => {
Promise.all([api.getCategories(), api.getTags()])
.then(([categoryData, tagData]) => {
setCategories(categoryData);
setTags(tagData);
})
.catch(() => undefined);
}, []);
useEffect(() => {
if (!postId) return;
setLoading(true);
api.getAdminBlogPost(postId)
.then((data) => {
setPost(data);
setAssets(data.assets ?? []);
setForm({
title: data.title,
content: data.content,
excerpt: data.excerpt ?? "",
category_id: data.category?.id ?? null,
tag_ids: data.tags.map((tag) => tag.id),
status: data.status as Types.PostCreateSchema["status"],
is_featured: data.is_featured,
seo_title: data.seo_title ?? "",
seo_description: data.seo_description ?? "",
canonical_url: data.canonical_url ?? "",
og_title: data.og_title ?? "",
og_description: data.og_description ?? "",
noindex: Boolean(data.noindex),
focus_keyword: data.focus_keyword ?? "",
});
})
.catch((error) => {
toast({ title: "دریافت نوشته ناموفق بود", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" });
})
.finally(() => setLoading(false));
}, [postId, toast]);
const canUpload = useMemo(() => Boolean(post?.id), [post?.id]);
const updateForm = <K extends keyof Types.PostCreateSchema>(key: K, value: Types.PostCreateSchema[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const savePost = async () => {
setSaving(true);
try {
const payload = { ...form, status: form.status || "draft" };
const saved = isNew ? await api.createPost(payload) : await api.updatePost(postId, payload);
setPost(saved);
setAssets(saved.assets ?? []);
toast({ title: "نوشته ذخیره شد", variant: "success" });
if (isNew) {
router.replace(`/admin/blog/${saved.id}/edit`);
}
return saved;
} catch (error) {
toast({ title: "ذخیره ناموفق بود", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" });
return null;
} finally {
setSaving(false);
}
};
const submitForReview = async () => {
const saved = await savePost();
if (!saved) return;
try {
const submitted = await api.submitBlogPost(saved.id);
setPost(submitted);
updateForm("status", submitted.status as Types.PostCreateSchema["status"]);
toast({ title: "برای بررسی ارسال شد", variant: "success" });
} catch (error) {
toast({ title: "ارسال ناموفق بود", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" });
}
};
const uploadAsset = async (file: File) => {
const targetPost = post ?? (await savePost());
if (!targetPost) return;
setUploading(true);
try {
const asset = await api.uploadBlogPostAsset(targetPost.id, file, { title: file.name });
setAssets((prev) => [asset, ...prev]);
toast({ title: "فایل آپلود شد", variant: "success" });
} catch (error) {
toast({ title: "آپلود ناموفق بود", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" });
} finally {
setUploading(false);
}
};
const onFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) uploadAsset(file);
event.currentTarget.value = "";
};
const copySnippet = async (asset: Types.PostAssetSchema) => {
const snippet = asset.markdown_image || asset.markdown_link || asset.absolute_file_url || "";
await navigator.clipboard.writeText(snippet);
toast({ title: "کد مارک‌داون کپی شد", variant: "success" });
};
if (loading) {
return (
<div className="flex min-h-[50vh] items-center justify-center" dir="rtl">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6" dir="rtl">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<Button variant="outline" onClick={() => router.push("/admin/blog")}>
<ArrowRight className="ml-2 h-4 w-4" />
بازگشت
</Button>
<div className="text-right">
<h2 className="text-2xl font-bold">{isNew ? "نوشته جدید" : "ویرایش نوشته"}</h2>
<p className="mt-1 text-sm text-muted-foreground">
مارکداون بنویسید، فایلها را در مرکز آپلود همین نوشته قرار دهید، سپس برای بررسی ارسال کنید.
</p>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<Card>
<CardHeader className="text-right">
<CardTitle>محتوا و سئو</CardTitle>
<CardDescription>عنوان، متن مارکداون و متادیتای موتورهای جستجو.</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label className="mb-2 block text-right">عنوان</Label>
<Input value={form.title} onChange={(event) => updateForm("title", event.target.value)} className="text-right" />
</div>
<div>
<Label className="mb-2 block text-right">دستهبندی</Label>
<Select
value={form.category_id ? String(form.category_id) : "none"}
onValueChange={(value) => updateForm("category_id", value === "none" ? null : Number(value))}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">بدون دستهبندی</SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={String(category.id)}>{category.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="mb-2 block text-right">خلاصه</Label>
<Textarea value={form.excerpt ?? ""} onChange={(event) => updateForm("excerpt", event.target.value)} className="min-h-20 text-right" />
</div>
<div>
<Label className="mb-2 block text-right">متن مارکداون</Label>
<Textarea
value={form.content}
onChange={(event) => updateForm("content", event.target.value)}
className="min-h-[420px] font-mono text-left"
dir="ltr"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label className="mb-2 block text-right">SEO Title</Label>
<Input value={form.seo_title ?? ""} onChange={(event) => updateForm("seo_title", event.target.value)} className="text-right" maxLength={70} />
</div>
<div>
<Label className="mb-2 block text-right">Focus Keyword</Label>
<Input value={form.focus_keyword ?? ""} onChange={(event) => updateForm("focus_keyword", event.target.value)} className="text-right" />
</div>
</div>
<div>
<Label className="mb-2 block text-right">SEO Description</Label>
<Textarea value={form.seo_description ?? ""} onChange={(event) => updateForm("seo_description", event.target.value)} className="min-h-20 text-right" maxLength={170} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label className="mb-2 block text-right">Canonical URL</Label>
<Input value={form.canonical_url ?? ""} onChange={(event) => updateForm("canonical_url", event.target.value)} dir="ltr" />
</div>
<div className="flex items-center justify-end gap-2 pt-8">
<Label>noindex</Label>
<Checkbox checked={Boolean(form.noindex)} onCheckedChange={(checked) => updateForm("noindex", Boolean(checked))} />
</div>
</div>
<div>
<Label className="mb-2 block text-right">برچسبها</Label>
<div className="flex flex-wrap justify-end gap-2">
{tags.map((tag) => {
const selected = form.tag_ids?.includes(tag.id);
return (
<Button
key={tag.id}
type="button"
size="sm"
variant={selected ? "default" : "outline"}
onClick={() => {
const current = form.tag_ids ?? [];
updateForm("tag_ids", selected ? current.filter((id) => id !== tag.id) : [...current, tag.id]);
}}
>
{tag.name}
</Button>
);
})}
</div>
</div>
</CardContent>
</Card>
<div className="space-y-6">
<Card>
<CardHeader className="text-right">
<CardTitle>پیشنمایش</CardTitle>
<CardDescription>همان متن مارکداون بدون ویرایش WYSIWYG.</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-2xl border bg-background p-4">
<Markdown content={form.content || "هنوز محتوایی نوشته نشده است."} justify size="base" />
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="text-right">
<CardTitle>مرکز آپلود</CardTitle>
<CardDescription>فایلها عمومی هستند و میتوانید لینک مارکداون آنها را در متن قرار دهید.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<input ref={fileInputRef} type="file" className="hidden" onChange={onFileChange} />
<Button
variant="secondary"
onClick={() => fileInputRef.current?.click()}
disabled={uploading || (!canUpload && saving)}
>
{uploading ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <UploadCloud className="ml-2 h-4 w-4" />}
آپلود فایل
</Button>
<div className="space-y-3">
{assets.length ? assets.map((asset) => (
<div key={asset.id} className="rounded-2xl border p-3">
<div className="flex items-center justify-between gap-3">
<Button variant="ghost" size="sm" onClick={() => copySnippet(asset)}>
<Copy className="h-4 w-4" />
</Button>
<div className="text-right">
<div className="flex items-center justify-end gap-2">
<Badge variant="secondary">{asset.file_type}</Badge>
<p className="font-medium">{asset.title}</p>
</div>
<p className="mt-1 text-xs text-muted-foreground">{asset.mime_type || "file"} · {Math.ceil(asset.size / 1024)} KB</p>
</div>
</div>
{asset.absolute_preview_url ? (
<img src={asset.absolute_preview_url} alt={asset.alt_text || asset.title} className="mt-3 aspect-video w-full rounded-xl object-cover" />
) : null}
</div>
)) : (
<div className="rounded-2xl border border-dashed p-6 text-center text-sm text-muted-foreground">
هنوز فایلی برای این نوشته آپلود نشده است.
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
<div className="sticky bottom-4 z-20 flex flex-wrap justify-end gap-3 rounded-2xl border bg-background/90 p-3 shadow-lg backdrop-blur">
<Button variant="secondary" onClick={savePost} disabled={saving}>
{saving ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <Save className="ml-2 h-4 w-4" />}
ذخیره پیشنویس
</Button>
<Button onClick={submitForReview} disabled={saving || !form.title.trim() || !form.content.trim()}>
<Send className="ml-2 h-4 w-4" />
ارسال برای بررسی
</Button>
</div>
</div>
);
}

View File

@@ -1,21 +1,22 @@
"use client"; "use client";
import type { ReactNode } from 'react'; import type { ReactNode } from "react";
import { Navigate, NavLink, useLocation } from '@/lib/router'; import { useMemo } from "react";
import { useMemo } from 'react'; import { Navigate, NavLink, useLocation } from "@/lib/router";
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from "@/contexts/AuthContext";
const navItems = [ const navItems = [
{ to: '/admin/users', label: 'مدیریت کاربران' }, { to: "/admin/users", label: "مدیریت کاربران", requiresStaff: true },
{ to: '/admin/events', label: 'مدیریت رویدادها' }, { to: "/admin/events", label: "مدیریت رویدادها", requiresStaff: true },
{ to: "/admin/blog", label: "مدیریت بلاگ", requiresStaff: false },
] as const; ] as const;
export default function AdminLayout({ children }: { children: ReactNode }) { export default function AdminLayout({ children }: { children: ReactNode }) {
const location = useLocation(); const location = useLocation();
const { user, isAuthenticated, loading } = useAuth(); const { user, isAuthenticated, loading } = useAuth();
const isAdmin = useMemo( const canAccessAdmin = useMemo(
() => isAuthenticated && Boolean(user?.is_staff || user?.is_superuser), () => isAuthenticated && Boolean(user?.is_staff || user?.is_superuser || user?.can_access_blog_admin),
[isAuthenticated, user?.is_staff, user?.is_superuser], [isAuthenticated, user?.can_access_blog_admin, user?.is_staff, user?.is_superuser],
); );
if (loading) { if (loading) {
@@ -26,27 +27,34 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
); );
} }
if (!isAdmin) { if (!canAccessAdmin) {
return <Navigate to="/" replace />; return <Navigate to="/" replace />;
} }
const visibleNavItems = navItems.filter((item) => {
if (item.requiresStaff) {
return Boolean(user?.is_staff || user?.is_superuser);
}
return Boolean(user?.is_staff || user?.is_superuser || user?.can_access_blog_admin);
});
return ( return (
<div className="min-h-screen bg-background" dir="rtl"> <div className="min-h-screen bg-background" dir="rtl">
<div className="border-b bg-muted/20"> <div className="border-b bg-muted/20">
<div className="container mx-auto flex items-center justify-between px-4 py-4 gap-4 flex-row-reverse md:flex-row"> <div className="container mx-auto flex items-center justify-between px-4 py-4 gap-4 flex-row-reverse md:flex-row">
<h1 className="text-2xl font-bold">پنل مدیریت</h1> <h1 className="text-2xl font-bold">پنل مدیریت</h1>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{navItems.map((item) => ( {visibleNavItems.map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
className={({ isActive }) => className={({ isActive }) =>
[ [
'rounded-full px-4 py-2 text-sm transition', "rounded-full px-4 py-2 text-sm transition",
(isActive || location.pathname?.startsWith(item.to)) (isActive || location.pathname?.startsWith(item.to))
? 'bg-primary text-primary-foreground shadow' ? "bg-primary text-primary-foreground shadow"
: 'bg-card text-muted-foreground hover:text-foreground border', : "bg-card text-muted-foreground hover:text-foreground border",
].join(' ') ].join(" ")
} }
> >
{item.label} {item.label}

View File

@@ -123,6 +123,12 @@ export default function Profile() {
staleTime: 7 * 24 * 60 * 60 * 1000, staleTime: 7 * 24 * 60 * 60 * 1000,
}); });
const { data: blogActivity } = useQuery({
queryKey: ["my-blog-activity"],
queryFn: () => api.getMyBlogActivity(),
enabled: isAuthenticated,
});
const [me, setMe] = useState<Types.UserProfileSchema | null>(user ?? null); const [me, setMe] = useState<Types.UserProfileSchema | null>(user ?? null);
const [fetching, setFetching] = useState(false); const [fetching, setFetching] = useState(false);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
@@ -953,6 +959,15 @@ export default function Profile() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4 text-right md:col-span-2 xl:col-span-4">
<p className="font-medium">آمار واقعی فعالیت بلاگ</p>
<div className="mt-3 grid gap-3 text-sm text-muted-foreground sm:grid-cols-4">
<span>پسندیدهها: {formatNumberPersian(blogActivity?.liked_posts.length ?? 0)}</span>
<span>ذخیرهشدهها: {formatNumberPersian(blogActivity?.saved_posts.length ?? 0)}</span>
<span>نظرها: {formatNumberPersian(blogActivity?.comments.length ?? 0)}</span>
<span>پاسخها: {formatNumberPersian(blogActivity?.replies.length ?? 0)}</span>
</div>
</div>
<ActivityPlaceholderCard <ActivityPlaceholderCard
icon={Heart} icon={Heart}
title="پست‌های لایک‌شده" title="پست‌های لایک‌شده"