وبلاگ: صفحه تمامعرض «نوشتن مقاله» — محیط نوشتن متمرکز برای ادمین
- /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:
@@ -19,6 +19,7 @@ import { SiteProvider, useSiteData } from './api';
|
|||||||
import { AdminProvider, useAdmin } from './admin/AdminContext';
|
import { AdminProvider, useAdmin } from './admin/AdminContext';
|
||||||
import { AdminBar } from './admin/AdminBar';
|
import { AdminBar } from './admin/AdminBar';
|
||||||
import { AdminLoginPage } from './admin/AdminLoginPage';
|
import { AdminLoginPage } from './admin/AdminLoginPage';
|
||||||
|
import { ArticleWritePage } from './admin/ArticleWritePage';
|
||||||
|
|
||||||
function ScrollToTop() {
|
function ScrollToTop() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
@@ -41,6 +42,8 @@ function AnimatedRoutes() {
|
|||||||
|
|
||||||
<Routes location={location}>
|
<Routes location={location}>
|
||||||
<Route path="/admin/login" element={<AdminLoginPage />} />
|
<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="/" element={<Home />} />
|
||||||
<Route path="/about" element={<About />} />
|
<Route path="/about" element={<About />} />
|
||||||
<Route path="/services" element={<ServicesPage />} />
|
<Route path="/services" element={<ServicesPage />} />
|
||||||
@@ -61,15 +64,15 @@ function AnimatedRoutes() {
|
|||||||
function AppChrome() {
|
function AppChrome() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { isAuthenticated } = useAdmin();
|
const { isAuthenticated } = useAdmin();
|
||||||
const isLoginPage = pathname === '/admin/login';
|
const isAdminPage = pathname.startsWith('/admin');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!isLoginPage && <Header />}
|
{!isAdminPage && <Header />}
|
||||||
<AnimatedRoutes />
|
<AnimatedRoutes />
|
||||||
{!isLoginPage && <Footer />}
|
{!isAdminPage && <Footer />}
|
||||||
{!isLoginPage && <FloatingContact />}
|
{!isAdminPage && <FloatingContact />}
|
||||||
{isAuthenticated && <AdminBar />}
|
{isAuthenticated && !isAdminPage && <AdminBar />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,8 +100,9 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEditMode) return
|
if (!isEditMode) return
|
||||||
const onClick = (e: MouseEvent) => {
|
const onClick = (e: MouseEvent) => {
|
||||||
const target = e.target as HTMLElement | null
|
const anchor = (e.target as HTMLElement | null)?.closest('a')
|
||||||
if (target?.closest('a')) e.preventDefault()
|
// لینکهای خود لایه مدیریت (مثل «نوشتن مقاله جدید») کار میکنند
|
||||||
|
if (anchor && !anchor.dataset.adminLink) e.preventDefault()
|
||||||
}
|
}
|
||||||
document.addEventListener('click', onClick, true)
|
document.addEventListener('click', onClick, true)
|
||||||
return () => document.removeEventListener('click', onClick, true)
|
return () => document.removeEventListener('click', onClick, true)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'
|
import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import Swal from 'sweetalert2'
|
import Swal from 'sweetalert2'
|
||||||
import { useSiteData } from '../api'
|
import { useSiteData } from '../api'
|
||||||
import type { SiteData } from '../api'
|
import type { SiteData } from '../api'
|
||||||
@@ -59,11 +60,14 @@ export function EditableItem({
|
|||||||
item,
|
item,
|
||||||
children,
|
children,
|
||||||
className = '',
|
className = '',
|
||||||
|
editHref,
|
||||||
}: {
|
}: {
|
||||||
resource: string
|
resource: string
|
||||||
item: ItemWithId
|
item: ItemWithId
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
|
/** در صورت ارسال، دکمه ویرایش به این مسیر هدایت میکند (مثل صفحه نوشتن مقاله) */
|
||||||
|
editHref?: string
|
||||||
}) {
|
}) {
|
||||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
||||||
const { reload } = useSiteData()
|
const { reload } = useSiteData()
|
||||||
@@ -115,9 +119,21 @@ export function EditableItem({
|
|||||||
return (
|
return (
|
||||||
<div className={`relative group/editable ${className}`}>
|
<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">
|
<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">
|
||||||
<PillButton onClick={() => setDrawerOpen(true)} label="ویرایش">
|
{editHref ? (
|
||||||
<PencilIcon className="h-3.5 w-3.5" />
|
<Link
|
||||||
</PillButton>
|
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 && (
|
{index > 0 && (
|
||||||
<PillButton onClick={() => move(-1)} label="انتقال به قبل">
|
<PillButton onClick={() => move(-1)} label="انتقال به قبل">
|
||||||
<ArrowUpIcon className="h-3.5 w-3.5" />
|
<ArrowUpIcon className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function Articles() {
|
|||||||
<div className="mt-14 grid gap-6 lg:grid-cols-3">
|
<div className="mt-14 grid gap-6 lg:grid-cols-3">
|
||||||
{articles.slice(0, 3).map((article, i) =>
|
{articles.slice(0, 3).map((article, i) =>
|
||||||
<Reveal key={article.id} delay={0.06 * i} className="h-full">
|
<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} />
|
<ArticleCard article={article} />
|
||||||
</EditableItem>
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { Reveal } from '../components/Reveal'
|
|||||||
import { ArticleCard } from '../components/ArticleCard'
|
import { ArticleCard } from '../components/ArticleCard'
|
||||||
import { Newsletter } from '../components/home/Newsletter'
|
import { Newsletter } from '../components/home/Newsletter'
|
||||||
import { useSite } from '../api'
|
import { useSite } from '../api'
|
||||||
import { AddItemButton, EditableItem } from '../admin/Editable'
|
import { EditableItem } from '../admin/Editable'
|
||||||
|
import { PlusIcon } from 'lucide-react'
|
||||||
|
|
||||||
export function Blog() {
|
export function Blog() {
|
||||||
const { articles } = useSite()
|
const { articles } = useSite()
|
||||||
@@ -19,10 +20,17 @@ export function Blog() {
|
|||||||
<section className="bg-ink py-24 lg:py-32" aria-label="مقالات">
|
<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="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<div className="mb-10 flex justify-start">
|
<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>
|
</div>
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<EditableItem resource="articles" item={featured}>
|
<EditableItem resource="articles" item={featured} editHref={`/admin/write/${featured.id}`}>
|
||||||
<Link
|
<Link
|
||||||
to={`/blog/${featured.slug}`}
|
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"
|
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">
|
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
||||||
{rest.map((article, i) => (
|
{rest.map((article, i) => (
|
||||||
<Reveal key={article.id} delay={0.05 * i} className="h-full">
|
<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} />
|
<ArticleCard article={article} />
|
||||||
</EditableItem>
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|||||||
Reference in New Issue
Block a user