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, Trash2Icon } 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 & { body: 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 [] } const STATUS_TEXT: Record = { idle: 'ذخیره شد', saving: 'در حال ذخیره…', error: 'خطا در ذخیره', } /** * صفحه تمام‌عرض نوشتن/ویرایش مقاله — محیط نوشتن متمرکز بدون چرخه سایت. * مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش). */ export function ArticleWritePage() { const { isAuthenticated, saveStatus, runSave } = 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(null) // مقاله تازه همیشه پیش‌نویس شروع می‌شود؛ با دکمه «انتشار در سایت» عمومی می‌شود const [draft, setDraft] = useState({ id: 0, slug: `article-${Date.now()}`, title: '', date: new Date().toISOString().split('T')[0], category: ARTICLE_CATEGORIES[0], tags: [], image: '', image_alt: '', image_title: '', author: '', meta_description: '', read: '۵ دقیقه', excerpt: '', body: EMPTY_DOC, status: 'draft', seo_keyphrase: '', }) /** * فرم متادیتا با React Hook Form — منبع نمایش فیلدها. * هر تغییر، هم فرم و هم draft (مبنای ذخیره‌سازی خودکار) را به‌روز می‌کند. */ const { control, watch, reset } = useForm({ 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(null) /** * شناسه محتوای بارگذاری‌شده — با رسیدن مقاله از سرور عوض می‌شود تا * بدنه ویرایشگر TipTap با متن واقعی مقاله مقداردهی شود. */ const [loadedContentKey, setLoadedContentKey] = useState('new') const [deleting, setDeleting] = useState(false) const draftRef = useRef(draft) draftRef.current = draft const createdRef = useRef(createdId) createdRef.current = createdId const pendingRef = useRef | null>(null) const timerRef = useRef | null>(null) // اگر مقاله در داده سایت نبود (مثلاً پیش از ادغام پیش‌نویس‌ها) مستقیم از انتهای مدیریتی خوانده می‌شود useEffect(() => { if (!id || existing) return let cancelled = false adminApi .get('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 setDraft({ ...article, tags: normalizeTags(article.tags), // بدنه ذخیره‌شده (سند جدید یا پاراگراف‌های قدیمی) به سند قابل ویرایش تبدیل می‌شود body: toArticleBodyDoc(article.body), }) 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) => { 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('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) => { 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 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) => queueSave({ [name]: e.target.value }), }) const metaLength = (draft.meta_description ?? '').trim().length // متن جایگزین تصویر شاخص — با watch('title') به‌صورت زنده ساخته می‌شود const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX] .filter(Boolean) .join(' - ') return (
{/* نوار بالا */}
بازگشت به وبلاگ
{saveStatus === 'idle' && } {STATUS_TEXT[saveStatus]} {draft.status === 'draft' ? 'پیش‌نویس' : 'منتشر شده'} {createdId && ( <> دیدن در سایت )}

{!createdId ? 'نوشتن مقاله جدید — به‌صورت پیش‌نویس ذخیره می‌شود؛ با دکمه «انتشار در سایت» عمومی می‌شود' : draft.status === 'draft' ? 'پیش‌نویس — تا انتشار در سایت عمومی دیده نمی‌شود؛ تغییرها ذخیره می‌شوند' : 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود'}

{/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند */} ( { field.onChange(event.target.value) queueSave({ title: event.target.value }) }} placeholder="عنوان مقاله…" 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" /> )} />
{/* تصویر شاخص + متن جایگزین و عنوان (هر دو به فرم وصل‌اند) */}
{ 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 }} />
{/* متن */}
} onChange={(json) => { // زمان مطالعه بهصورت پویا از متن سند محاسبه میشود (۲۰۰ واژه در دقیقه) queueSave({ body: json, read: formatReadingTime(json) }) }} />
{/* توضیحات متای سئو */}

توضیحات سئو (Meta Description)

= 150 && metaLength <= 160 ? 'text-emerald-400' : 'text-ivory/40' }`} > {metaLength.toLocaleString('fa-IR')} از ۱۶۰ نویسه

این همان متنی است که در صفحه نتایج گوگل زیر لینک مقاله به کاربران نمایش داده می‌شود (۱۵۰ تا ۱۶۰ نویسه).