فرانتاند: ویرایشگر WYSIWYG وبلاگ (TipTap) با آپلود تصویر در متن
- ArticleEditor: ویرایشگر TipTap v3 راستبهچسب با نوار ابزار فارسی — بولد/ایتالیک/زیرخط/خطخورده، تیتر H2/H3، لیست نقطهای/شمارهای، نقلقول، لینک (مسدودسازی javascript: با isAllowedUri)، واگرد/ازنو - درج تصویر داخل متن: دکمه آپلود، چسباندن یا کشیدن فایل — ذخیره در MinIO از طریق /api/admin/upload و درج URL نسبی /media/... - ذخیره خودکار: debounce ۸۰۰ms + flush هنگام بستن کشو - کشوی مقاله عریضتر (max-w-3xl)؛ فیلد body مقالات بهجای لیست متنی، ویرایشگر WYSIWYG رندر میکند - رندر صفحه مقاله: تبدیل سند به HTML با @tiptap/static-renderer (فقط تگهای شناختهشده — بدون ریسک XSS) + استایلهای .article-body (تیتر با خط مسی، لیست، نقلقول، تصویر) + پشتیبانی از ردیفهای قدیمی - وابستگیهای جدید: @tiptap/react، @tiptap/pm، @tiptap/starter-kit، @tiptap/extension-image، @tiptap/extension-link، @tiptap/static-renderer
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { PlusIcon, Trash2Icon, XIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
@@ -7,6 +7,7 @@ import { useAdmin, useFieldSaver } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import type { EntityConfig, FieldConfig } from './entities'
|
||||
import { ImageField } from '../components/admin/ImageField'
|
||||
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||
|
||||
type EntityDrawerProps = {
|
||||
config: EntityConfig
|
||||
@@ -22,6 +23,12 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
||||
const { runSave } = useAdmin()
|
||||
const { reload } = useSiteData()
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const flushRef = useRef<() => void>(() => {})
|
||||
|
||||
const closeWithFlush = () => {
|
||||
flushRef.current()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const saveField = (name: string, value: unknown) =>
|
||||
save(config.resource, item.id, { [name]: value }, (d) => {
|
||||
@@ -69,7 +76,7 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
onClick={closeWithFlush}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<motion.aside
|
||||
@@ -77,7 +84,9 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
||||
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"
|
||||
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%' }}
|
||||
@@ -92,7 +101,7 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
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"
|
||||
>
|
||||
@@ -108,6 +117,13 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
||||
field={field}
|
||||
value={item[field.name]}
|
||||
onSave={(value) => saveField(field.name, value)}
|
||||
registerFlush={
|
||||
field.type === 'wysiwyg'
|
||||
? (fn) => {
|
||||
flushRef.current = fn
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -135,10 +151,12 @@ function FieldControl({
|
||||
field,
|
||||
value,
|
||||
onSave,
|
||||
registerFlush,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
registerFlush?: (fn: () => void) => void
|
||||
}) {
|
||||
const [text, setText] = useState<string>(String(value ?? ''))
|
||||
|
||||
@@ -152,6 +170,10 @@ function FieldControl({
|
||||
return <ImageField label={field.label} value={String(value ?? '')} onSave={(url) => onSave(url)} />
|
||||
}
|
||||
|
||||
if (field.type === 'wysiwyg') {
|
||||
return <WysiwygField field={field} value={value} onSave={onSave} registerFlush={registerFlush} />
|
||||
}
|
||||
|
||||
if (field.type === 'list') {
|
||||
return <ListField field={field} value={value} onSave={onSave} />
|
||||
}
|
||||
@@ -248,3 +270,70 @@ function ListField({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/** فیلد WYSIWYG مقاله — ذخیره خودکار با تاخیر کوتاه و flush هنگام بستن کشو */
|
||||
function WysiwygField({
|
||||
field,
|
||||
value,
|
||||
onSave,
|
||||
registerFlush,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
registerFlush?: (fn: () => void) => void
|
||||
}) {
|
||||
const [doc, setDoc] = useState<Record<string, unknown>>(() =>
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: { type: 'doc', content: [{ type: 'paragraph' }] },
|
||||
)
|
||||
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>
|
||||
<p className="mt-1 text-[10px] font-light text-ivory/40">
|
||||
ذخیره خودکار — تصویر را داخل متن بچسبانید یا بکشید
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<ArticleEditor doc={doc} onChange={handleChange} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user