# Conflicts: # frontend/src/App.tsx # frontend/src/admin/ArticleWritePage.tsx # frontend/src/admin/EntityDrawer.tsx # frontend/src/pages/ArticleDetail.tsx
495 lines
17 KiB
TypeScript
495 lines
17 KiB
TypeScript
import { useEffect, useRef, 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 { editableFields } from './entities'
|
|
import { toArticleBodyDoc } from '../utils/tiptapDoc'
|
|
import type { EntityConfig, FieldConfig } from './entities'
|
|
import { ImageField } from '../components/admin/ImageField'
|
|
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
|
|
import { LuxeCreatableSelect, LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
|
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
|
|
|
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, editLang } = useAdmin()
|
|
const { reload } = useSiteData()
|
|
const [deleting, setDeleting] = useState(false)
|
|
const flushRef = useRef<() => void>(() => {})
|
|
|
|
// در ویرایش انگلیسی فقط فیلدهای قابل ترجمه با نام انگلیسی رندر میشوند؛
|
|
// متن فارسی همان فیلد بهعنوان جاینگهدار نشان داده میشود تا مترجم زمینه را ببیند
|
|
const fields = editableFields(config, editLang)
|
|
const faBase = (name: string) => name.replace(/_en$/, '')
|
|
|
|
const closeWithFlush = () => {
|
|
flushRef.current()
|
|
onClose()
|
|
}
|
|
|
|
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={closeWithFlush}
|
|
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 flex-col border-e border-ivory/10 bg-ink shadow-2xl ${
|
|
config.resource === 'articles' ? 'sm:max-w-3xl' : 'sm:max-w-md'
|
|
}`}
|
|
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={closeWithFlush}
|
|
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">
|
|
{fields.map((field) => {
|
|
const isImage = field.type === 'image'
|
|
// فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
|
|
const altKey = isImage
|
|
? field.name === 'src' && 'alt' in item
|
|
? 'alt'
|
|
: `${field.name}_alt`
|
|
: ''
|
|
const imageTitleKey = isImage ? `${field.name}_title` : ''
|
|
// پیشنهاد Alt از عنوان رکورد ساخته میشود (هرگز از URL)
|
|
const imageAltFallback = [
|
|
String(item[config.titleKey] ?? '').trim(),
|
|
field.altFallbackSuffix,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' - ')
|
|
return (
|
|
<FieldControl
|
|
key={field.name}
|
|
field={field}
|
|
value={item[field.name]}
|
|
placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined}
|
|
altValue={altKey ? String(item[altKey] ?? '') : undefined}
|
|
altFallback={imageAltFallback}
|
|
imageTitleValue={imageTitleKey ? String(item[imageTitleKey] ?? '') : undefined}
|
|
onSave={(value) => saveField(field.name, value)}
|
|
onSaveAlt={altKey ? (alt) => saveField(altKey, alt) : undefined}
|
|
onSaveImageTitle={
|
|
imageTitleKey ? (title) => saveField(imageTitleKey, title) : undefined
|
|
}
|
|
registerFlush={
|
|
field.type === 'wysiwyg'
|
|
? (fn) => {
|
|
flushRef.current = fn
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
)
|
|
})}
|
|
</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,
|
|
altValue,
|
|
altFallback,
|
|
placeholder,
|
|
imageTitleValue,
|
|
onSave,
|
|
onSaveAlt,
|
|
onSaveImageTitle,
|
|
registerFlush,
|
|
}: {
|
|
field: FieldConfig
|
|
value: unknown
|
|
/** مقدار Alt تصویر — فقط برای فیلدهای image */
|
|
altValue?: string
|
|
altFallback?: string
|
|
/** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی بهعنوان جاینگهدار نمایش داده میشود */
|
|
placeholder?: unknown
|
|
/** مقدار Title تصویر — فقط برای فیلدهای image */
|
|
imageTitleValue?: string
|
|
onSave: (value: unknown) => Promise<boolean>
|
|
onSaveAlt?: (alt: string) => Promise<boolean>
|
|
onSaveImageTitle?: (title: string) => Promise<boolean>
|
|
registerFlush?: (fn: () => void) => void
|
|
}) {
|
|
const [text, setText] = useState<string>(String(value ?? ''))
|
|
|
|
// همگامسازی مقدار محلی با دادهای که از بیرون میآید (بارگذاری/بهروزرسانی خوشبینانه)
|
|
useEffect(() => {
|
|
setText(String(value ?? ''))
|
|
}, [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 ?? '')}
|
|
alt={altValue}
|
|
altFallback={altFallback}
|
|
title={imageTitleValue}
|
|
onSave={(url) => onSave(url)}
|
|
onSaveAlt={onSaveAlt}
|
|
onSaveTitle={onSaveImageTitle}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (field.type === 'select') {
|
|
// مقدار نمایشی از وضعیت محلی میآید تا افزودن/حذف گزینه فوراً دیده شود
|
|
const selectProps = {
|
|
value: text,
|
|
options: field.options ?? [],
|
|
onChange: (next: string) => {
|
|
setText(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">
|
|
{field.creatable ? (
|
|
<LuxeCreatableSelect {...selectProps} />
|
|
) : (
|
|
<LuxeSelect {...selectProps} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (field.type === 'date') {
|
|
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">
|
|
<JalaliDatePicker
|
|
value={String(value ?? '')}
|
|
onChange={(isoDate) => {
|
|
setText(isoDate)
|
|
void onSave(isoDate)
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (field.type === 'wysiwyg') {
|
|
return (
|
|
<WysiwygField
|
|
field={field}
|
|
value={value}
|
|
onSave={onSave}
|
|
registerFlush={registerFlush}
|
|
hint={placeholder ? 'خالی = بازدیدکننده متن فارسی را میبیند' : undefined}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (field.type === 'list') {
|
|
return field.ui === 'tags' ? (
|
|
<TagsField field={field} value={value} onSave={onSave} />
|
|
) : (
|
|
<ListField field={field} value={value} onSave={onSave} hint={placeholder ? 'خالی = بازدیدکننده فهرست فارسی را میبیند' : undefined} />
|
|
)
|
|
}
|
|
|
|
if (field.type === 'textarea') {
|
|
return (
|
|
<div>
|
|
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
|
<textarea
|
|
dir="auto"
|
|
rows={4}
|
|
placeholder={placeholder ? String(placeholder) : undefined}
|
|
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'}
|
|
placeholder={placeholder ? String(placeholder) : undefined}
|
|
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>
|
|
)
|
|
}
|
|
|
|
/** فیلد هشتگها — react-select قابل ساخت (Creatable) با ذخیره فوری */
|
|
function TagsField({
|
|
field,
|
|
value,
|
|
onSave,
|
|
}: {
|
|
field: FieldConfig
|
|
value: unknown
|
|
onSave: (value: unknown) => Promise<boolean>
|
|
}) {
|
|
const [tags, setTags] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
|
|
|
|
const update = (next: string[]) => {
|
|
setTags(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">
|
|
<LuxeTagsSelect value={tags} onChange={update} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** ویرایش آرایه خطی (پاراگرافها، گالری، نکات) — هر تغییر ذخیره میشود */
|
|
function ListField({
|
|
field,
|
|
value,
|
|
onSave,
|
|
hint,
|
|
}: {
|
|
field: FieldConfig
|
|
value: unknown
|
|
onSave: (value: unknown) => Promise<boolean>
|
|
hint?: string
|
|
}) {
|
|
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>}
|
|
{hint && <p className="mt-1 text-[10px] font-light text-copper/70">{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>
|
|
)
|
|
}
|
|
|
|
|
|
/** فیلد WYSIWYG مقاله — ذخیره خودکار با تاخیر کوتاه و flush هنگام بستن کشو */
|
|
function WysiwygField({
|
|
field,
|
|
value,
|
|
onSave,
|
|
registerFlush,
|
|
hint,
|
|
}: {
|
|
field: FieldConfig
|
|
value: unknown
|
|
onSave: (value: unknown) => Promise<boolean>
|
|
registerFlush?: (fn: () => void) => void
|
|
hint?: string
|
|
}) {
|
|
const [doc, setDoc] = useState<Record<string, unknown>>(
|
|
// بدنه ذخیرهشده (سند جدید، رشته JSON یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
|
() => toArticleBodyDoc(value),
|
|
)
|
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const pending = useRef<Record<string, unknown> | null>(null)
|
|
|
|
const flush = () => {
|
|
if (timer.current) {
|
|
clearTimeout(timer.current)
|
|
timer.current = null
|
|
}
|
|
if (pending.current) {
|
|
const next = pending.current
|
|
pending.current = null
|
|
void onSave(next)
|
|
}
|
|
}
|
|
|
|
// ثبت آخرین flush در کشو + ذخیره تغییرات در انتظار هنگام unmount
|
|
useEffect(() => {
|
|
registerFlush?.(() => flush())
|
|
return () => {
|
|
if (timer.current) clearTimeout(timer.current)
|
|
if (pending.current) {
|
|
const next = pending.current
|
|
pending.current = null
|
|
void onSave(next)
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
})
|
|
|
|
const handleChange = (json: Record<string, unknown>) => {
|
|
setDoc(json)
|
|
pending.current = json
|
|
if (timer.current) clearTimeout(timer.current)
|
|
timer.current = setTimeout(flush, 800)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
|
{hint && <p className="mt-1 text-[10px] font-light text-copper/70">{hint}</p>}
|
|
<p className="mt-1 text-[10px] font-light text-ivory/40">
|
|
ذخیره خودکار — تصویر را داخل متن بچسبانید یا بکشید
|
|
</p>
|
|
<div className="mt-2">
|
|
<ArticleEditor doc={doc} onChange={handleChange} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|