feat(admin): enhance editor UI (links, image captions) & fix categories
Added animated auto-opening link modal, custom image node with isolated caption deletion, fixed single-select category data binding, improved featured image layout, and updated README.
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Link2Icon, Trash2Icon, XIcon } from 'lucide-react'
|
||||
import { buildLinkRel, normalizeHref } from '../../utils/link'
|
||||
|
||||
export type LinkModalValue = {
|
||||
href: string
|
||||
newTab: boolean
|
||||
nofollow: boolean
|
||||
}
|
||||
|
||||
type LinkModalProps = {
|
||||
/** مقادیر اولیه — از لینک فعال زیر نشانگر یا خالی برای لینک تازه */
|
||||
initial: LinkModalValue
|
||||
/** ویرایش لینک موجود (دکمه «حذف لینک» نمایش داده میشود) */
|
||||
editing: boolean
|
||||
onApply: (value: LinkModalValue) => void
|
||||
onRemove: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const FIELD_CLASS =
|
||||
'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'
|
||||
|
||||
/** مدت انیمیشن ورود/خروج — با duration-300 کلاسها هماهنگ است */
|
||||
const TRANSITION_MS = 300
|
||||
|
||||
/**
|
||||
* مودال ثبت و ویرایش لینک — انیمیشن نرم fade و scale با کلاسهای Tailwind.
|
||||
* ورود با یک فریم تاخیر (تا کلاسهای بسته اعمال شوند) و خروج پیش از بستن مودال اجرا میشود.
|
||||
* جایگزین پنجره پیشفرض مرورگر برای لینک شده است.
|
||||
*/
|
||||
export function LinkModal({ initial, editing, onApply, onRemove, onClose }: LinkModalProps) {
|
||||
const [href, setHref] = useState(initial.href)
|
||||
const [newTab, setNewTab] = useState(initial.newTab)
|
||||
const [nofollow, setNofollow] = useState(initial.nofollow)
|
||||
const [error, setError] = useState('')
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [closing, setClosing] = useState(false)
|
||||
|
||||
// انیمیشن ورود: ابتدا حالت بسته رندر میشود، سپس به حالت باز میرود
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(() => setVisible(true))
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [])
|
||||
|
||||
/** بستن نرم: اول انیمیشن خروج کامل میشود، بعد عملیات و unmount */
|
||||
const closeWith = (action?: () => void) => {
|
||||
if (closing) return
|
||||
setClosing(true)
|
||||
setVisible(false)
|
||||
window.setTimeout(() => {
|
||||
action?.()
|
||||
onClose()
|
||||
}, TRANSITION_MS)
|
||||
}
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
const normalized = normalizeHref(href)
|
||||
if (!normalized) {
|
||||
setError('نشانی معتبر نیست — نمونه: https://example.com یا /contact')
|
||||
return
|
||||
}
|
||||
closeWith(() => onApply({ href: normalized, newTab, nofollow }))
|
||||
}
|
||||
|
||||
const relPreview = buildLinkRel({ newTab, nofollow })
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[97] flex items-center justify-center p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="ثبت لینک"
|
||||
>
|
||||
{/* پسزمینه تار — fade نرم */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-char/70 backdrop-blur-sm transition-opacity duration-300 ease-out ${
|
||||
visible ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
onClick={() => closeWith()}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* کارت مودال — fade + scale نرم */}
|
||||
<form
|
||||
onSubmit={submit}
|
||||
dir="rtl"
|
||||
className={`relative w-full max-w-md border border-ivory/10 bg-ink p-7 shadow-2xl transition-all duration-300 ease-out ${
|
||||
visible ? 'translate-y-0 scale-100 opacity-100' : 'translate-y-2 scale-95 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-sm font-bold text-copper">
|
||||
<Link2Icon className="h-4 w-4" aria-hidden="true" />
|
||||
{editing ? 'ویرایش لینک' : 'افزودن لینک'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeWith()}
|
||||
aria-label="بستن"
|
||||
className="text-ivory/50 transition-colors hover:text-ivory"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* نشانی */}
|
||||
<label htmlFor="link-href" className="mt-6 block text-xs font-light text-ivory/60">
|
||||
نشانی پیوند (URL)
|
||||
</label>
|
||||
<input
|
||||
id="link-href"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
autoFocus
|
||||
value={href}
|
||||
onChange={(event) => {
|
||||
setHref(event.target.value)
|
||||
if (error) setError('')
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
className={FIELD_CLASS}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-2 border-r-2 border-red-400/70 pr-3 text-[11px] font-light leading-6 text-red-300">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* باز شدن در تب جدید */}
|
||||
<label className="mt-5 flex cursor-pointer items-center gap-3 text-sm font-light text-ivory/80">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newTab}
|
||||
onChange={(event) => setNewTab(event.target.checked)}
|
||||
className="h-4 w-4 accent-copper"
|
||||
/>
|
||||
باز شدن در تب جدید
|
||||
<span className="text-[10px] font-light text-ivory/35" dir="ltr">target="_blank"</span>
|
||||
</label>
|
||||
|
||||
{/* نوع لینک از نظر سئو */}
|
||||
<fieldset className="mt-5">
|
||||
<legend className="text-xs font-light text-ivory/60">انتقال اعتبار سئو (rel)</legend>
|
||||
<div className="mt-3 grid gap-2.5">
|
||||
{[
|
||||
{ key: 'dofollow', label: 'Dofollow', hint: 'اعتبار سئو به مقصد منتقل میشود', value: false },
|
||||
{ key: 'nofollow', label: 'Nofollow', hint: 'گوگل این لینک را دنبال نکند', value: true },
|
||||
].map((option) => (
|
||||
<label
|
||||
key={option.key}
|
||||
className={`flex cursor-pointer items-start gap-3 border px-4 py-3 transition-colors duration-150 ${
|
||||
nofollow === option.value
|
||||
? 'border-copper/60 bg-copper/10'
|
||||
: 'border-ivory/12 hover:border-ivory/25'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="link-rel"
|
||||
checked={nofollow === option.value}
|
||||
onChange={() => setNofollow(option.value)}
|
||||
className="mt-0.5 h-4 w-4 accent-copper"
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-sm font-light text-ivory">{option.label}</span>
|
||||
<span className="text-[11px] font-light text-ivory/45">{option.hint}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{relPreview && (
|
||||
<p className="mt-4 text-[10px] font-light text-ivory/35" dir="ltr">
|
||||
rel="{relPreview}"
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* دکمهها */}
|
||||
<div className="mt-7 flex items-center justify-between gap-3">
|
||||
{editing ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeWith(onRemove)}
|
||||
className="flex items-center gap-1.5 border border-red-400/40 px-4 py-2.5 text-xs font-light text-red-300 transition-colors hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
حذف لینک
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeWith()}
|
||||
className="border border-ivory/20 px-5 py-2.5 text-xs font-light text-ivory/70 transition-colors hover:border-ivory/40 hover:text-ivory"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-copper px-6 py-2.5 text-xs font-bold text-char transition-transform duration-200 hover:scale-105"
|
||||
>
|
||||
{editing ? 'ذخیره لینک' : 'افزودن لینک'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user