409 lines
17 KiB
TypeScript
409 lines
17 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
||
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 type { ArticleBodyDoc } from '../data/site'
|
||
|
||
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
|
||
|
||
type Draft = {
|
||
id: number
|
||
slug: string
|
||
title: string
|
||
title_en: string
|
||
date: string
|
||
date_en: string
|
||
category: string
|
||
category_en: string
|
||
image: string
|
||
read: string
|
||
read_en: string
|
||
excerpt: string
|
||
excerpt_en: string
|
||
body: ArticleBodyDoc
|
||
body_en: ArticleBodyDoc
|
||
status: 'draft' | 'published'
|
||
seo_keyphrase: string
|
||
}
|
||
|
||
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().toLocaleDateString('fa-IR'),
|
||
date_en: '',
|
||
category: 'یادداشت',
|
||
category_en: '',
|
||
image: '',
|
||
read: '۵ دقیقه',
|
||
read_en: '',
|
||
excerpt: '',
|
||
excerpt_en: '',
|
||
body: EMPTY_DOC,
|
||
body_en: EMPTY_DOC,
|
||
status: 'draft',
|
||
seo_keyphrase: '',
|
||
})
|
||
const [createdId, setCreatedId] = useState<number | null>(null)
|
||
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),
|
||
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc,
|
||
body_en:
|
||
rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody)
|
||
? (rawEnBody as ArticleBodyDoc)
|
||
: EMPTY_DOC,
|
||
})
|
||
setCreatedId(article.id)
|
||
// 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 }),
|
||
})
|
||
|
||
// در زبان انگلیسی، فیلدهای متنی به ستونهای _en میروند؛ اسلاگ/تصویر/وضعیت مشترکاند
|
||
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
|
||
|
||
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>
|
||
|
||
{/* عنوان — در ویرایش انگلیسی، متن فارسی فعلی جاینگهدار میماند */}
|
||
<input
|
||
type="text"
|
||
{...textField(suf('title'))}
|
||
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}
|
||
onSave={async (url) => {
|
||
queueSave({ image: url })
|
||
return true
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* متن — ویرایشگر روی سند زبان جاری کار میکند */}
|
||
<div className="mt-8">
|
||
<ArticleEditor
|
||
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
|
||
onChange={(json) => queueSave({ [suf('body')]: json })}
|
||
/>
|
||
</div>
|
||
|
||
{/* چکیده و متادیتا */}
|
||
<SeoChecklist
|
||
input={{
|
||
title: draft.title,
|
||
slug: draft.slug,
|
||
excerpt: 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>
|
||
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده میشود)</label>
|
||
<textarea
|
||
rows={2}
|
||
{...textField(suf('excerpt'))}
|
||
placeholder={editLang === 'en' ? draft.excerpt || 'English excerpt…' : undefined}
|
||
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-5 lg:grid-cols-4">
|
||
<div>
|
||
<label className="text-xs font-light text-ivory/50">دسته</label>
|
||
<input
|
||
type="text"
|
||
{...textField(suf('category'))}
|
||
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>
|
||
<label className="text-xs font-light text-ivory/50">تاریخ</label>
|
||
<input
|
||
type="text"
|
||
{...textField(suf('date'))}
|
||
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>
|
||
<label className="text-xs font-light text-ivory/50">زمان مطالعه</label>
|
||
<input
|
||
type="text"
|
||
{...textField(suf('read'))}
|
||
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>
|
||
<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>
|
||
)
|
||
}
|