feat(frontend): add blog editor and interactions
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user