لایه ویرایش بصری روی سایت واقعی (بدون داشبورد جدا): - ورود ادمین از مسیر پنهان /admin/login با توکن JWT (localStorage) و AdminContext شامل isEditMode و وضعیت ذخیره (همگام/در حال ذخیره/خطا) - AdminBar شناور پایین صفحه (فقط برای کاربر واردشده): کلید حالت ویرایش، وضعیت ذخیره، خروج — حالت ویرایش در sessionStorage میماند - InlineEdit: در حالت عادی دقیقاً همان HTML قبلی (بدون تغییر چیدمان)؛ در حالت ویرایش کادرخطچین + contentEditable با ذخیره خودکار در blur (Enter ذخیره، Esc لغو) — وایرایششده روی همه متنهای سایت: هیرو، تیتر بخشها، هیرو صفحات، داستان، فرم تماس، خبرنامه، فوتر، منو، برچسبهای وضعیت پروژه و... - کشوی عمومی EntityDrawer با پیکربندی متمرکز برای ۱۴ مجموعه (خدمات، پروژهها، مقالات، نظرات، سوالات، تیم، تایملاین، آمار، چرا آرامش، مدلهای سرمایهگذاری، مراحل کار، دفاتر، گالری، منو): ویرایش چندفیلدی، افزودن، حذف و جابهجایی ترتیب (▲▼) با ذخیره خودکار هر فیلد - دکمههای شناور ویرایش/حذف/ترتیب فقط در حالت ویرایش روی آیتمها - کشوهای یگانه: برند و لوگو، تنظیمات هیرو، فرم تماس، خبرنامه، فوتر، قبل/بعد و نقلقول داستان — آپلود تصویر به MinIO از همانجا - بهروزرسانی خوشبینانه SiteContext (updateSite) پس از هر PATCH؛ مسیرها در حالت ویرایش کلیک نمیشوند؛ breadcrumb محافظ beforeunload
88 lines
2.8 KiB
TypeScript
88 lines
2.8 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import type { CSSProperties, ElementType, KeyboardEvent } from 'react'
|
|
import { useAdmin } from '../../admin/AdminContext'
|
|
|
|
type InlineEditProps = {
|
|
value: string
|
|
/** ذخیره async — در صورت خطا، متن به مقدار قبلی برمیگردد */
|
|
onSave: (value: string) => Promise<boolean> | boolean | void
|
|
as?: ElementType
|
|
/** چندخطی: Enter خط جدید میگذارد و فقط blur ذخیره میکند */
|
|
multiline?: boolean
|
|
className?: string
|
|
style?: CSSProperties
|
|
'aria-label'?: string
|
|
}
|
|
|
|
/**
|
|
* ویرایش درجا متن — در حالت عادی دقیقاً همان HTML قبلی را رندر میکند
|
|
* (بدون هیچ تغییر چیدمان) و فقط در Edit Mode برای ادمین قابل ویرایش میشود.
|
|
*/
|
|
export function InlineEdit({
|
|
value,
|
|
onSave,
|
|
as: Tag = 'span',
|
|
multiline = false,
|
|
className = '',
|
|
style,
|
|
...rest
|
|
}: InlineEditProps) {
|
|
const { isAuthenticated, isEditMode } = useAdmin()
|
|
const editing = isAuthenticated && isEditMode
|
|
const ref = useRef<HTMLElement>(null)
|
|
const [focused, setFocused] = useState(false)
|
|
|
|
// همگامسازی متن فقط وقتی المان در حال ویرایش نیست (پرش نشانگر رخ ندهد)
|
|
useEffect(() => {
|
|
const el = ref.current
|
|
if (el && !focused && el.innerText !== value) el.innerText = value
|
|
}, [value, focused])
|
|
|
|
if (!editing) {
|
|
return (
|
|
<Tag className={className} style={style} {...rest}>
|
|
{value}
|
|
</Tag>
|
|
)
|
|
}
|
|
|
|
const commit = async () => {
|
|
const el = ref.current
|
|
if (!el) return
|
|
setFocused(false)
|
|
const next = el.innerText.replace(/\u00a0/g, ' ')
|
|
if (next === value) return
|
|
el.innerText = value // بازگشت فوری به مقدار فعلی؛ در صورت موفقیت، بهروزرسانی خوشبینانه متن تازه میآید
|
|
await onSave(next)
|
|
}
|
|
|
|
const onKeyDown = (e: KeyboardEvent<HTMLElement>) => {
|
|
if (!multiline && e.key === 'Enter') {
|
|
e.preventDefault()
|
|
;(e.currentTarget as HTMLElement).blur()
|
|
}
|
|
if (e.key === 'Escape') {
|
|
if (ref.current) ref.current.innerText = value
|
|
;(e.currentTarget as HTMLElement).blur()
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Tag
|
|
ref={ref as never}
|
|
contentEditable
|
|
suppressContentEditableWarning
|
|
spellCheck={false}
|
|
data-inline-edit
|
|
aria-label={rest['aria-label']}
|
|
className={`${className} cursor-text rounded-sm transition-[outline-color,background-color] duration-150 outline-1 outline-dashed outline-transparent hover:bg-copper/5 hover:outline-copper/40 focus:bg-copper/5 focus:outline-copper focus:outline-solid`}
|
|
style={style}
|
|
onFocus={() => setFocused(true)}
|
|
onBlur={commit}
|
|
onKeyDown={onKeyDown}
|
|
>
|
|
{value}
|
|
</Tag>
|
|
)
|
|
}
|