182 lines
6.4 KiB
TypeScript
182 lines
6.4 KiB
TypeScript
import { useId, useRef, useState } from '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'
|
|
import { useSmartTextField } from './useSmartTextField'
|
|
|
|
type ImageFieldProps = {
|
|
label: string
|
|
value: string
|
|
onSave: (url: string) => Promise<boolean>
|
|
/** متن جایگزین (Alt) */
|
|
alt?: string
|
|
onSaveAlt?: (alt: string) => Promise<boolean>
|
|
/** عنوان تصویر (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 با پیشنمایش بزرگ.
|
|
* ترتیب فیلدها: ۱) عنوان تصویر (پیشفرض «Photo 1») ۲) متن جایگزین (پیشفرض «{عنوان} - تصویر شاخص»).
|
|
* هر دو «دستی یا پیشفرض» هستند و Alt هرگز از URL ساخته نمیشود.
|
|
* پیشفرض خودکار با فوکوس پاک میشود تا کاربر بلافاصله تایپ کند و با بلورِ خالی برمیگردد.
|
|
*/
|
|
export function ImageField({
|
|
label,
|
|
value,
|
|
onSave,
|
|
alt,
|
|
onSaveAlt,
|
|
title,
|
|
onSaveTitle,
|
|
altFallback = '',
|
|
titleFallback = '',
|
|
}: ImageFieldProps) {
|
|
const { runSave, isAuthenticated, isEditMode } = useAdmin()
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const titleId = useId()
|
|
const altId = useId()
|
|
const [uploading, setUploading] = useState(false)
|
|
|
|
const showTitle = title !== undefined || !!onSaveTitle
|
|
const showAlt = alt !== undefined || !!onSaveAlt
|
|
|
|
const titleField = useSmartTextField({
|
|
value: title ?? '',
|
|
fallback: titleFallback,
|
|
hardFallback: DEFAULT_IMAGE_TITLE,
|
|
onCommit: onSaveTitle,
|
|
})
|
|
const altField = useSmartTextField({
|
|
value: alt ?? '',
|
|
fallback: altFallback,
|
|
hardFallback: label,
|
|
onCommit: onSaveAlt,
|
|
})
|
|
|
|
if (!isAuthenticated || !isEditMode) return null
|
|
|
|
const pick = async (file: File | undefined) => {
|
|
if (!file) return
|
|
setUploading(true)
|
|
try {
|
|
await runSave(async () => {
|
|
const { url } = await adminApi.upload(file)
|
|
const ok = await onSave(url)
|
|
if (!ok) throw new Error('ذخیره نشد')
|
|
// پیشفرضهای Alt/Title همان لحظه ثبت میشوند تا تصویر بدون متادیتا نماند
|
|
if (showTitle) await titleField.commit()
|
|
if (showAlt) await altField.commit()
|
|
})
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<p className="text-xs font-bold text-ivory/80">{label}</p>
|
|
|
|
{/* پیشنمایش بزرگ تصویر */}
|
|
<div className="mt-2 overflow-hidden border border-ivory/15 bg-char">
|
|
{value ? (
|
|
<img
|
|
src={value}
|
|
alt={altField.display || label}
|
|
title={titleField.display || undefined}
|
|
className="h-[27rem] w-full object-cover"
|
|
/>
|
|
) : (
|
|
<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>
|
|
|
|
<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={titleField.display}
|
|
onChange={(event) => titleField.onChange(event.target.value)}
|
|
onFocus={titleField.onFocus}
|
|
onBlur={titleField.onBlur}
|
|
placeholder={titleFallback.trim() || 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={altField.display}
|
|
onChange={(event) => altField.onChange(event.target.value)}
|
|
onFocus={altField.onFocus}
|
|
onBlur={altField.onBlur}
|
|
placeholder={
|
|
altFallback.trim() || 'توضیح کوتاه تصویر برای سئو و صفحهخوانها'
|
|
}
|
|
className={META_FIELD_CLASS}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
|
className="hidden"
|
|
onChange={(event) => {
|
|
void pick(event.target.files?.[0])
|
|
event.target.value = ''
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|