331 lines
15 KiB
TypeScript
331 lines
15 KiB
TypeScript
"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>
|
||
);
|
||
}
|