feat(blog): redesign admin markdown editor
This commit is contained in:
@@ -5,6 +5,7 @@ import { ArrowLeft, ArrowRight, FolderUp, ImageUp, Loader2, Save, Send, Trash2 }
|
||||
import { useRouter } from "next/navigation";
|
||||
import Markdown from "@/components/Markdown";
|
||||
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";
|
||||
@@ -12,6 +13,7 @@ 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";
|
||||
@@ -169,13 +171,6 @@ export default function AdminBlogEditor({ postId }: Props) {
|
||||
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 file = event.target.files?.[0];
|
||||
event.currentTarget.value = "";
|
||||
@@ -227,27 +222,42 @@ export default function AdminBlogEditor({ postId }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6" dir="rtl">
|
||||
<div className="flex flex-col gap-4 md:flex-row-reverse md:items-center md:justify-between">
|
||||
<div className="text-right">
|
||||
<h2 className="text-2xl font-bold">{isNew ? "نوشته جدید" : "ویرایش نوشته"}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
متن را با مارکداون بنویسید، تصویر شاخص را تنظیم کنید و فایلهای داخل متن را از مرکز آپلود جداگانه مدیریت کنید.
|
||||
</p>
|
||||
<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 justify-end gap-2">
|
||||
<Badge variant="outline">{form.status || "draft"}</Badge>
|
||||
<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>
|
||||
<Button variant="outline" onClick={() => router.push("/admin/blog")}>
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
بازگشت
|
||||
</Button>
|
||||
</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">
|
||||
<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>
|
||||
@@ -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" />
|
||||
</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" />
|
||||
<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>noindex</Label>
|
||||
<Checkbox checked={Boolean(form.noindex)} onCheckedChange={(checked) => updateForm("noindex", Boolean(checked))} />
|
||||
<Label>نوشته ویژه</Label>
|
||||
<Checkbox checked={Boolean(form.is_featured)} onCheckedChange={(checked) => updateForm("is_featured", Boolean(checked))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -323,12 +320,7 @@ export default function AdminBlogEditor({ postId }: Props) {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selected ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
updateForm(
|
||||
"tag_ids",
|
||||
selected ? selectedTagIds.filter((id) => id !== tag.id) : [...selectedTagIds, tag.id],
|
||||
);
|
||||
}}
|
||||
onClick={() => updateForm("tag_ids", selected ? selectedTagIds.filter((id) => id !== tag.id) : [...selectedTagIds, tag.id])}
|
||||
>
|
||||
{tag.name}
|
||||
</Button>
|
||||
@@ -350,12 +342,7 @@ export default function AdminBlogEditor({ postId }: Props) {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selected ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
updateForm(
|
||||
"writer_ids",
|
||||
selected ? selectedWriterIds.filter((id) => id !== writer.id) : [...selectedWriterIds, writer.id],
|
||||
);
|
||||
}}
|
||||
onClick={() => updateForm("writer_ids", selected ? selectedWriterIds.filter((id) => id !== writer.id) : [...selectedWriterIds, writer.id])}
|
||||
>
|
||||
{fullName}
|
||||
</Button>
|
||||
@@ -367,84 +354,134 @@ export default function AdminBlogEditor({ postId }: Props) {
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>تصویر شاخص</CardTitle>
|
||||
<CardDescription>این تصویر به عنوان تامبنیل کارتهای لیست بلاگ و کاور نوشته استفاده میشود.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent 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 ? (
|
||||
<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" />}
|
||||
انتخاب تصویر
|
||||
<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 ? (
|
||||
<Button variant="outline" onClick={deleteFeaturedImage} disabled={uploadingFeatured}>
|
||||
<Trash2 className="ml-2 h-4 w-4" />
|
||||
حذف تصویر
|
||||
</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}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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" />
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</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">
|
||||
<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>
|
||||
<Card>
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>متن نوشته</CardTitle>
|
||||
<CardDescription>ویرایشگر مارکداون در کنار پیشنمایش زنده، مشابه جریان نوشتن Quera.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="hidden grid-cols-2 gap-0 overflow-hidden rounded-3xl border bg-muted/20 md:grid">
|
||||
<div className="border-l 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 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user