Files
guilan-ace-frontend/src/views/AdminBlogEditor.tsx
Amirhossein Khalili fc94ceb9f5
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
feat(ui): confirm destructive admin actions
2026-06-14 09:10:32 +03:30

517 lines
22 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 { useEffect, useMemo, useRef, useState } from "react";
import { AlertTriangle, ArrowLeft, ArrowRight, FolderUp, ImageUp, Loader2, Save, Send, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import ConfirmAction from "@/components/ConfirmAction";
import Markdown from "@/components/Markdown";
import MarkdownEditor, { type MarkdownDirectionMode } from "@/components/MarkdownEditor";
import { useAuth } from "@/contexts/AuthContext";
import { Link } from "@/lib/router";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type * as Types from "@/lib/types";
import { resolveErrorMessage } from "@/lib/utils";
import { useToast } from "@/hooks/use-toast";
type Props = {
postId: number | null;
};
const emptyForm: Types.PostCreateSchema = {
title: "",
content: "",
excerpt: "",
category_id: null,
tag_ids: [],
writer_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 { user } = useAuth();
const { toast } = useToast();
const featuredInputRef = 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 [users, setUsers] = useState<NonNullable<Types.PostListSchema["writers"]>>([]);
const [loading, setLoading] = useState(Boolean(postId));
const [saving, setSaving] = useState(false);
const [uploadingFeatured, setUploadingFeatured] = useState(false);
const [editorDirection, setEditorDirection] = useState<MarkdownDirectionMode>("auto");
const isNew = postId == null;
const featuredImage = post?.absolute_featured_image_preview_url || post?.absolute_featured_image_url || post?.featured_image;
const canPersistPost = form.title.trim() && form.content.trim();
const canAssignWriters = Boolean(user?.is_staff || user?.is_superuser || user?.can_review_blog_posts);
const reviewNote = post?.status === "changes_requested" ? post.review_note?.trim() : "";
useEffect(() => {
Promise.all([api.getCategories(), api.getTags()])
.then(([categoryData, tagData]) => {
setCategories(categoryData);
setTags(tagData);
})
.catch(() => undefined);
}, []);
useEffect(() => {
if (!canAssignWriters) return;
api.listBlogWriters()
.then((data) => setUsers(data))
.catch(() => undefined);
}, [canAssignWriters]);
useEffect(() => {
if (!postId) return;
setLoading(true);
api.getAdminBlogPost(postId)
.then((data) => {
setPost(data);
setForm({
title: data.title,
content: data.content,
excerpt: data.excerpt ?? "",
category_id: data.category?.id ?? null,
tag_ids: data.tags.map((tag) => tag.id),
writer_ids: data.writers?.map((writer) => writer.id) ?? [data.author.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 selectedTagIds = useMemo(() => form.tag_ids ?? [], [form.tag_ids]);
const selectedWriterIds = useMemo(() => form.writer_ids ?? [], [form.writer_ids]);
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);
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 ensureSavedPost = async () => {
if (post?.id) return post;
if (!canPersistPost) {
toast({
title: "ابتدا نوشته را کامل کنید",
description: "برای ذخیره پیش‌نویس و باز کردن مرکز آپلود، عنوان و متن نوشته لازم است.",
variant: "destructive",
});
return null;
}
return savePost();
};
const onFeaturedImageChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.currentTarget.value = "";
if (!file) return;
const targetPost = await ensureSavedPost();
if (!targetPost) return;
setUploadingFeatured(true);
try {
const updated = await api.uploadBlogPostFeaturedImage(targetPost.id, file);
setPost(updated);
toast({ title: "تصویر شاخص به‌روزرسانی شد", variant: "success" });
} catch (error) {
toast({
title: "آپلود تصویر شاخص ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
} finally {
setUploadingFeatured(false);
}
};
const deleteFeaturedImage = async () => {
if (!post?.id) return;
setUploadingFeatured(true);
try {
const updated = await api.deleteBlogPostFeaturedImage(post.id);
setPost(updated);
toast({ title: "تصویر شاخص حذف شد", variant: "success" });
} catch (error) {
toast({
title: "حذف تصویر شاخص ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
} finally {
setUploadingFeatured(false);
}
};
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">
<div className="rounded-3xl border bg-background/90 p-4 shadow-sm">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="text-right">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-2xl font-bold">{isNew ? "نوشته جدید" : "ویرایش نوشته"}</h2>
<Badge variant="outline">{form.status || "draft"}</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
نوشتن مارکداون، پیشنمایش زنده و تنظیمات انتشار در یک محیط مینیمال.
</p>
</div>
<div className="flex flex-wrap justify-start gap-2">
<Button variant="outline" onClick={() => router.push("/admin/blog")}>
<ArrowRight className="ml-2 h-4 w-4" />
بازگشت
</Button>
<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 || !canPersistPost}>
<Send className="ml-2 h-4 w-4" />
ارسال برای بررسی
</Button>
</div>
</div>
</div>
{reviewNote ? (
<div className="rounded-3xl border border-amber-300/70 bg-amber-50 p-5 text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-950/30 dark:text-amber-100">
<div className="flex items-start gap-3 text-right">
<AlertTriangle className="mt-1 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="space-y-2">
<p className="font-bold">این نوشته نیازمند اصلاح است</p>
<p className="text-sm leading-7">{reviewNote}</p>
</div>
</div>
</div>
) : null}
<Card>
<CardHeader className="text-right">
<CardTitle>مشخصات نوشته</CardTitle>
<CardDescription>اطلاعات اصلی، دستهبندی، نویسندگان، تصویر شاخص و فایلهای ضمیمه.</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 xl:grid-cols-[1fr_320px]">
<div 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 className="grid gap-4 md:grid-cols-2">
<div>
<Label className="mb-2 block text-right">وضعیت</Label>
<Select
value={form.status || "draft"}
onValueChange={(value) => updateForm("status", value as Types.PostCreateSchema["status"])}
disabled={!canAssignWriters}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<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="flex items-center justify-end gap-2 pt-8">
<Label>نوشته ویژه</Label>
<Checkbox checked={Boolean(form.is_featured)} onCheckedChange={(checked) => updateForm("is_featured", Boolean(checked))} />
</div>
</div>
<div>
<Label className="mb-2 block text-right">برچسبها</Label>
<div className="flex flex-wrap gap-2">
{tags.map((tag) => {
const selected = selectedTagIds.includes(tag.id);
return (
<Button
key={tag.id}
type="button"
size="sm"
variant={selected ? "default" : "outline"}
onClick={() => updateForm("tag_ids", selected ? selectedTagIds.filter((id) => id !== tag.id) : [...selectedTagIds, tag.id])}
>
{tag.name}
</Button>
);
})}
</div>
</div>
{canAssignWriters ? (
<div>
<Label className="mb-2 block text-right">نویسندگان</Label>
<div className="flex flex-wrap gap-2">
{users.map((writer) => {
const selected = selectedWriterIds.includes(writer.id);
const fullName = [writer.first_name, writer.last_name].filter(Boolean).join(" ") || writer.username;
return (
<Button
key={writer.id}
type="button"
size="sm"
variant={selected ? "default" : "outline"}
onClick={() => updateForm("writer_ids", selected ? selectedWriterIds.filter((id) => id !== writer.id) : [...selectedWriterIds, writer.id])}
>
{fullName}
</Button>
);
})}
</div>
<p className="mt-2 text-right text-xs text-muted-foreground">
مالک اصلی نوشته تغییر نمیکند؛ این گزینه فقط لیست نویسندگان عمومی را تنظیم میکند.
</p>
</div>
) : null}
</div>
<div className="space-y-4">
<input ref={featuredInputRef} type="file" accept="image/*" className="hidden" onChange={onFeaturedImageChange} />
<div className="overflow-hidden rounded-2xl border bg-muted">
{featuredImage ? (
<img src={featuredImage} alt={post?.title || form.title || "thumbnail"} className="aspect-video w-full object-cover" />
) : (
<div className="flex aspect-video items-center justify-center text-sm text-muted-foreground">
تصویری انتخاب نشده است.
</div>
)}
</div>
<div className="flex flex-wrap justify-end gap-2">
{post?.featured_image || post?.absolute_featured_image_url ? (
<ConfirmAction
title="حذف تصویر شاخص"
description="آیا از حذف تصویر شاخص این نوشته مطمئن هستید؟"
onConfirm={deleteFeaturedImage}
disabled={uploadingFeatured}
trigger={
<Button variant="outline" disabled={uploadingFeatured}>
<Trash2 className="ml-2 h-4 w-4" />
حذف تصویر
</Button>
}
/>
) : null}
<Button variant="secondary" onClick={() => featuredInputRef.current?.click()} disabled={uploadingFeatured || saving}>
{uploadingFeatured ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <ImageUp className="ml-2 h-4 w-4" />}
انتخاب تصویر شاخص
</Button>
</div>
{post?.id ? (
<Button asChild className="w-full justify-center rounded-2xl py-6" variant="outline">
<Link to={`/admin/blog/${post.id}/assets`}>
<FolderUp className="ml-2 h-4 w-4" />
رفتن به مرکز آپلود
<ArrowLeft className="mr-2 h-4 w-4" />
</Link>
</Button>
) : (
<div className="rounded-2xl border border-dashed p-4 text-right text-sm text-muted-foreground">
برای باز شدن مرکز آپلود، ابتدا نوشته را به عنوان پیشنویس ذخیره کنید.
</div>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="text-right">
<CardTitle>متن نوشته</CardTitle>
<CardDescription>ویرایشگر مارکداون در کنار پیشنمایش زنده</CardDescription>
</CardHeader>
<CardContent>
<div className="hidden grid-cols-2 gap-0 overflow-hidden bg-muted/20 md:grid">
<div className="bg-background ">
{/* <div className="border-b px-4 py-3 text-right text-sm font-medium">متن مارک‌داون</div> */}
<MarkdownEditor
value={form.content}
onChange={(value) => updateForm("content", value)}
minHeight="620px"
directionMode={editorDirection}
onDirectionModeChange={setEditorDirection}
onSave={savePost}
className="rounded-none border-0"
/>
</div>
<div className="bg-background">
{/* <div className="border-b px-4 py-3 text-right text-sm font-medium">پیش‌نمایش</div> */}
<div className="min-h-[560px] p-5">
<Markdown content={form.content || "هنوز محتوایی نوشته نشده است."} justify={false} size="base" />
</div>
</div>
</div>
<Tabs defaultValue="editor" className="md:hidden" dir="rtl">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="editor">ویرایش</TabsTrigger>
<TabsTrigger value="preview">پیشنمایش</TabsTrigger>
</TabsList>
<TabsContent value="editor">
<MarkdownEditor
value={form.content}
onChange={(value) => updateForm("content", value)}
minHeight="520px"
directionMode={editorDirection}
onDirectionModeChange={setEditorDirection}
onSave={savePost}
/>
</TabsContent>
<TabsContent value="preview">
<div className="min-h-[520px] rounded-2xl border bg-background p-4">
<Markdown content={form.content || "هنوز محتوایی نوشته نشده است."} justify={false} size="base" />
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
<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">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>
<Label className="mb-2 block text-right">OG Title</Label>
<Input value={form.og_title ?? ""} onChange={(event) => updateForm("og_title", event.target.value)} className="text-right" />
</div>
</div>
<div>
<Label className="mb-2 block text-right">OG Description</Label>
<Textarea value={form.og_description ?? ""} onChange={(event) => updateForm("og_description", event.target.value)} className="min-h-20 text-right" />
</div>
<div className="flex items-center justify-end gap-2 rounded-2xl border p-4">
<Label>noindex</Label>
<Checkbox checked={Boolean(form.noindex)} onCheckedChange={(checked) => updateForm("noindex", Boolean(checked))} />
</div>
</CardContent>
</Card>
</div>
);
}