لایه ویرایش بصری روی سایت واقعی (بدون داشبورد جدا): - ورود ادمین از مسیر پنهان /admin/login با توکن JWT (localStorage) و AdminContext شامل isEditMode و وضعیت ذخیره (همگام/در حال ذخیره/خطا) - AdminBar شناور پایین صفحه (فقط برای کاربر واردشده): کلید حالت ویرایش، وضعیت ذخیره، خروج — حالت ویرایش در sessionStorage میماند - InlineEdit: در حالت عادی دقیقاً همان HTML قبلی (بدون تغییر چیدمان)؛ در حالت ویرایش کادرخطچین + contentEditable با ذخیره خودکار در blur (Enter ذخیره، Esc لغو) — وایرایششده روی همه متنهای سایت: هیرو، تیتر بخشها، هیرو صفحات، داستان، فرم تماس، خبرنامه، فوتر، منو، برچسبهای وضعیت پروژه و... - کشوی عمومی EntityDrawer با پیکربندی متمرکز برای ۱۴ مجموعه (خدمات، پروژهها، مقالات، نظرات، سوالات، تیم، تایملاین، آمار، چرا آرامش، مدلهای سرمایهگذاری، مراحل کار، دفاتر، گالری، منو): ویرایش چندفیلدی، افزودن، حذف و جابهجایی ترتیب (▲▼) با ذخیره خودکار هر فیلد - دکمههای شناور ویرایش/حذف/ترتیب فقط در حالت ویرایش روی آیتمها - کشوهای یگانه: برند و لوگو، تنظیمات هیرو، فرم تماس، خبرنامه، فوتر، قبل/بعد و نقلقول داستان — آپلود تصویر به MinIO از همانجا - بهروزرسانی خوشبینانه SiteContext (updateSite) پس از هر PATCH؛ مسیرها در حالت ویرایش کلیک نمیشوند؛ breadcrumb محافظ beforeunload
251 lines
8.4 KiB
TypeScript
251 lines
8.4 KiB
TypeScript
import { useState } from 'react'
|
|
import { AnimatePresence, motion } from 'framer-motion'
|
|
import { PlusIcon, Trash2Icon, XIcon } from 'lucide-react'
|
|
import Swal from 'sweetalert2'
|
|
import { useSiteData } from '../api'
|
|
import { useAdmin, useFieldSaver } from './AdminContext'
|
|
import { adminApi } from './adminClient'
|
|
import type { EntityConfig, FieldConfig } from './entities'
|
|
import { ImageField } from '../components/admin/ImageField'
|
|
|
|
type EntityDrawerProps = {
|
|
config: EntityConfig
|
|
/** رکورد یگانه یا آیتم مجموعه — باید id داشته باشد */
|
|
item: Record<string, unknown> & { id: number }
|
|
onClose: () => void
|
|
/** فقط برای مجموعهها: حذف آیتم */
|
|
deletable?: boolean
|
|
}
|
|
|
|
export function EntityDrawer({ config, item, onClose, deletable = false }: EntityDrawerProps) {
|
|
const save = useFieldSaver()
|
|
const { runSave } = useAdmin()
|
|
const { reload } = useSiteData()
|
|
const [deleting, setDeleting] = useState(false)
|
|
|
|
const saveField = (name: string, value: unknown) =>
|
|
save(config.resource, item.id, { [name]: value }, (d) => {
|
|
const target = d[config.dataKey as keyof typeof d] as unknown
|
|
const node =
|
|
config.kind === 'singleton'
|
|
? (target as Record<string, unknown>)
|
|
: (target as { id: number }[]).find((x) => x.id === item.id)
|
|
if (node) (node as Record<string, unknown>)[name] = value
|
|
})
|
|
|
|
const remove = async () => {
|
|
const result = await Swal.fire({
|
|
icon: 'warning',
|
|
title: 'حذف رکورد',
|
|
text: `«${String(item[config.titleKey] ?? '')}» برای همیشه حذف شود؟`,
|
|
background: '#1e1a16',
|
|
color: '#F5F3EF',
|
|
iconColor: '#d5874c',
|
|
showCancelButton: true,
|
|
confirmButtonText: 'حذف',
|
|
cancelButtonText: 'انصراف',
|
|
buttonsStyling: false,
|
|
customClass: {
|
|
popup: 'font-pinar border border-ivory/10',
|
|
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
|
|
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
|
|
},
|
|
})
|
|
if (!result.isConfirmed) return
|
|
setDeleting(true)
|
|
await runSave(async () => {
|
|
await adminApi.remove(config.resource, item.id)
|
|
reload()
|
|
onClose()
|
|
})
|
|
setDeleting(false)
|
|
}
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
<motion.div
|
|
key="backdrop"
|
|
className="fixed inset-0 z-[95] bg-char/70 backdrop-blur-sm"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={onClose}
|
|
aria-hidden="true"
|
|
/>
|
|
<motion.aside
|
|
key="drawer"
|
|
dir="rtl"
|
|
role="dialog"
|
|
aria-label={`ویرایش ${config.title}`}
|
|
className="fixed inset-y-0 left-0 z-[96] flex w-full max-w-md flex-col border-e border-ivory/10 bg-ink shadow-2xl"
|
|
initial={{ x: '-100%' }}
|
|
animate={{ x: 0 }}
|
|
exit={{ x: '-100%' }}
|
|
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
|
|
>
|
|
<header className="flex items-center justify-between border-b border-ivory/10 px-6 py-4">
|
|
<div>
|
|
<p className="text-xs font-light text-copper">
|
|
{config.kind === 'singleton' ? 'تنظیمات بخش' : 'ویرایش رکورد'}
|
|
</p>
|
|
<h2 className="mt-1 text-lg font-bold text-ivory">{config.title}</h2>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label="بستن"
|
|
className="flex h-9 w-9 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors hover:border-copper hover:text-copper"
|
|
>
|
|
<XIcon className="h-4 w-4" />
|
|
</button>
|
|
</header>
|
|
|
|
<div className="flex-1 overflow-y-auto px-6 py-6">
|
|
<div className="grid gap-6">
|
|
{config.fields.map((field) => (
|
|
<FieldControl
|
|
key={field.name}
|
|
field={field}
|
|
value={item[field.name]}
|
|
onSave={(value) => saveField(field.name, value)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{deletable && (
|
|
<footer className="border-t border-ivory/10 px-6 py-4">
|
|
<button
|
|
type="button"
|
|
onClick={remove}
|
|
disabled={deleting}
|
|
className="flex items-center gap-2 border border-red-400/40 px-4 py-2 text-xs font-light text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
|
>
|
|
<Trash2Icon className="h-3.5 w-3.5" />
|
|
حذف این رکورد
|
|
</button>
|
|
</footer>
|
|
)}
|
|
</motion.aside>
|
|
</AnimatePresence>
|
|
)
|
|
}
|
|
|
|
function FieldControl({
|
|
field,
|
|
value,
|
|
onSave,
|
|
}: {
|
|
field: FieldConfig
|
|
value: unknown
|
|
onSave: (value: unknown) => Promise<boolean>
|
|
}) {
|
|
const [text, setText] = useState<string>(String(value ?? ''))
|
|
|
|
const commitText = async () => {
|
|
const next = field.type === 'number' ? Number(text) || 0 : text
|
|
if (String(value ?? '') === String(next)) return
|
|
await onSave(next)
|
|
}
|
|
|
|
if (field.type === 'image') {
|
|
return <ImageField label={field.label} value={String(value ?? '')} onSave={(url) => onSave(url)} />
|
|
}
|
|
|
|
if (field.type === 'list') {
|
|
return <ListField field={field} value={value} onSave={onSave} />
|
|
}
|
|
|
|
if (field.type === 'textarea') {
|
|
return (
|
|
<div>
|
|
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
|
<textarea
|
|
dir="auto"
|
|
rows={4}
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
onBlur={commitText}
|
|
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
|
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
|
<input
|
|
dir={field.type === 'number' ? 'ltr' : 'auto'}
|
|
type={field.type === 'number' ? 'number' : 'text'}
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
onBlur={commitText}
|
|
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** ویرایش آرایه خطی (پاراگرافها، گالری، نکات) — هر تغییر ذخیره میشود */
|
|
function ListField({
|
|
field,
|
|
value,
|
|
onSave,
|
|
}: {
|
|
field: FieldConfig
|
|
value: unknown
|
|
onSave: (value: unknown) => Promise<boolean>
|
|
}) {
|
|
const [lines, setLines] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
|
|
|
|
const update = (next: string[]) => {
|
|
setLines(next)
|
|
void onSave(next)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
|
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
|
<div className="mt-2 grid gap-2">
|
|
{lines.map((line, i) => (
|
|
<div key={i} className="flex items-start gap-2">
|
|
<textarea
|
|
dir="auto"
|
|
rows={line.length > 60 ? 3 : 1}
|
|
value={line}
|
|
onChange={(e) => {
|
|
const next = [...lines]
|
|
next[i] = e.target.value
|
|
setLines(next)
|
|
}}
|
|
onBlur={() => {
|
|
if (line !== (value as string[])[i]) update(lines)
|
|
}}
|
|
className="flex-1 resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
|
/>
|
|
<button
|
|
type="button"
|
|
aria-label="حذف خط"
|
|
onClick={() => update(lines.filter((_, j) => j !== i))}
|
|
className="mt-1 text-ivory/40 transition-colors hover:text-red-300"
|
|
>
|
|
<XIcon className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={() => update([...lines, ''])}
|
|
className="flex w-fit items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1.5 text-xs font-light text-ivory/60 transition-colors hover:border-copper hover:text-copper"
|
|
>
|
|
<PlusIcon className="h-3 w-3" />
|
|
افزودن خط
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|