فرانت‌اند: ویرایش زنده درجا — دوقلوی دیجیتال سایت برای ادمین

لایه ویرایش بصری روی سایت واقعی (بدون داشبورد جدا):

- ورود ادمین از مسیر پنهان /admin/login با توکن JWT (localStorage) و
  AdminContext شامل isEditMode و وضعیت ذخیره (همگام/در حال ذخیره/خطا)
- AdminBar شناور پایین صفحه (فقط برای کاربر واردشده): کلید حالت ویرایش،
  وضعیت ذخیره، خروج — حالت ویرایش در sessionStorage می‌ماند
- InlineEdit: در حالت عادی دقیقاً همان HTML قبلی (بدون تغییر چیدمان)؛
  در حالت ویرایش کادرخط‌چین + contentEditable با ذخیره خودکار در blur
  (Enter ذخیره، Esc لغو) — وایرایش‌شده روی همه متن‌های سایت: هیرو،
  تیتر بخش‌ها، هیرو صفحات، داستان، فرم تماس، خبرنامه، فوتر، منو،
  برچسب‌های وضعیت پروژه و...
- کشوی عمومی EntityDrawer با پیکربندی متمرکز برای ۱۴ مجموعه (خدمات،
  پروژه‌ها، مقالات، نظرات، سوالات، تیم، تایم‌لاین، آمار، چرا آرامش،
  مدل‌های سرمایه‌گذاری، مراحل کار، دفاتر، گالری، منو): ویرایش چندفیلدی،
  افزودن، حذف و جابه‌جایی ترتیب (▲▼) با ذخیره خودکار هر فیلد
- دکمه‌های شناور ویرایش/حذف/ترتیب فقط در حالت ویرایش روی آیتم‌ها
- کشوهای یگانه: برند و لوگو، تنظیمات هیرو، فرم تماس، خبرنامه، فوتر،
  قبل/بعد و نقل‌قول داستان — آپلود تصویر به MinIO از همان‌جا
- به‌روزرسانی خوش‌بینانه SiteContext (updateSite) پس از هر PATCH؛
  مسیرها در حالت ویرایش کلیک نمی‌شوند؛ breadcrumb محافظ beforeunload
This commit is contained in:
MMD
2026-09-06 21:57:46 +03:30
parent 6e422ec3dc
commit 5ed082631e
39 changed files with 1882 additions and 260 deletions
@@ -0,0 +1,81 @@
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>
)
}
@@ -0,0 +1,87 @@
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>
)
}