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.
216 lines
8.2 KiB
TypeScript
216 lines
8.2 KiB
TypeScript
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>
|
|
)
|
|
}
|