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

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

- ورود ادمین از مسیر پنهان /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
+157
View File
@@ -0,0 +1,157 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import Swal from 'sweetalert2'
import type { SiteData } from '../api'
import { useSiteData } from '../api'
import { adminApi, getToken, HttpError, setToken } from './adminClient'
export type SaveStatus = 'idle' | 'saving' | 'error'
interface AdminContextValue {
isAuthenticated: boolean
isEditMode: boolean
toggleEditMode: () => void
login: (username: string, password: string) => Promise<void>
logout: () => void
saveStatus: SaveStatus
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را به‌روز می‌کند و خطاها را با دکمه تلاش دوباره نشان می‌دهد */
runSave: (action: () => Promise<void>) => Promise<boolean>
}
const AdminContext = createContext<AdminContextValue | null>(null)
export function useAdmin(): AdminContextValue {
const ctx = useContext(AdminContext)
if (!ctx) throw new Error('useAdmin باید داخل AdminProvider استفاده شود')
return ctx
}
function showSaveError(error: unknown): void {
const message =
error instanceof HttpError ? error.message : 'ارتباط با سرور برقرار نشد — اتصال اینترنت را بررسی کنید'
Swal.fire({
icon: 'error',
title: 'ذخیره نشد',
text: message,
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
confirmButtonText: 'باشه',
timer: 4000,
timerProgressBar: true,
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10 rounded-none',
title: 'font-pinar font-bold text-lg text-ivory',
htmlContainer: 'font-pinar font-light text-sm text-ivory/70',
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5',
},
})
}
export function AdminProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => !!getToken())
const [isEditMode, setIsEditMode] = useState<boolean>(
() => sessionStorage.getItem('aramesh_edit_mode') === '1',
)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const { reload } = useSiteData()
const toggleEditMode = useCallback(() => {
setIsEditMode((v) => {
sessionStorage.setItem('aramesh_edit_mode', v ? '0' : '1')
return !v
})
}, [])
const login = useCallback(async (username: string, password: string) => {
await adminApi.login(username, password)
setIsAuthenticated(true)
}, [])
const logout = useCallback(() => {
setToken(null)
setIsAuthenticated(false)
setIsEditMode(false)
sessionStorage.removeItem('aramesh_edit_mode')
}, [])
const runSave = useCallback(
async (action: () => Promise<void>): Promise<boolean> => {
setSaveStatus('saving')
try {
await action()
setSaveStatus('idle')
return true
} catch (error) {
setSaveStatus('error')
showSaveError(error)
if (error instanceof HttpError && error.status === 401) {
logout()
reload()
}
return false
}
},
[logout, reload],
)
// در حالت ویرایش، کلیک روی لینک‌ها مسیریابی نمی‌کند تا ویرایش متن ممکن باشد
useEffect(() => {
if (!isEditMode) return
const onClick = (e: MouseEvent) => {
const target = e.target as HTMLElement | null
if (target?.closest('a')) e.preventDefault()
}
document.addEventListener('click', onClick, true)
return () => document.removeEventListener('click', onClick, true)
}, [isEditMode])
// در حین ذخیره‌سازی، از بسته‌شدن تصادفی صفحه جلوگیری می‌شود
useEffect(() => {
if (saveStatus !== 'saving') return
const guard = (e: BeforeUnloadEvent) => {
e.preventDefault()
}
window.addEventListener('beforeunload', guard)
return () => window.removeEventListener('beforeunload', guard)
}, [saveStatus])
const value = useMemo<AdminContextValue>(
() => ({
isAuthenticated,
isEditMode,
toggleEditMode,
login,
logout,
saveStatus,
runSave,
}),
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave],
)
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
}
/**
* پرکاربردترین کمک‌ساز ویرایش: یک فیلد واحد را PATCH می‌کند و پس از موفقیت
* همان تغییر را به‌صورت خوش‌بینانه در درخت داده سایت اعمال می‌کند.
*/
export function useFieldSaver() {
const { runSave } = useAdmin()
const { updateSite } = useSiteData()
return useCallback(
(
resource: string,
id: number,
changes: Record<string, unknown>,
apply: (data: SiteData) => void,
): Promise<boolean> =>
runSave(async () => {
await adminApi.patch(resource, id, changes)
updateSite(apply)
}),
[runSave, updateSite],
)
}