Files
guilan-ace-frontend/src/views/AdminBlog.tsx
Amirhossein Khalili 053b742f89
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
feat(blog): show review feedback in admin
2026-06-13 00:00:40 +03:30

331 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 BlogThumbnail from "@/components/BlogThumbnail";
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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
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 [changesPost, setChangesPost] = useState<Types.PostListSchema | null>(null);
const [changesNote, setChangesNote] = useState("");
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"], note?: string) => {
setActingId(postId);
try {
await api.reviewBlogPost(postId, { action, note });
await loadPosts();
toast({ title: action === "publish" ? "نوشته منتشر شد" : "درخواست اصلاح ثبت شد", variant: "success" });
} catch (error) {
toast({ title: "عملیات ناموفق بود", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" });
} finally {
setActingId(null);
}
};
const openChangesDialog = (post: Types.PostListSchema) => {
setChangesPost(post);
setChangesNote("");
};
const closeChangesDialog = () => {
if (actingId) return;
setChangesPost(null);
setChangesNote("");
};
const requestChanges = async () => {
if (!changesPost) return;
await reviewPost(changesPost.id, "request_changes", changesNote.trim() || undefined);
setChangesPost(null);
setChangesNote("");
};
return (
<div className="space-y-6" dir="rtl">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="text-right">
<h2 className="text-2xl font-bold">مدیریت بلاگ</h2>
<p className="mt-1 text-sm text-muted-foreground">
پیشنویسها، صف بررسی، انتشار و اصلاح نوشتهها.
</p>
</div>
<Button asChild>
<Link to="/admin/blog/new/edit">
<Plus className="ml-2 h-4 w-4" />
نوشته جدید
</Link>
</Button>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Card><CardContent className="flex items-center flex-row gap-2 p-4"><BookOpenText className="h-5 w-5 text-primary" /><span>کل: {posts.length}</span></CardContent></Card>
<Card><CardContent className="flex items-center flex-row gap-2 p-4"><Clock3 className="h-5 w-5 text-amber-600" /><span>بررسی: {stats.submitted ?? 0}</span></CardContent></Card>
<Card><CardContent className="flex items-center flex-row gap-2 p-4"><CheckCircle2 className="h-5 w-5 text-emerald-600" /><span>منتشر: {stats.published ?? 0}</span></CardContent></Card>
<Card><CardContent className="flex items-center flex-row gap-2 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="text-right">
<CardTitle>نوشتهها</CardTitle>
<CardDescription>دسترسی نویسندهها به نوشتههای خودشان محدود میشود.</CardDescription>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="جستجو..." className="w-full text-right sm:w-64" />
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-full sm: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>
</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-4 rounded-2xl border p-3 sm:p-4 md:flex-row md:items-center md:justify-between">
<div className="flex min-w-0 flex-1 items-start gap-3 md:gap-4">
<BlogThumbnail
post={post}
imageUrl={post.absolute_featured_image_thumbnail_url || post.absolute_featured_image_preview_url || post.absolute_featured_image_url || post.featured_image}
className="h-20 w-24 shrink-0 rounded-xl sm:h-24 sm:w-36 md:h-28 md:w-44"
imageClassName="group-hover:scale-100"
/>
<div className="min-w-0 flex-1 text-right">
<div className="flex flex-col-reverse flex-wrap items-start gap-2 sm:flex-row sm:items-center">
<h3 className="line-clamp-2 font-semibold leading-7">{post.title}</h3>
<Badge variant={post.status === "published" ? "default" : "secondary"}>{statusLabels[post.status] ?? post.status}</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{post.updated_at ? formatJalali(post.updated_at, false) : ""}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2 md:grid md:grid-cols-2 md:grid-rows-2" dir="ltr">
<div className="flex flex-1 basis-[calc(33.333%-0.5rem)] md:col-start-1 md:row-start-1 md:block md:basis-auto">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" asChild className="w-full">
<Link
to={`/admin/blog/${post.id}/preview`}
aria-label="پیش‌نمایش"
className="flex justify-center"
>
<Eye className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>پیشنمایش</TooltipContent>
</Tooltip>
</div>
<div className="flex flex-1 basis-[calc(33.333%-0.5rem)] md:col-start-1 md:row-start-2 md:block md:basis-auto">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="secondary" size="sm" asChild className="w-full">
<Link
to={`/admin/blog/${post.id}/edit`}
aria-label="ویرایش"
className="flex justify-center"
>
<Edit className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>ویرایش</TooltipContent>
</Tooltip>
</div>
{post.status === "draft" || post.status === "changes_requested" ? (
<div className="flex flex-1 basis-[calc(33.333%-0.5rem)] md:col-start-2 md:row-start-1 md:block md:basis-auto">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
onClick={() => submitPost(post.id)}
disabled={actingId === post.id}
aria-label="ارسال برای بررسی"
className="w-full"
>
<Send className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>ارسال برای بررسی</TooltipContent>
</Tooltip>
</div>
) : null}
{canReview && post.status === "submitted" ? (
<>
<div className="flex flex-1 basis-[calc(33.333%-0.5rem)] md:col-start-2 md:row-start-1 md:block md:basis-auto">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
onClick={() => reviewPost(post.id, "publish")}
disabled={actingId === post.id}
aria-label="انتشار"
className="w-full"
>
<CheckCircle2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>انتشار</TooltipContent>
</Tooltip>
</div>
<div className="flex flex-1 basis-[calc(33.333%-0.5rem)] md:col-start-2 md:row-start-2 md:block md:basis-auto">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={() => openChangesDialog(post)}
disabled={actingId === post.id}
aria-label="درخواست اصلاح"
className="w-full"
>
<XCircle className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>درخواست اصلاح</TooltipContent>
</Tooltip>
</div>
</>
) : null}
</div>
</div>
))
) : (
<div className="rounded-2xl border border-dashed p-8 text-center text-muted-foreground">
نوشتهای پیدا نشد.
</div>
)}
</CardContent>
</Card>
<Dialog open={Boolean(changesPost)} onOpenChange={(open) => (open ? undefined : closeChangesDialog())}>
<DialogContent className="max-w-xl rounded-3xl" dir="rtl">
<DialogHeader className="mt-6 text-right md:text-right">
<DialogTitle>درخواست اصلاح نوشته</DialogTitle>
<DialogDescription>
توضیح کوتاهی بنویسید تا نویسنده بداند چه چیزی باید در نوشته اصلاح شود.
</DialogDescription>
</DialogHeader>
<div className="rounded-2xl border bg-muted/30 p-3 text-right">
<p className="text-xs text-muted-foreground">نوشته</p>
<p className="mt-1 font-semibold">{changesPost?.title}</p>
</div>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
void requestChanges();
}}
>
<Textarea
value={changesNote}
onChange={(event) => setChangesNote(event.target.value)}
placeholder="مثلاً: بخش مقدمه نیاز به منبع دارد، تیترها را واضح‌تر کنید، یا نمونه کد را اصلاح کنید..."
className="min-h-36 text-right leading-7"
autoFocus
/>
<div className="flex flex-wrap justify-start gap-2">
<Button type="submit" disabled={!changesPost || actingId === changesPost.id}>
{actingId === changesPost?.id ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : null}
ثبت درخواست اصلاح
</Button>
<Button type="button" variant="outline" onClick={closeChangesDialog} disabled={Boolean(actingId)}>
انصراف
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
);
}