لایه ویرایش بصری روی سایت واقعی (بدون داشبورد جدا): - ورود ادمین از مسیر پنهان /admin/login با توکن JWT (localStorage) و AdminContext شامل isEditMode و وضعیت ذخیره (همگام/در حال ذخیره/خطا) - AdminBar شناور پایین صفحه (فقط برای کاربر واردشده): کلید حالت ویرایش، وضعیت ذخیره، خروج — حالت ویرایش در sessionStorage میماند - InlineEdit: در حالت عادی دقیقاً همان HTML قبلی (بدون تغییر چیدمان)؛ در حالت ویرایش کادرخطچین + contentEditable با ذخیره خودکار در blur (Enter ذخیره، Esc لغو) — وایرایششده روی همه متنهای سایت: هیرو، تیتر بخشها، هیرو صفحات، داستان، فرم تماس، خبرنامه، فوتر، منو، برچسبهای وضعیت پروژه و... - کشوی عمومی EntityDrawer با پیکربندی متمرکز برای ۱۴ مجموعه (خدمات، پروژهها، مقالات، نظرات، سوالات، تیم، تایملاین، آمار، چرا آرامش، مدلهای سرمایهگذاری، مراحل کار، دفاتر، گالری، منو): ویرایش چندفیلدی، افزودن، حذف و جابهجایی ترتیب (▲▼) با ذخیره خودکار هر فیلد - دکمههای شناور ویرایش/حذف/ترتیب فقط در حالت ویرایش روی آیتمها - کشوهای یگانه: برند و لوگو، تنظیمات هیرو، فرم تماس، خبرنامه، فوتر، قبل/بعد و نقلقول داستان — آپلود تصویر به MinIO از همانجا - بهروزرسانی خوشبینانه SiteContext (updateSite) پس از هر PATCH؛ مسیرها در حالت ویرایش کلیک نمیشوند؛ breadcrumb محافظ beforeunload
82 lines
2.7 KiB
TypeScript
82 lines
2.7 KiB
TypeScript
import { useRef, useState } from 'react'
|
|
import { ImagePlusIcon, Loader2Icon } from 'lucide-react'
|
|
import { adminApi } from '../../admin/adminClient'
|
|
import { useAdmin } from '../../admin/AdminContext'
|
|
|
|
type ImageFieldProps = {
|
|
label: string
|
|
value: string
|
|
onSave: (url: string) => Promise<boolean>
|
|
}
|
|
|
|
/**
|
|
* انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش استفاده میشود.
|
|
* پس از آپلود موفق، URL نسبی /media/... ذخیره میشود.
|
|
*/
|
|
export function ImageField({ label, value, onSave }: ImageFieldProps) {
|
|
const { runSave, isAuthenticated, isEditMode } = useAdmin()
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const [uploading, setUploading] = useState(false)
|
|
|
|
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('ذخیره نشد')
|
|
})
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<p className="text-xs font-bold text-ivory/80">{label}</p>
|
|
<div className="mt-2 flex items-center gap-3">
|
|
{value ? (
|
|
<img
|
|
src={value}
|
|
alt={label}
|
|
className="h-16 w-24 border border-ivory/15 object-cover"
|
|
/>
|
|
) : (
|
|
<div className="flex h-16 w-24 items-center justify-center border border-dashed border-ivory/20 text-ivory/30">
|
|
—
|
|
</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>
|
|
<input
|
|
ref={inputRef}
|
|
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 = ''
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|