feat(frontend): add blog editor and interactions
This commit is contained in:
8
src/app/admin/blog/[id]/edit/page.tsx
Normal file
8
src/app/admin/blog/[id]/edit/page.tsx
Normal 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)} />;
|
||||||
|
}
|
||||||
5
src/app/admin/blog/page.tsx
Normal file
5
src/app/admin/blog/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminBlog from "@/views/AdminBlog";
|
||||||
|
|
||||||
|
export default function AdminBlogPage() {
|
||||||
|
return <AdminBlog />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
242
src/components/BlogPostInteractions.tsx
Normal file
242
src/components/BlogPostInteractions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/lib/api.ts
100
src/lib/api.ts
@@ -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');
|
||||||
|
|||||||
@@ -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
186
src/views/AdminBlog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
350
src/views/AdminBlogEditor.tsx
Normal file
350
src/views/AdminBlogEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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="پستهای لایکشده"
|
||||||
|
|||||||
Reference in New Issue
Block a user