Update blog editor features

This commit is contained in:
fatemeh sane
2026-09-13 10:39:25 +03:30
parent 20c95e73b3
commit bb6c586d99
21 changed files with 1235 additions and 187 deletions
+62 -4
View File
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react'
import { useEffect, useId, useRef, useState } from 'react'
import { ImagePlusIcon, Loader2Icon } from 'lucide-react'
import { adminApi } from '../../admin/adminClient'
import { useAdmin } from '../../admin/AdminContext'
@@ -7,33 +7,69 @@ type ImageFieldProps = {
label: string
value: string
onSave: (url: string) => Promise<boolean>
/** متن جایگزین (Alt) — با ارسال این prop ورودی Alt نمایش داده می‌شود */
alt?: string
/** ذخیره متن جایگزین — بدون آن، Alt فقط برای پیش‌نمایش و پیشنهاد استفاده می‌شود */
onSaveAlt?: (alt: string) => Promise<boolean>
/** پیشنهاد Alt وقتی خالی است (مثل عنوان مقاله یا نام موجودیت) */
altFallback?: string
}
/**
* انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش استفاده می‌شود.
* انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش و صفحه نوشتن مقاله.
* پس از آپلود موفق، URL نسبی /media/... ذخیره می‌شود.
* اگر Alt خالی باشد، پیش از فراخوانی API از عنوان موجودیت یا نام فایل ساخته می‌شود.
*/
export function ImageField({ label, value, onSave }: ImageFieldProps) {
export function ImageField({ label, value, onSave, alt, onSaveAlt, altFallback = '' }: ImageFieldProps) {
const { runSave, isAuthenticated, isEditMode } = useAdmin()
const inputRef = useRef<HTMLInputElement>(null)
const altInputId = useId()
const [uploading, setUploading] = useState(false)
const [altText, setAltText] = useState(alt ?? '')
const showAlt = alt !== undefined || !!onSaveAlt
// همگام‌سازی با مقدار ذخیره‌شده (پس از به‌روزرسانی خوش‌بینانه در درخت داده)
useEffect(() => {
setAltText(alt ?? '')
}, [alt])
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
}
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)
}
})
} finally {
setUploading(false)
}
}
const commitAlt = () => {
const next = altText.trim()
if (!onSaveAlt || next === (alt ?? '').trim()) return
void onSaveAlt(next)
}
return (
<div>
<p className="text-xs font-bold text-ivory/80">{label}</p>
@@ -41,7 +77,7 @@ export function ImageField({ label, value, onSave }: ImageFieldProps) {
{value ? (
<img
src={value}
alt={label}
alt={altText || label}
className="h-16 w-24 border border-ivory/15 object-cover"
/>
) : (
@@ -66,6 +102,28 @@ export function ImageField({ label, value, onSave }: ImageFieldProps) {
{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>
)}
<input
ref={inputRef}
type="file"