228 lines
8.5 KiB
TypeScript
228 lines
8.5 KiB
TypeScript
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'
|
|
import { ENTITIES, isTranslatable } from './entities'
|
|
|
|
export type SaveStatus = 'idle' | 'saving' | 'error'
|
|
/** زبان ویرایش محتوا — رابط مدیریتی فارسی میماند؛ این فقط تعیین میکند کدام ستون زبان ذخیره شود */
|
|
export type EditLang = 'fa' | 'en'
|
|
|
|
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>
|
|
/** زبان محتوایی که در حالت ویرایش ذخیره میشود (فارسی یا انگلیسی) */
|
|
editLang: EditLang
|
|
toggleEditLang: () => void
|
|
}
|
|
|
|
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 [editLang, setEditLang] = useState<EditLang>(
|
|
() => (sessionStorage.getItem('aramesh_edit_lang') === 'en' ? 'en' : 'fa'),
|
|
)
|
|
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 toggleEditLang = useCallback(() => {
|
|
setEditLang((v) => {
|
|
const next: EditLang = v === 'fa' ? 'en' : 'fa'
|
|
sessionStorage.setItem('aramesh_edit_lang', next)
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
const login = useCallback(async (username: string, password: string) => {
|
|
await adminApi.login(username, password)
|
|
setIsAuthenticated(true)
|
|
// داده سایت دوباره بارگذاری میشود تا پیشنویس مقالهها هم ادغام شوند
|
|
reload()
|
|
}, [reload])
|
|
|
|
const logout = useCallback(() => {
|
|
setToken(null)
|
|
setIsAuthenticated(false)
|
|
setIsEditMode(false)
|
|
sessionStorage.removeItem('aramesh_edit_mode')
|
|
// داده سایت دوباره بارگذاری میشود تا پیشنویسها از درخت داده حذف شوند
|
|
reload()
|
|
}, [reload])
|
|
|
|
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 anchor = (e.target as HTMLElement | null)?.closest('a')
|
|
// لینکهای خود لایه مدیریت (مثل «نوشتن مقاله جدید») کار میکنند
|
|
if (anchor && !anchor.dataset.adminLink) 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,
|
|
editLang,
|
|
toggleEditLang,
|
|
}),
|
|
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang],
|
|
)
|
|
|
|
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
|
|
}
|
|
|
|
/**
|
|
* پرکاربردترین کمکساز ویرایش: یک فیلد واحد را PATCH میکند و پس از موفقیت
|
|
* همان تغییر را بهصورت خوشبینانه در درخت داده سایت اعمال میکند.
|
|
*
|
|
* در حالت ویرایش انگلیسی، کلیدهای قابل ترجمه به پسوند _en نگاشت میشوند؛ چون
|
|
* برخی mutatorهای کامپوننتها نام فیلد را جداگانه مینویسند، مقدار قبلی فیلدهای
|
|
* فارسی اسنپشات میشود و پس از اجرای mutator بازگردانده میشود تا متن انگلیسی
|
|
* هرگز جای متن فارسی در درخت خام ننشیند.
|
|
*/
|
|
export function useFieldSaver() {
|
|
const { runSave, editLang } = useAdmin()
|
|
const { updateSite } = useSiteData()
|
|
|
|
return useCallback(
|
|
(
|
|
resource: string,
|
|
id: number,
|
|
changes: Record<string, unknown>,
|
|
apply: (data: SiteData) => void,
|
|
): Promise<boolean> =>
|
|
runSave(async () => {
|
|
const originalKeys = Object.keys(changes)
|
|
if (editLang === 'en') {
|
|
const mapped: Record<string, unknown> = {}
|
|
for (const [key, value] of Object.entries(changes)) {
|
|
mapped[isTranslatable(resource, key) ? `${key}_en` : key] = value
|
|
}
|
|
for (const key of Object.keys(changes)) delete changes[key]
|
|
Object.assign(changes, mapped)
|
|
}
|
|
await adminApi.patch(resource, id, changes)
|
|
updateSite((d) => {
|
|
const config = ENTITIES[resource]
|
|
let node: Record<string, unknown> | undefined
|
|
if (config) {
|
|
const target = d[config.dataKey as keyof SiteData] as unknown
|
|
node =
|
|
config.kind === 'singleton'
|
|
? (target as Record<string, unknown> | undefined)
|
|
: (target as { id: number }[] | undefined)?.find((x) => x.id === id)
|
|
}
|
|
const snapshot =
|
|
editLang === 'en' && node
|
|
? Object.fromEntries(originalKeys.filter((k) => k !== 'id').map((k) => [k, node![k]]))
|
|
: null
|
|
apply(d)
|
|
if (snapshot && node) {
|
|
Object.assign(node, snapshot)
|
|
Object.assign(node, changes)
|
|
}
|
|
})
|
|
}),
|
|
[runSave, updateSite, editLang],
|
|
)
|
|
}
|
|
|
|
/**
|
|
* نام فیلد متناظر با زبان ویرایش جاری — در حالت انگلیسی فیلدهای قابل ترجمه
|
|
* پسوند _en میگیرند. برای جاهایی که نام فیلد جدا از شیء تغییرات ذخیره میشود.
|
|
*/
|
|
export function useEditField() {
|
|
const { editLang } = useAdmin()
|
|
return useCallback(
|
|
(resource: string, field: string) =>
|
|
editLang === 'en' && isTranslatable(resource, field) ? `${field}_en` : field,
|
|
[editLang],
|
|
)
|
|
}
|