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:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { ImagePlusIcon, Loader2Icon } from 'lucide-react'
|
||||
import { ImageIcon, ImagePlusIcon, Loader2Icon } from 'lucide-react'
|
||||
import { DEFAULT_IMAGE_TITLE } from '../../data/constants'
|
||||
import { adminApi } from '../../admin/adminClient'
|
||||
import { useAdmin } from '../../admin/AdminContext'
|
||||
|
||||
@@ -7,120 +8,175 @@ type ImageFieldProps = {
|
||||
label: string
|
||||
value: string
|
||||
onSave: (url: string) => Promise<boolean>
|
||||
/** متن جایگزین (Alt) — با ارسال این prop ورودی Alt نمایش داده میشود */
|
||||
/** متن جایگزین (Alt) */
|
||||
alt?: string
|
||||
/** ذخیره متن جایگزین — بدون آن، Alt فقط برای پیشنمایش و پیشنهاد استفاده میشود */
|
||||
onSaveAlt?: (alt: string) => Promise<boolean>
|
||||
/** پیشنهاد Alt وقتی خالی است (مثل عنوان مقاله یا نام موجودیت) */
|
||||
/** عنوان تصویر (Title) */
|
||||
title?: string
|
||||
onSaveTitle?: (title: string) => Promise<boolean>
|
||||
/**
|
||||
* پیشنهاد Alt وقتی کاربر چیزی ننوشته — مثل «{عنوان مقاله} - تصویر شاخص».
|
||||
* هرگز از URL یا نام فایل ساخته نمیشود.
|
||||
*/
|
||||
altFallback?: string
|
||||
/** پیشنهاد عنوان تصویر — پیشفرض «Photo 1» */
|
||||
titleFallback?: string
|
||||
}
|
||||
|
||||
const META_FIELD_CLASS =
|
||||
'mt-1.5 w-full border border-ivory/15 bg-char px-3 py-2 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||
|
||||
/**
|
||||
* انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش و صفحه نوشتن مقاله.
|
||||
* پس از آپلود موفق، URL نسبی /media/... ذخیره میشود.
|
||||
* اگر Alt خالی باشد، پیش از فراخوانی API از عنوان موجودیت یا نام فایل ساخته میشود.
|
||||
* انتخاب و آپلود تصویر به MinIO با پیشنمایش بزرگ.
|
||||
* ترتیب فیلدها: ۱) عنوان تصویر (پیشفرض «Photo 1») ۲) متن جایگزین (پیشفرض «{عنوان} - تصویر شاخص»).
|
||||
* هر دو «دستی یا پیشفرض» هستند و Alt هرگز از URL ساخته نمیشود.
|
||||
*/
|
||||
export function ImageField({ label, value, onSave, alt, onSaveAlt, altFallback = '' }: ImageFieldProps) {
|
||||
export function ImageField({
|
||||
label,
|
||||
value,
|
||||
onSave,
|
||||
alt,
|
||||
onSaveAlt,
|
||||
title,
|
||||
onSaveTitle,
|
||||
altFallback = '',
|
||||
titleFallback = '',
|
||||
}: ImageFieldProps) {
|
||||
const { runSave, isAuthenticated, isEditMode } = useAdmin()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const altInputId = useId()
|
||||
const titleId = useId()
|
||||
const altId = useId()
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [altText, setAltText] = useState(alt ?? '')
|
||||
const [titleText, setTitleText] = useState(title ?? '')
|
||||
|
||||
const showTitle = title !== undefined || !!onSaveTitle
|
||||
const showAlt = alt !== undefined || !!onSaveAlt
|
||||
|
||||
// همگامسازی با مقدار ذخیرهشده (پس از بهروزرسانی خوشبینانه در درخت داده)
|
||||
// همگامسازی با مقادیر ذخیرهشده (پس از بهروزرسانی خوشبینانه در درخت داده)
|
||||
useEffect(() => {
|
||||
setAltText(alt ?? '')
|
||||
}, [alt])
|
||||
useEffect(() => {
|
||||
setTitleText(title ?? '')
|
||||
}, [title])
|
||||
|
||||
if (!isAuthenticated || !isEditMode) return null
|
||||
|
||||
/** متن جایگزین نهایی: ورودی کاربر → عنوان موجودیت → نام فایل */
|
||||
const resolveAlt = (fileName: string): string => {
|
||||
const typed = altText.trim()
|
||||
if (typed) return typed
|
||||
const fromEntity = altFallback.trim()
|
||||
if (fromEntity) return fromEntity
|
||||
return fileName.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() || label
|
||||
/** عنوان نمایشی: ورودی کاربر → پیشنهاد → «Photo 1» (با تغییر عنوان مقاله زنده بهروز میشود) */
|
||||
const titleDisplayed = titleText.trim() || titleFallback.trim() || DEFAULT_IMAGE_TITLE
|
||||
|
||||
/** Alt نمایشی: ورودی کاربر → پیشنهاد عنوانمحور (هرگز URL) */
|
||||
const altDisplayed = altText.trim() || altFallback.trim()
|
||||
|
||||
const resolveTitle = () => titleDisplayed
|
||||
const resolveAlt = () => altDisplayed || label
|
||||
|
||||
const persistMeta = async () => {
|
||||
const nextTitle = resolveTitle()
|
||||
const nextAlt = resolveAlt()
|
||||
setTitleText(nextTitle)
|
||||
setAltText(nextAlt)
|
||||
if (showTitle && nextTitle !== (title ?? '').trim() && onSaveTitle) await onSaveTitle(nextTitle)
|
||||
if (showAlt && nextAlt !== (alt ?? '').trim() && onSaveAlt) await onSaveAlt(nextAlt)
|
||||
}
|
||||
|
||||
const pick = async (file: File | undefined) => {
|
||||
if (!file) return
|
||||
const generatedAlt = resolveAlt(file.name)
|
||||
setUploading(true)
|
||||
try {
|
||||
await runSave(async () => {
|
||||
const { url } = await adminApi.upload(file)
|
||||
const ok = await onSave(url)
|
||||
if (!ok) throw new Error('ذخیره نشد')
|
||||
// Alt ساختهشده هم ذخیره میشود تا تصویر بدون متن جایگزین نماند
|
||||
if (showAlt && generatedAlt !== (alt ?? '').trim()) {
|
||||
setAltText(generatedAlt)
|
||||
if (onSaveAlt) await onSaveAlt(generatedAlt)
|
||||
}
|
||||
await persistMeta()
|
||||
})
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const commitAlt = () => {
|
||||
const next = altText.trim()
|
||||
if (!onSaveAlt || next === (alt ?? '').trim()) return
|
||||
void onSaveAlt(next)
|
||||
const commitMeta = () => {
|
||||
if (!onSaveTitle && !onSaveAlt) return
|
||||
void persistMeta()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{label}</p>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
|
||||
{/* پیشنمایش بزرگ تصویر */}
|
||||
<div className="mt-2 overflow-hidden border border-ivory/15 bg-char">
|
||||
{value ? (
|
||||
<img
|
||||
src={value}
|
||||
alt={altText || label}
|
||||
className="h-16 w-24 border border-ivory/15 object-cover"
|
||||
title={titleText || undefined}
|
||||
className="h-[27rem] w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-16 w-24 items-center justify-center border border-dashed border-ivory/20 text-ivory/30">
|
||||
—
|
||||
<div className="flex h-[27rem] w-full flex-col items-center justify-center gap-2 border border-dashed border-ivory/20 text-ivory/30">
|
||||
<ImageIcon className="h-7 w-7" aria-hidden="true" />
|
||||
<span className="text-[11px] font-light">تصویری انتخاب نشده است</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ImagePlusIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{uploading ? 'در حال آپلود…' : 'انتخاب تصویر'}
|
||||
</button>
|
||||
{value && <p className="mt-1.5 truncate text-[10px] font-light text-ivory/35" dir="ltr">{value}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAlt && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label htmlFor={altInputId} className="text-[11px] font-light text-ivory/50">
|
||||
متن جایگزین (Alt)
|
||||
</label>
|
||||
<span className="text-[10px] font-light text-ivory/30">برای سئو و صفحهخوانها</span>
|
||||
</div>
|
||||
<input
|
||||
id={altInputId}
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={altText}
|
||||
onChange={(e) => setAltText(e.target.value)}
|
||||
onBlur={commitAlt}
|
||||
placeholder={altFallback ? `پیشفرض: ${altFallback}` : 'توضیح کوتاه تصویر…'}
|
||||
className="mt-1.5 w-full border border-ivory/15 bg-char px-3 py-2 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none"
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ImagePlusIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(showTitle || showAlt) && (
|
||||
<div className="mt-4 grid max-w-md gap-4 border-t border-ivory/10 pt-4 sm:grid-cols-2">
|
||||
{showTitle && (
|
||||
<div>
|
||||
<label htmlFor={titleId} className="text-[11px] font-light text-ivory/50">
|
||||
عنوان تصویر (Title)
|
||||
</label>
|
||||
<input
|
||||
id={titleId}
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={titleDisplayed}
|
||||
onChange={(event) => setTitleText(event.target.value)}
|
||||
onBlur={commitMeta}
|
||||
placeholder={titleFallback || DEFAULT_IMAGE_TITLE}
|
||||
className={META_FIELD_CLASS}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAlt && (
|
||||
<div>
|
||||
<label htmlFor={altId} className="text-[11px] font-light text-ivory/50">
|
||||
متن جایگزین (Alt)
|
||||
</label>
|
||||
<input
|
||||
id={altId}
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={altDisplayed}
|
||||
onChange={(event) => setAltText(event.target.value)}
|
||||
onBlur={commitMeta}
|
||||
placeholder={
|
||||
altFallback || 'توضیح کوتاه تصویر برای سئو و صفحهخوانها'
|
||||
}
|
||||
className={META_FIELD_CLASS}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -129,9 +185,9 @@ export function ImageField({ label, value, onSave, alt, onSaveAlt, altFallback =
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
void pick(e.target.files?.[0])
|
||||
e.target.value = ''
|
||||
onChange={(event) => {
|
||||
void pick(event.target.files?.[0])
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user