وبلاگ: صفحه تمام‌عرض «نوشتن مقاله» — محیط نوشتن متمرکز برای ادمین

- /admin/write (مقاله جدید) و /admin/write/:id (ویرایش) — صفحه کامل
  بدون چرخه سایت: نوار بالا (بازگشت، وضعیت ذخیره، ذخیره، دیدن در سایت،
  حذف)، عنوان بزرگ، تصویر شاخص، ویرایشگر TipTap تمام‌ارتفاع و بخش
  جزئیات انتشار (چکیده، دسته، تاریخ، زمان مطالعه، slug)
- ذخیره خودکار با debounce + flush هنگام خروج؛ اولین ذخیره مقاله را
  منتشر می‌کند و مسیر به /admin/write/:id تغییر می‌کند
- قلم‌ویژه مقاله‌ها (مداد روی کارت) ادمین را به همین صفحه می‌برد
  (editHref روی EditableItem)؛ لینک‌های data-admin-link در حالت ویرایش
  کار می‌کنند
- رفع: create-on-unmount حذف شد (با draft قدیمی مقاله سرگردانی
  می‌ساخت) — unmount فقط برای مقاله ساخته‌شده PATCH می‌زند
This commit is contained in:
MMD
2026-09-07 02:35:41 +03:30
parent 2c96562b66
commit 17345daa81
6 changed files with 344 additions and 15 deletions
+8 -5
View File
@@ -19,6 +19,7 @@ import { SiteProvider, useSiteData } from './api';
import { AdminProvider, useAdmin } from './admin/AdminContext';
import { AdminBar } from './admin/AdminBar';
import { AdminLoginPage } from './admin/AdminLoginPage';
import { ArticleWritePage } from './admin/ArticleWritePage';
function ScrollToTop() {
const { pathname } = useLocation();
@@ -41,6 +42,8 @@ function AnimatedRoutes() {
<Routes location={location}>
<Route path="/admin/login" element={<AdminLoginPage />} />
<Route path="/admin/write" element={<ArticleWritePage />} />
<Route path="/admin/write/:id" element={<ArticleWritePage />} />
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/services" element={<ServicesPage />} />
@@ -61,15 +64,15 @@ function AnimatedRoutes() {
function AppChrome() {
const { pathname } = useLocation();
const { isAuthenticated } = useAdmin();
const isLoginPage = pathname === '/admin/login';
const isAdminPage = pathname.startsWith('/admin');
return (
<>
{!isLoginPage && <Header />}
{!isAdminPage && <Header />}
<AnimatedRoutes />
{!isLoginPage && <Footer />}
{!isLoginPage && <FloatingContact />}
{isAuthenticated && <AdminBar />}
{!isAdminPage && <Footer />}
{!isAdminPage && <FloatingContact />}
{isAuthenticated && !isAdminPage && <AdminBar />}
</>
);
}
+3 -2
View File
@@ -100,8 +100,9 @@ export function AdminProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (!isEditMode) return
const onClick = (e: MouseEvent) => {
const target = e.target as HTMLElement | null
if (target?.closest('a')) e.preventDefault()
const anchor = (e.target as HTMLElement | null)?.closest('a')
// لینک‌های خود لایه مدیریت (مثل «نوشتن مقاله جدید») کار می‌کنند
if (anchor && !anchor.dataset.adminLink) e.preventDefault()
}
document.addEventListener('click', onClick, true)
return () => document.removeEventListener('click', onClick, true)
+301
View File
@@ -0,0 +1,301 @@
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 { 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
date: string
category: string
image: string
read: string
excerpt: string
body: ArticleBodyDoc
}
const STATUS_TEXT: Record<string, string> = {
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 [draft, setDraft] = useState<Draft>(() =>
existing
? {
...existing,
body: (Array.isArray(existing.body) ? EMPTY_DOC : existing.body) as ArticleBodyDoc,
}
: {
id: 0,
slug: `article-${Date.now()}`,
title: '',
date: new Date().toLocaleDateString('fa-IR'),
category: 'یادداشت',
image: '',
read: '۵ دقیقه',
excerpt: '',
body: EMPTY_DOC,
},
)
const [createdId, setCreatedId] = useState<number | null>(existing?.id ?? 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)
if (!isAuthenticated) return <Navigate to="/admin/login" replace />
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)
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 }),
})
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">
<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>
{createdId && (
<>
<Link
to={`/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 ? 'ویرایش مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود' : 'نوشتن مقاله جدید — با اولین ذخیره منتشر می‌شود'}
</p>
{/* عنوان */}
<input
type="text"
{...textField('title')}
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"
/>
<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={draft.body as unknown as Record<string, unknown>}
onChange={(json) => queueSave({ body: json })}
/>
</div>
{/* چکیده و متادیتا */}
<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 className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده می‌شود)</label>
<textarea
rows={2}
{...textField('excerpt')}
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('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('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('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>
)
}
+16
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'
import { Link } from 'react-router-dom'
import Swal from 'sweetalert2'
import { useSiteData } from '../api'
import type { SiteData } from '../api'
@@ -59,11 +60,14 @@ export function EditableItem({
item,
children,
className = '',
editHref,
}: {
resource: string
item: ItemWithId
children: ReactNode
className?: string
/** در صورت ارسال، دکمه ویرایش به این مسیر هدایت می‌کند (مثل صفحه نوشتن مقاله) */
editHref?: string
}) {
const { isAuthenticated, isEditMode, runSave } = useAdmin()
const { reload } = useSiteData()
@@ -115,9 +119,21 @@ export function EditableItem({
return (
<div className={`relative group/editable ${className}`}>
<div className="absolute left-2 top-2 z-30 flex flex-col gap-1.5 opacity-0 transition-opacity duration-150 group-hover/editable:opacity-100">
{editHref ? (
<Link
to={editHref}
data-admin-link
title="ویرایش"
aria-label="ویرایش"
className="flex h-7 w-7 items-center justify-center border border-copper/50 bg-ink/90 text-copper backdrop-blur-sm transition-colors duration-150 hover:bg-copper hover:text-char"
>
<PencilIcon className="h-3.5 w-3.5" />
</Link>
) : (
<PillButton onClick={() => setDrawerOpen(true)} label="ویرایش">
<PencilIcon className="h-3.5 w-3.5" />
</PillButton>
)}
{index > 0 && (
<PillButton onClick={() => move(-1)} label="انتقال به قبل">
<ArrowUpIcon className="h-3.5 w-3.5" />
+1 -1
View File
@@ -38,7 +38,7 @@ export function Articles() {
<div className="mt-14 grid gap-6 lg:grid-cols-3">
{articles.slice(0, 3).map((article, i) =>
<Reveal key={article.id} delay={0.06 * i} className="h-full">
<EditableItem resource="articles" item={article} className="h-full">
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
<ArticleCard article={article} />
</EditableItem>
</Reveal>
+12 -4
View File
@@ -6,7 +6,8 @@ import { Reveal } from '../components/Reveal'
import { ArticleCard } from '../components/ArticleCard'
import { Newsletter } from '../components/home/Newsletter'
import { useSite } from '../api'
import { AddItemButton, EditableItem } from '../admin/Editable'
import { EditableItem } from '../admin/Editable'
import { PlusIcon } from 'lucide-react'
export function Blog() {
const { articles } = useSite()
@@ -19,10 +20,17 @@ export function Blog() {
<section className="bg-ink py-24 lg:py-32" aria-label="مقالات">
<div className="mx-auto max-w-site px-6 lg:px-10">
<div className="mb-10 flex justify-start">
<AddItemButton resource="articles" label="نوشتن مقاله جدید" variant="button" />
<Link
to="/admin/write"
data-admin-link
className="flex items-center gap-2 bg-copper px-6 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-105"
>
<PlusIcon className="h-4 w-4" />
نوشتن مقاله جدید
</Link>
</div>
<Reveal>
<EditableItem resource="articles" item={featured}>
<EditableItem resource="articles" item={featured} editHref={`/admin/write/${featured.id}`}>
<Link
to={`/blog/${featured.slug}`}
className="group grid items-stretch border border-ivory/10 bg-char transition-[transform,box-shadow,border-color] duration-300 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-lift lg:grid-cols-2"
@@ -62,7 +70,7 @@ export function Blog() {
<div className="mt-12 grid gap-6 lg:grid-cols-3">
{rest.map((article, i) => (
<Reveal key={article.id} delay={0.05 * i} className="h-full">
<EditableItem resource="articles" item={article} className="h-full">
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
<ArticleCard article={article} />
</EditableItem>
</Reveal>