545 lines
23 KiB
TypeScript
545 lines
23 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, 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<Article, 'body'> & { 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<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 [fetched, setFetched] = useState<Draft | null>(null)
|
||
|
||
// مقاله تازه همیشه پیشنویس شروع میشود؛ با دکمه «انتشار در سایت» عمومی میشود
|
||
const [draft, setDraft] = useState<Draft>({
|
||
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<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
|
||
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<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
|
||
|
||
// متن جایگزین تصویر شاخص — با 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">
|
||
<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={`/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') زنده بماند */}
|
||
<Controller
|
||
name="title"
|
||
control={control}
|
||
render={({ field }) => (
|
||
<input
|
||
type="text"
|
||
{...field}
|
||
onChange={(event) => {
|
||
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"
|
||
/>
|
||
)}
|
||
/>
|
||
<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={draft.body as unknown as Record<string, unknown>}
|
||
onChange={(json) => {
|
||
// زمان مطالعه بهصورت پویا از متن سند محاسبه میشود (۲۰۰ واژه در دقیقه)
|
||
queueSave({ body: json, read: formatReadingTime(json) })
|
||
}}
|
||
/>
|
||
</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>
|
||
|
||
{/* چکیده و متادیتا */}
|
||
<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('excerpt')}
|
||
placeholder="خلاصه کوتاه مقاله برای کارتهای وبلاگ…"
|
||
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
|
||
{...textField('read')}
|
||
className="mt-2 w-full cursor-not-allowed border border-ivory/15 bg-char/50 px-3 py-2 text-sm font-light 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>
|
||
)
|
||
}
|