- Alt/Title تصویر شاخص اکنون ذخیره میشود (ستونهای جدید image_alt/image_title — قبلاً بیصدا دور ریخته میشد) - Alt و Title تصاویر داخل متن پس از درج هم قابل ویرایشاند (کنار کپشن) - نام فایل ذخیرهسازی خوانا: نام دلخواه هنگام درج یا نام خود فایل (بهجای UUID خالی) - پنل پرسشوپاسخ پایان مقاله (ستونهای faqs/faqs_en) با ظاهر آکاردئونی سایت و اسکیمای FAQPage - چسباندن از Outline و هر منبع خارجی: تصاویر خودکار دانلود و در Object Storage سایت میزبانی میشوند (endpoint جدید import-image با محافظ SSRF) + دکمه میزبانی مجدد روی تصاویر خارجی موجود
663 lines
29 KiB
TypeScript
663 lines
29 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
||
import { Controller, useController, useForm } from 'react-hook-form'
|
||
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
|
||
import { ArrowRightIcon, CheckIcon, EyeIcon, PlusIcon, Trash2Icon, XIcon } from 'lucide-react'
|
||
import Swal from 'sweetalert2'
|
||
import { useSiteData } from '../api'
|
||
import { useAdmin } from './AdminContext'
|
||
import { adminApi } from './adminClient'
|
||
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||
import { SeoChecklist } from '../components/admin/SeoChecklist'
|
||
import { ImageField } from '../components/admin/ImageField'
|
||
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
|
||
import { CategorySelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
||
import type { CategoryOption } from '../components/admin/LuxeSelect'
|
||
import { ARTICLE_CATEGORIES } from './entities'
|
||
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
|
||
import { formatReadingTime } from '../utils/readingTime'
|
||
import { toArticleBodyDoc } from '../utils/tiptapDoc'
|
||
import type { Article, ArticleBodyDoc } from '../data/site'
|
||
|
||
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
|
||
|
||
/** پیشنویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته دو زبانه */
|
||
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc; body_en: ArticleBodyDoc }
|
||
|
||
/**
|
||
* فیلدهای متادیتای مقاله که با React Hook Form مدیریت میشوند:
|
||
* عنوان (مبنای Alt پویا)، دستهبندی و متادیتای تصویر شاخص.
|
||
* `category` در فرم یک شیء تکمقداری {value,label} یا null است
|
||
* که هنگام ذخیره به رشته (ستون category در بکاند) نگاشت میشود.
|
||
*/
|
||
type ArticleMetaForm = {
|
||
title: string
|
||
category: CategoryOption | null
|
||
image_alt: string
|
||
image_title: string
|
||
}
|
||
|
||
/** مقدار رشتهای ذخیرهشده → گزینه react-select (یا null وقتی خالی است) */
|
||
function toCategoryOption(value: string): CategoryOption | null {
|
||
const category = (value ?? '').trim()
|
||
return category ? { label: category, value: category } : null
|
||
}
|
||
|
||
/** گزینه react-select → مقدار رشتهای ذخیرهسازی در بکاند */
|
||
function categoryValue(option: CategoryOption | null): string {
|
||
return option?.value.trim() ?? ''
|
||
}
|
||
|
||
/** دادههای قدیمی ممکن است هشتگ را رشته کاماشده داشته باشند */
|
||
function normalizeTags(value: unknown): string[] {
|
||
if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean)
|
||
if (typeof value === 'string') return value.split(',').map((t) => t.trim()).filter(Boolean)
|
||
return []
|
||
}
|
||
|
||
/** یک ردیف پرسشوپاسخ مقاله */
|
||
type ArticleFaq = { question: string; answer: string }
|
||
|
||
/**
|
||
* پنل پرسشوپاسخهای پایان مقاله — مثل بخش متا دیسکریپشن زیر ویرایشگر.
|
||
* در زبان فارسی ستون faqs و در زبان انگلیسی faqs_en را ویرایش میکند؛
|
||
* نسخه انگلیسیِ خالی در سایت به فارسی برمیگردد. ذخیرهسازی خودکار است.
|
||
*/
|
||
function FaqPanel({
|
||
faqs,
|
||
onChange,
|
||
}: {
|
||
faqs: ArticleFaq[]
|
||
onChange: (next: ArticleFaq[]) => void
|
||
}) {
|
||
const update = (index: number, patch: Partial<ArticleFaq>) => {
|
||
const next = faqs.map((f, i) => (i === index ? { ...f, ...patch } : f))
|
||
onChange(next)
|
||
}
|
||
const remove = (index: number) => onChange(faqs.filter((_, i) => i !== index))
|
||
const add = () => onChange([...faqs, { question: '', answer: '' }])
|
||
|
||
return (
|
||
<div className="mt-4 grid gap-4">
|
||
{faqs.map((faq, i) => (
|
||
<div key={i} className="grid gap-3 border border-ivory/10 bg-ink/60 p-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-[11px] font-light text-copper">پرسش {i + 1}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => remove(i)}
|
||
aria-label={`حذف پرسش ${i + 1}`}
|
||
className="flex h-7 w-7 items-center justify-center border border-red-400/40 text-red-300 transition-colors hover:bg-red-500/10"
|
||
>
|
||
<XIcon className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
dir="auto"
|
||
value={faq.question}
|
||
onChange={(e) => update(i, { question: e.target.value })}
|
||
placeholder="پرسش مخاطب…"
|
||
aria-label={`متن پرسش ${i + 1}`}
|
||
className="w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||
/>
|
||
<textarea
|
||
rows={2}
|
||
dir="auto"
|
||
value={faq.answer}
|
||
onChange={(e) => update(i, { answer: e.target.value })}
|
||
placeholder="پاسخ کوتاه و روشن…"
|
||
aria-label={`پاسخ پرسش ${i + 1}`}
|
||
className="w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||
/>
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={add}
|
||
className="flex w-fit items-center gap-2 border border-dashed border-ivory/25 px-4 py-2 text-xs font-light text-ivory/60 transition-colors hover:border-copper hover:text-copper"
|
||
>
|
||
<PlusIcon className="h-3.5 w-3.5" />
|
||
افزودن پرسشوپاسخ
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const STATUS_TEXT: Record<string, string> = {
|
||
idle: 'ذخیره شد',
|
||
saving: 'در حال ذخیره…',
|
||
error: 'خطا در ذخیره',
|
||
}
|
||
|
||
/**
|
||
* صفحه تمامعرض نوشتن/ویرایش مقاله — محیط نوشتن متمرکز بدون چرخه سایت.
|
||
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
|
||
*/
|
||
export function ArticleWritePage() {
|
||
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang } = useAdmin()
|
||
const { id } = useParams<{ id: string }>()
|
||
const navigate = useNavigate()
|
||
const { data, updateSite } = useSiteData()
|
||
|
||
const existing = id ? data?.articles.find((a) => a.id === Number(id)) : undefined
|
||
const [fetched, setFetched] = useState<Draft | null>(null)
|
||
|
||
// مقاله تازه همیشه پیشنویس شروع میشود؛ با دکمه «انتشار در سایت» عمومی میشود
|
||
const [draft, setDraft] = useState<Draft>({
|
||
id: 0,
|
||
slug: `article-${Date.now()}`,
|
||
title: '',
|
||
title_en: '',
|
||
date: new Date().toISOString().split('T')[0],
|
||
date_en: '',
|
||
category: ARTICLE_CATEGORIES[0],
|
||
category_en: '',
|
||
tags: [],
|
||
image: '',
|
||
image_alt: '',
|
||
image_title: '',
|
||
author: '',
|
||
meta_description: '',
|
||
read: '۵ دقیقه',
|
||
read_en: '',
|
||
excerpt: '',
|
||
excerpt_en: '',
|
||
body: EMPTY_DOC,
|
||
body_en: EMPTY_DOC,
|
||
status: 'draft',
|
||
seo_keyphrase: '',
|
||
})
|
||
|
||
/**
|
||
* فرم متادیتا با React Hook Form — منبع نمایش فیلدها.
|
||
* هر تغییر، هم فرم و هم draft (مبنای ذخیرهسازی خودکار) را بهروز میکند.
|
||
*/
|
||
const { control, watch, reset } = useForm<ArticleMetaForm>({
|
||
defaultValues: {
|
||
title: '',
|
||
category: toCategoryOption(ARTICLE_CATEGORIES[0]),
|
||
image_alt: '',
|
||
image_title: '',
|
||
},
|
||
mode: 'onChange',
|
||
})
|
||
/** عنوان زنده مقاله — مبنای متن جایگزین (Alt) تصویر شاخص */
|
||
const watchedTitle = watch('title')
|
||
const { field: imageAltField } = useController({ name: 'image_alt', control })
|
||
const { field: imageTitleField } = useController({ name: 'image_title', control })
|
||
|
||
const [createdId, setCreatedId] = useState<number | null>(null)
|
||
/**
|
||
* شناسه محتوای بارگذاریشده — با رسیدن مقاله از سرور عوض میشود تا
|
||
* بدنه ویرایشگر TipTap با متن واقعی مقاله مقداردهی شود.
|
||
*/
|
||
const [loadedContentKey, setLoadedContentKey] = useState<string | number>('new')
|
||
const [deleting, setDeleting] = useState(false)
|
||
const draftRef = useRef(draft)
|
||
draftRef.current = draft
|
||
const createdRef = useRef(createdId)
|
||
createdRef.current = createdId
|
||
const pendingRef = useRef<Record<string, unknown> | null>(null)
|
||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
|
||
// اگر مقاله در داده سایت نبود (مثلاً پیش از ادغام پیشنویسها) مستقیم از انتهای مدیریتی خوانده میشود
|
||
useEffect(() => {
|
||
if (!id || existing) return
|
||
let cancelled = false
|
||
adminApi
|
||
.get<Draft>('articles', Number(id))
|
||
.then((a) => {
|
||
if (!cancelled) setFetched(a)
|
||
})
|
||
.catch(() => {
|
||
/* مقاله وجود ندارد — فرم «مقاله جدید» میماند */
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [id, existing])
|
||
|
||
const article = existing ?? fetched
|
||
|
||
// وقتی مقاله موجود شناخته شد، فرم و شناسه ساختهشده یکبار مقداردهی میشود
|
||
useEffect(() => {
|
||
if (!article || createdRef.current) return
|
||
const rawEnBody = (article as unknown as { body_en?: unknown }).body_en
|
||
setDraft({
|
||
...(article as unknown as Draft),
|
||
tags: normalizeTags((article as unknown as { tags?: unknown }).tags),
|
||
// بدنه ذخیرهشده (سند جدید یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||
body: toArticleBodyDoc(article.body),
|
||
body_en:
|
||
rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody)
|
||
? (rawEnBody as ArticleBodyDoc)
|
||
: EMPTY_DOC,
|
||
})
|
||
setCreatedId(article.id)
|
||
// بدنه ویرایشگر با محتوای بارگذاریشده همگام میشود
|
||
setLoadedContentKey(article.id)
|
||
// فرم متادیتا هم با مقادیر مقاله بارگذاریشده همگام میشود
|
||
reset({
|
||
title: article.title ?? '',
|
||
category: toCategoryOption(article.category ?? ''),
|
||
image_alt: article.image_alt ?? '',
|
||
image_title: article.image_title ?? '',
|
||
})
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [article?.id])
|
||
|
||
const doSave = async (changes: Record<string, unknown>) => {
|
||
if (createdRef.current) {
|
||
await adminApi.patch('articles', createdRef.current, changes)
|
||
updateSite((d) => {
|
||
const a = d.articles.find((x) => x.id === createdRef.current)
|
||
if (a) Object.assign(a, changes)
|
||
})
|
||
} else {
|
||
// اولین ذخیره = ساخت پیشنویس: مقاله ساخته میشود اما تا «انتشار» در سایت عمومی دیده نمیشود
|
||
const payload = { ...draftRef.current, ...changes }
|
||
if (!payload.title) payload.title = 'بدون عنوان'
|
||
const created = await adminApi.create<Draft & { id: number }>('articles', payload)
|
||
setCreatedId(created.id)
|
||
updateSite((d) => {
|
||
d.articles.unshift(created as never)
|
||
})
|
||
navigate(`/admin/write/${created.id}`, { replace: true })
|
||
}
|
||
}
|
||
|
||
const flush = useCallback(async () => {
|
||
if (timerRef.current) {
|
||
clearTimeout(timerRef.current)
|
||
timerRef.current = null
|
||
}
|
||
const changes = pendingRef.current
|
||
if (!changes) return
|
||
pendingRef.current = null
|
||
await doSave(changes)
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [])
|
||
|
||
const queueSave = (changes: Record<string, unknown>) => {
|
||
pendingRef.current = { ...(pendingRef.current ?? {}), ...changes }
|
||
setDraft((d) => ({ ...d, ...changes }) as Draft)
|
||
if (timerRef.current) clearTimeout(timerRef.current)
|
||
timerRef.current = setTimeout(() => void flush(), 800)
|
||
}
|
||
|
||
// ذخیره تغییرات در انتظار هنگام ترک صفحه — فقط برای مقاله ساختهشده
|
||
// (create در unmount ممنوع: با draft قدیمی، مقاله سرگردانی میسازد)
|
||
useEffect(() => {
|
||
return () => {
|
||
if (timerRef.current) clearTimeout(timerRef.current)
|
||
const changes = pendingRef.current
|
||
if (!changes || !createdRef.current) return
|
||
void adminApi.patch('articles', createdRef.current, changes).catch(() => {
|
||
/* صفحه در حال بستن است */
|
||
})
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [])
|
||
|
||
const forceSave = () => void runSave(flush)
|
||
|
||
// محافظ مسیر — پس از همه هوکها تا ترتیب فراخوانی هوکها همیشه یکسان بماند
|
||
if (!isAuthenticated) return <Navigate to="/admin/login" replace />
|
||
|
||
const remove = async () => {
|
||
if (!createdId) return
|
||
const result = await Swal.fire({
|
||
icon: 'warning',
|
||
title: 'حذف مقاله',
|
||
text: 'این مقاله برای همیشه حذف شود؟',
|
||
background: '#1e1a16',
|
||
color: '#F5F3EF',
|
||
iconColor: '#d5874c',
|
||
showCancelButton: true,
|
||
confirmButtonText: 'حذف',
|
||
cancelButtonText: 'انصراف',
|
||
buttonsStyling: false,
|
||
customClass: {
|
||
popup: 'font-pinar border border-ivory/10',
|
||
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
|
||
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
|
||
},
|
||
})
|
||
if (!result.isConfirmed) return
|
||
setDeleting(true)
|
||
await runSave(async () => {
|
||
await adminApi.remove('articles', createdId)
|
||
navigate('/blog')
|
||
})
|
||
setDeleting(false)
|
||
}
|
||
|
||
const textField = (name: keyof Draft) => ({
|
||
value: String(draft[name] ?? ''),
|
||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||
queueSave({ [name]: e.target.value }),
|
||
})
|
||
|
||
const metaLength = (draft.meta_description ?? '').trim().length
|
||
// در زبان انگلیسی، فیلدهای متنی به ستونهای _en میروند؛ اسلاگ/تصویر/وضعیت مشترکاند
|
||
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
|
||
|
||
// متن جایگزین تصویر شاخص — با watch('title') بهصورت زنده ساخته میشود
|
||
const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX]
|
||
.filter(Boolean)
|
||
.join(' - ')
|
||
|
||
return (
|
||
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
|
||
{/* نوار بالا */}
|
||
<header className="sticky top-0 z-40 border-b border-ivory/10 bg-ink/95 backdrop-blur-md">
|
||
<div className="mx-auto flex max-w-4xl items-center justify-between gap-4 px-6 py-3">
|
||
<Link
|
||
to="/blog"
|
||
data-admin-link
|
||
className="flex items-center gap-2 text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
||
>
|
||
<ArrowRightIcon className="h-4 w-4" />
|
||
بازگشت به وبلاگ
|
||
</Link>
|
||
|
||
<div className="flex items-center gap-4">
|
||
<button
|
||
type="button"
|
||
onClick={toggleEditLang}
|
||
title="تعیین میکند متن مقاله روی کدام زبان ذخیره شود"
|
||
className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${
|
||
editLang === 'en'
|
||
? 'bg-copper text-char'
|
||
: 'border border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
|
||
}`}
|
||
>
|
||
{editLang === 'fa' ? 'ویرایش: فارسی' : 'Edit: English'}
|
||
</button>
|
||
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
||
{saveStatus === 'idle' && <CheckIcon className="h-3.5 w-3.5 text-emerald-400" />}
|
||
{STATUS_TEXT[saveStatus]}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={forceSave}
|
||
className="border border-copper/50 px-5 py-2 text-xs font-bold text-copper transition-colors duration-200 hover:bg-copper hover:text-char"
|
||
>
|
||
ذخیره
|
||
</button>
|
||
<span
|
||
className={`border px-3 py-2 text-xs font-light ${
|
||
draft.status === 'draft'
|
||
? 'border-amber-400/50 text-amber-300'
|
||
: 'border-emerald-400/50 text-emerald-300'
|
||
}`}
|
||
>
|
||
{draft.status === 'draft' ? 'پیشنویس' : 'منتشر شده'}
|
||
</span>
|
||
{createdId && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => queueSave({ status: draft.status === 'draft' ? 'published' : 'draft' })}
|
||
className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${
|
||
draft.status === 'draft'
|
||
? 'bg-copper text-char hover:scale-105'
|
||
: 'border border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
|
||
}`}
|
||
>
|
||
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیشنویس'}
|
||
</button>
|
||
<Link
|
||
to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`}
|
||
data-admin-link
|
||
className="flex items-center gap-1.5 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/70 transition-colors hover:border-copper hover:text-copper"
|
||
>
|
||
<EyeIcon className="h-3.5 w-3.5" />
|
||
دیدن در سایت
|
||
</Link>
|
||
<button
|
||
type="button"
|
||
onClick={remove}
|
||
disabled={deleting}
|
||
aria-label="حذف مقاله"
|
||
className="flex h-8 w-8 items-center justify-center border border-red-400/40 text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
||
>
|
||
<Trash2Icon className="h-3.5 w-3.5" />
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="mx-auto max-w-4xl px-6 pb-32 pt-10">
|
||
<p className="text-xs font-light text-copper">
|
||
{!createdId
|
||
? 'نوشتن مقاله جدید — بهصورت پیشنویس ذخیره میشود؛ با دکمه «انتشار در سایت» عمومی میشود'
|
||
: draft.status === 'draft'
|
||
? 'پیشنویس — تا انتشار در سایت عمومی دیده نمیشود؛ تغییرها ذخیره میشوند'
|
||
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال میشود'}
|
||
</p>
|
||
|
||
{/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند؛
|
||
در ویرایش انگلیسی همان مقدار در ستون title_en ذخیره میشود */}
|
||
<Controller
|
||
name="title"
|
||
control={control}
|
||
render={({ field }) => (
|
||
<input
|
||
type="text"
|
||
{...field}
|
||
onChange={(event) => {
|
||
field.onChange(event.target.value)
|
||
queueSave({ [suf('title')]: event.target.value })
|
||
}}
|
||
placeholder={editLang === 'en' ? draft.title || 'English article title…' : 'عنوان مقاله…'}
|
||
aria-label="عنوان مقاله"
|
||
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
|
||
/>
|
||
)}
|
||
/>
|
||
<div className="mt-4 h-px w-28 bg-copper" />
|
||
|
||
{/* تصویر شاخص + متن جایگزین و عنوان (هر دو به فرم وصلاند) */}
|
||
<div className="mt-8">
|
||
<ImageField
|
||
label="تصویر شاخص مقاله"
|
||
value={draft.image}
|
||
alt={imageAltField.value}
|
||
altFallback={imageAltFallback}
|
||
title={imageTitleField.value}
|
||
onSave={async (url) => {
|
||
queueSave({ image: url })
|
||
return true
|
||
}}
|
||
onSaveAlt={async (alt) => {
|
||
imageAltField.onChange(alt)
|
||
queueSave({ image_alt: alt })
|
||
return true
|
||
}}
|
||
onSaveTitle={async (imageTitle) => {
|
||
imageTitleField.onChange(imageTitle)
|
||
queueSave({ image_title: imageTitle })
|
||
return true
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* متن — ویرایشگر روی سند زبان جاری کار میکند */}
|
||
<div className="mt-8">
|
||
<ArticleEditor
|
||
contentKey={loadedContentKey}
|
||
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
|
||
onChange={(json) => {
|
||
// زمان مطالعه بهصورت پویا از متن سند محاسبه میشود — به زبان جاری
|
||
const minutes = formatReadingTime(json)
|
||
if (editLang === 'en') {
|
||
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
|
||
} else {
|
||
queueSave({ body: json, read: `${minutes} دقیقه` })
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* توضیحات متای سئو */}
|
||
<section className="mt-8 border border-ivory/15 bg-char p-6">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<h2 className="text-sm font-bold text-copper">توضیحات سئو (Meta Description)</h2>
|
||
<span
|
||
className={`text-[11px] font-light ${
|
||
metaLength >= 150 && metaLength <= 160 ? 'text-emerald-400' : 'text-ivory/40'
|
||
}`}
|
||
>
|
||
{metaLength.toLocaleString('fa-IR')} از ۱۶۰ نویسه
|
||
</span>
|
||
</div>
|
||
<p className="mt-1 text-xs font-light text-ivory/50">
|
||
این همان متنی است که در صفحه نتایج گوگل زیر لینک مقاله به کاربران نمایش داده میشود (۱۵۰ تا ۱۶۰ نویسه).
|
||
</p>
|
||
<textarea
|
||
id="meta-description"
|
||
rows={3}
|
||
value={draft.meta_description ?? ''}
|
||
onChange={(e) => queueSave({ meta_description: e.target.value })}
|
||
placeholder="توضیح جذابی برای گوگل بنویسید که شامل کلمه کلیدی باشد…"
|
||
className="mt-3 w-full resize-y border border-ivory/15 bg-ink px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||
/>
|
||
</section>
|
||
|
||
{/* پرسشوپاسخهای پایان مقاله — سئوی FAQPage */}
|
||
<section className="mt-8 border border-ivory/15 bg-char p-6">
|
||
<h2 className="text-sm font-bold text-copper">پرسشهای متداول مقاله</h2>
|
||
<p className="mt-1 text-xs font-light text-ivory/50">
|
||
در پایان مقاله با ظاهر آکاردئونی سایت نمایش داده میشود و با اسکیمای FAQPage
|
||
شانس نمایش در نتایج غنی گوگل را بالا میبرد.
|
||
{editLang === 'en' && ' — ویرایش نسخه انگلیسی (خالی = نمایش متن فارسی)'}
|
||
</p>
|
||
<FaqPanel
|
||
faqs={(editLang === 'en' ? draft.faqs_en : draft.faqs) ?? []}
|
||
onChange={(next) => queueSave({ [suf('faqs')]: next })}
|
||
/>
|
||
</section>
|
||
|
||
{/* چکیده و متادیتا */}
|
||
<SeoChecklist
|
||
input={{
|
||
title: draft.title,
|
||
slug: draft.slug,
|
||
excerpt: draft.meta_description || draft.excerpt,
|
||
body: draft.body,
|
||
keyphrase: draft.seo_keyphrase,
|
||
}}
|
||
/>
|
||
|
||
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="جزئیات مقاله">
|
||
<h2 className="text-sm font-bold text-copper">جزئیات انتشار، دستهبندی و هشتگها</h2>
|
||
<div className="mt-5 grid gap-5">
|
||
<div>
|
||
<label htmlFor="seo-keyphrase" className="text-xs font-light text-ivory/50">کلمه کلیدی هدف (فقط برای راهنمای سئو — در سایت نمایش داده نمیشود)</label>
|
||
<input
|
||
id="seo-keyphrase"
|
||
type="text"
|
||
value={draft.seo_keyphrase}
|
||
onChange={(e) => queueSave({ seo_keyphrase: e.target.value })}
|
||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||
<div>
|
||
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده میشود)</label>
|
||
<textarea
|
||
rows={2}
|
||
{...textField(suf('excerpt'))}
|
||
placeholder={editLang === 'en' ? draft.excerpt || 'English excerpt…' : 'خلاصه کوتاه مقاله برای کارتهای وبلاگ…'}
|
||
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="article-author" className="text-xs font-light text-ivory/50">نویسنده</label>
|
||
<input
|
||
id="article-author"
|
||
type="text"
|
||
{...textField('author')}
|
||
placeholder="نام و نام خانوادگی نویسنده"
|
||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||
<div>
|
||
<label className="text-xs font-light text-ivory/50">دستهبندی مقاله (Category)</label>
|
||
<div className="mt-2">
|
||
{/* تکانتخابی: ورودی/خروجی Controller یک {value,label} یا null است */}
|
||
<Controller
|
||
name="category"
|
||
control={control}
|
||
render={({ field }) => (
|
||
<CategorySelect
|
||
value={field.value}
|
||
options={ARTICLE_CATEGORIES}
|
||
onChange={(option) => {
|
||
field.onChange(option)
|
||
queueSave({ category: categoryValue(option) })
|
||
}}
|
||
/>
|
||
)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-light text-ivory/50">هشتگها / برچسبها</label>
|
||
<div className="mt-2">
|
||
<LuxeTagsSelect
|
||
value={normalizeTags(draft.tags)}
|
||
onChange={(tags) => queueSave({ tags })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
||
<div>
|
||
<label htmlFor="article-date" className="text-xs font-light text-ivory/50">تاریخ انتشار (شمسی)</label>
|
||
<div className="mt-2">
|
||
<JalaliDatePicker
|
||
id="article-date"
|
||
value={draft.date ?? ''}
|
||
onChange={(isoDate) => queueSave({ date: isoDate })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-light text-ivory/50">زمان مطالعه (محاسبه خودکار)</label>
|
||
<input
|
||
type="text"
|
||
disabled={editLang !== 'en'}
|
||
{...(editLang === 'en' ? textField('read_en') : { value: String(draft.read ?? '') })}
|
||
className={`mt-2 w-full text-sm font-light ${
|
||
editLang === 'en'
|
||
? 'border border-ivory/15 bg-char px-3 py-2 text-ivory focus:border-copper focus:outline-none'
|
||
: 'cursor-not-allowed border border-ivory/15 bg-char/50 px-3 py-2 text-ivory/50'
|
||
}`}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-light text-ivory/50">نشانی (slug)</label>
|
||
<input
|
||
type="text"
|
||
dir="ltr"
|
||
{...textField('slug')}
|
||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|