feat(frontend): add blog editor and interactions
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-06-08 21:31:07 +03:30
parent f2b4cfce1a
commit 49dcb1dd1b
10 changed files with 1019 additions and 35 deletions

View 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>
);
}