517 lines
22 KiB
TypeScript
517 lines
22 KiB
TypeScript
"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>
|
||
);
|
||
}
|