feat(blog): redesign admin markdown editor

This commit is contained in:
2026-06-12 15:08:53 +03:30
parent 5fcc370611
commit 492bfd9918

View File

@@ -5,6 +5,7 @@ import { ArrowLeft, ArrowRight, FolderUp, ImageUp, Loader2, Save, Send, Trash2 }
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Markdown from "@/components/Markdown"; import Markdown from "@/components/Markdown";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { Link } from "@/lib/router";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -12,6 +13,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 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 { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type * as Types from "@/lib/types"; import type * as Types from "@/lib/types";
@@ -169,13 +171,6 @@ export default function AdminBlogEditor({ postId }: Props) {
return savePost(); return savePost();
}; };
const openUploadCenter = async () => {
const targetPost = await ensureSavedPost();
if (targetPost) {
router.push(`/admin/blog/${targetPost.id}/assets`);
}
};
const onFeaturedImageChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const onFeaturedImageChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
event.currentTarget.value = ""; event.currentTarget.value = "";
@@ -227,27 +222,42 @@ export default function AdminBlogEditor({ postId }: Props) {
} }
return ( return (
<div className="space-y-6" dir="rtl"> <div className="space-y-6">
<div className="flex flex-col gap-4 md:flex-row-reverse md:items-center md:justify-between"> <div className="rounded-3xl border bg-background/90 p-4 shadow-sm">
<div className="text-right"> <div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<h2 className="text-2xl font-bold">{isNew ? "نوشته جدید" : "ویرایش نوشته"}</h2> <div className="text-right">
<p className="mt-1 text-sm text-muted-foreground"> <div className="flex flex-wrap items-center justify-end gap-2">
متن را با مارکداون بنویسید، تصویر شاخص را تنظیم کنید و فایلهای داخل متن را از مرکز آپلود جداگانه مدیریت کنید. <Badge variant="outline">{form.status || "draft"}</Badge>
</p> <h2 className="text-2xl font-bold">{isNew ? "نوشته جدید" : "ویرایش نوشته"}</h2>
</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>
<Button variant="outline" onClick={() => router.push("/admin/blog")}>
<ArrowRight className="ml-2 h-4 w-4" />
بازگشت
</Button>
</div> </div>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]"> <Card>
<Card> <CardHeader className="text-right">
<CardHeader className="text-right"> <CardTitle>مشخصات نوشته</CardTitle>
<CardTitle>محتوا و سئو</CardTitle> <CardDescription>اطلاعات اصلی، دستهبندی، نویسندگان، تصویر شاخص و فایلهای ضمیمه.</CardDescription>
<CardDescription>عنوان، متن مارکداون و متادیتای موتورهای جستوجو.</CardDescription> </CardHeader>
</CardHeader> <CardContent className="grid gap-6 xl:grid-cols-[1fr_320px]">
<CardContent className="space-y-5"> <div className="space-y-5">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div> <div>
<Label className="mb-2 block text-right">عنوان</Label> <Label className="mb-2 block text-right">عنوان</Label>
@@ -275,40 +285,27 @@ export default function AdminBlogEditor({ postId }: Props) {
<Textarea value={form.excerpt ?? ""} onChange={(event) => updateForm("excerpt", event.target.value)} className="min-h-20 text-right" /> <Textarea value={form.excerpt ?? ""} onChange={(event) => updateForm("excerpt", event.target.value)} className="min-h-20 text-right" />
</div> </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 className="grid gap-4 md:grid-cols-2">
<div> <div>
<Label className="mb-2 block text-right">SEO Title</Label> <Label className="mb-2 block text-right">وضعیت</Label>
<Input value={form.seo_title ?? ""} onChange={(event) => updateForm("seo_title", event.target.value)} className="text-right" maxLength={70} /> <Select
</div> value={form.status || "draft"}
<div> onValueChange={(value) => updateForm("status", value as Types.PostCreateSchema["status"])}
<Label className="mb-2 block text-right">Focus Keyword</Label> disabled={!canAssignWriters}
<Input value={form.focus_keyword ?? ""} onChange={(event) => updateForm("focus_keyword", event.target.value)} className="text-right" /> >
</div> <SelectTrigger><SelectValue /></SelectTrigger>
</div> <SelectContent>
<SelectItem value="draft">پیشنویس</SelectItem>
<div> <SelectItem value="submitted">در انتظار بررسی</SelectItem>
<Label className="mb-2 block text-right">SEO Description</Label> <SelectItem value="changes_requested">نیازمند تغییر</SelectItem>
<Textarea value={form.seo_description ?? ""} onChange={(event) => updateForm("seo_description", event.target.value)} className="min-h-20 text-right" maxLength={170} /> <SelectItem value="published">منتشر شده</SelectItem>
</div> <SelectItem value="archived">آرشیو</SelectItem>
</SelectContent>
<div className="grid gap-4 md:grid-cols-2"> </Select>
<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>
<div className="flex items-center justify-end gap-2 pt-8"> <div className="flex items-center justify-end gap-2 pt-8">
<Label>noindex</Label> <Label>نوشته ویژه</Label>
<Checkbox checked={Boolean(form.noindex)} onCheckedChange={(checked) => updateForm("noindex", Boolean(checked))} /> <Checkbox checked={Boolean(form.is_featured)} onCheckedChange={(checked) => updateForm("is_featured", Boolean(checked))} />
</div> </div>
</div> </div>
@@ -323,12 +320,7 @@ export default function AdminBlogEditor({ postId }: Props) {
type="button" type="button"
size="sm" size="sm"
variant={selected ? "default" : "outline"} variant={selected ? "default" : "outline"}
onClick={() => { onClick={() => updateForm("tag_ids", selected ? selectedTagIds.filter((id) => id !== tag.id) : [...selectedTagIds, tag.id])}
updateForm(
"tag_ids",
selected ? selectedTagIds.filter((id) => id !== tag.id) : [...selectedTagIds, tag.id],
);
}}
> >
{tag.name} {tag.name}
</Button> </Button>
@@ -350,12 +342,7 @@ export default function AdminBlogEditor({ postId }: Props) {
type="button" type="button"
size="sm" size="sm"
variant={selected ? "default" : "outline"} variant={selected ? "default" : "outline"}
onClick={() => { onClick={() => updateForm("writer_ids", selected ? selectedWriterIds.filter((id) => id !== writer.id) : [...selectedWriterIds, writer.id])}
updateForm(
"writer_ids",
selected ? selectedWriterIds.filter((id) => id !== writer.id) : [...selectedWriterIds, writer.id],
);
}}
> >
{fullName} {fullName}
</Button> </Button>
@@ -367,84 +354,134 @@ export default function AdminBlogEditor({ postId }: Props) {
</p> </p>
</div> </div>
) : null} ) : null}
</CardContent> </div>
</Card>
<div className="space-y-6"> <div className="space-y-4">
<Card> <input ref={featuredInputRef} type="file" accept="image/*" className="hidden" onChange={onFeaturedImageChange} />
<CardHeader className="text-right"> <div className="overflow-hidden rounded-2xl border bg-muted">
<CardTitle>تصویر شاخص</CardTitle> {featuredImage ? (
<CardDescription>این تصویر به عنوان تامبنیل کارتهای لیست بلاگ و کاور نوشته استفاده میشود.</CardDescription> <img src={featuredImage} alt={post?.title || form.title || "thumbnail"} className="aspect-video w-full object-cover" />
</CardHeader> ) : (
<CardContent className="space-y-4"> <div className="flex aspect-video items-center justify-center text-sm text-muted-foreground">
<input ref={featuredInputRef} type="file" accept="image/*" className="hidden" onChange={onFeaturedImageChange} /> تصویری انتخاب نشده است.
<div className="overflow-hidden rounded-2xl border bg-muted"> </div>
{featuredImage ? ( )}
<img src={featuredImage} alt={post?.title || form.title || "thumbnail"} className="aspect-video w-full object-cover" /> </div>
) : ( <div className="flex flex-wrap justify-end gap-2">
<div className="flex aspect-video items-center justify-center text-sm text-muted-foreground"> {post?.featured_image || post?.absolute_featured_image_url ? (
تصویری انتخاب نشده است. <Button variant="outline" onClick={deleteFeaturedImage} disabled={uploadingFeatured}>
</div> <Trash2 className="ml-2 h-4 w-4" />
)} حذف تصویر
</div>
<div className="flex flex-wrap justify-end gap-2">
{post?.featured_image || post?.absolute_featured_image_url ? (
<Button variant="outline" onClick={deleteFeaturedImage} 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> </Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="text-right">
<CardTitle>مرکز آپلود</CardTitle>
<CardDescription>فایلهای داخل متن، تصاویر، اسناد و آرشیوها در صفحه جداگانه همین نوشته مدیریت میشوند.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button className="w-full justify-center rounded-2xl py-6" variant="outline" onClick={openUploadCenter} disabled={saving}>
<FolderUp className="ml-2 h-4 w-4" />
رفتن به مرکز آپلود
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
{!post?.id ? (
<p className="text-right text-xs text-muted-foreground">
برای نوشته جدید، ابتدا پیشنویس ذخیره میشود و سپس مرکز آپلود باز خواهد شد.
</p>
) : null} ) : null}
</CardContent> <Button variant="secondary" onClick={() => featuredInputRef.current?.click()} disabled={uploadingFeatured || saving}>
</Card> {uploadingFeatured ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <ImageUp className="ml-2 h-4 w-4" />}
انتخاب تصویر شاخص
<Card> </Button>
<CardHeader className="text-right"> </div>
<CardTitle>پیشنمایش</CardTitle> {post?.id ? (
<CardDescription>همان متن مارکداون بدون ویرایش WYSIWYG.</CardDescription> <Button asChild className="w-full justify-center rounded-2xl py-6" variant="outline">
</CardHeader> <Link to={`/admin/blog/${post.id}/assets`}>
<CardContent> <FolderUp className="ml-2 h-4 w-4" />
<div className="rounded-2xl border bg-background p-4"> رفتن به مرکز آپلود
<Markdown content={form.content || "هنوز محتوایی نوشته نشده است."} justify size="base" /> <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> </div>
</div> </CardContent>
</div> </Card>
<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"> <Card>
<Button variant="secondary" onClick={savePost} disabled={saving}> <CardHeader className="text-right">
{saving ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <Save className="ml-2 h-4 w-4" />} <CardTitle>متن نوشته</CardTitle>
ذخیره پیشنویس <CardDescription>ویرایشگر مارکداون در کنار پیشنمایش زنده، مشابه جریان نوشتن Quera.</CardDescription>
</Button> </CardHeader>
<Button onClick={submitForReview} disabled={saving || !canPersistPost}> <CardContent>
<Send className="ml-2 h-4 w-4" /> <div className="hidden grid-cols-2 gap-0 overflow-hidden rounded-3xl border bg-muted/20 md:grid">
ارسال برای بررسی <div className="border-l bg-background">
</Button> <div className="border-b px-4 py-3 text-right text-sm font-medium">پیشنمایش</div>
</div> <div className="min-h-[560px] p-5">
<Markdown content={form.content || "هنوز محتوایی نوشته نشده است."} justify={false} size="base" />
</div>
</div>
<div className="bg-background">
<div className="border-b px-4 py-3 text-right text-sm font-medium">متن مارکداون</div>
<Textarea
value={form.content}
onChange={(event) => updateForm("content", event.target.value)}
className="min-h-[620px] resize-y rounded-none border-0 font-mono text-left shadow-none focus-visible:ring-0"
dir="ltr"
/>
</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">
<Textarea
value={form.content}
onChange={(event) => updateForm("content", event.target.value)}
className="min-h-[520px] font-mono text-left"
dir="ltr"
/>
</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> </div>
); );
} }