فرانتاند: نسخه انگلیسی با مسیر /en/ — مسیریابی دوزبانه، دیکشنری رابط، بومیسازی داده، تغییر زبان در هدر، ویرایش انگلیسی در پنل و پشتیبانی LTR
This commit is contained in:
@@ -126,7 +126,7 @@ function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||
|
||||
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر میشود */
|
||||
export function AdminBar() {
|
||||
const { isEditMode, toggleEditMode, saveStatus, logout } = useAdmin()
|
||||
const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang } = useAdmin()
|
||||
const [pwOpen, setPwOpen] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -161,6 +161,19 @@ export function AdminBar() {
|
||||
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleEditLang}
|
||||
title="تعیین میکند ویرایشها روی کدام ستون زبان ذخیره شوند"
|
||||
className={`flex items-center gap-1.5 border px-3 py-1.5 text-xs font-light transition-colors duration-200 ${
|
||||
editLang === 'en'
|
||||
? 'border-copper bg-copper/10 text-copper'
|
||||
: 'border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
|
||||
}`}
|
||||
>
|
||||
زبان ویرایش: {editLang === 'fa' ? 'فارسی' : 'English'}
|
||||
</button>
|
||||
|
||||
<span className="flex items-center gap-2 border-r border-ivory/10 pr-5 text-xs font-light text-ivory/60">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${STATUS_COLOR[saveStatus]}`} aria-hidden="true" />
|
||||
{STATUS_TEXT[saveStatus]}
|
||||
|
||||
@@ -4,8 +4,11 @@ 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
|
||||
@@ -16,6 +19,9 @@ interface AdminContextValue {
|
||||
saveStatus: SaveStatus
|
||||
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را بهروز میکند و خطاها را با دکمه تلاش دوباره نشان میدهد */
|
||||
runSave: (action: () => Promise<void>) => Promise<boolean>
|
||||
/** زبان محتوایی که در حالت ویرایش ذخیره میشود (فارسی یا انگلیسی) */
|
||||
editLang: EditLang
|
||||
toggleEditLang: () => void
|
||||
}
|
||||
|
||||
const AdminContext = createContext<AdminContextValue | null>(null)
|
||||
@@ -54,6 +60,9 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
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()
|
||||
|
||||
@@ -64,6 +73,14 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
})
|
||||
}, [])
|
||||
|
||||
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)
|
||||
@@ -131,8 +148,10 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
logout,
|
||||
saveStatus,
|
||||
runSave,
|
||||
editLang,
|
||||
toggleEditLang,
|
||||
}),
|
||||
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave],
|
||||
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang],
|
||||
)
|
||||
|
||||
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
|
||||
@@ -141,9 +160,14 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
/**
|
||||
* پرکاربردترین کمکساز ویرایش: یک فیلد واحد را PATCH میکند و پس از موفقیت
|
||||
* همان تغییر را بهصورت خوشبینانه در درخت داده سایت اعمال میکند.
|
||||
*
|
||||
* در حالت ویرایش انگلیسی، کلیدهای قابل ترجمه به پسوند _en نگاشت میشوند؛ چون
|
||||
* برخی mutatorهای کامپوننتها نام فیلد را جداگانه مینویسند، مقدار قبلی فیلدهای
|
||||
* فارسی اسنپشات میشود و پس از اجرای mutator بازگردانده میشود تا متن انگلیسی
|
||||
* هرگز جای متن فارسی در درخت خام ننشیند.
|
||||
*/
|
||||
export function useFieldSaver() {
|
||||
const { runSave } = useAdmin()
|
||||
const { runSave, editLang } = useAdmin()
|
||||
const { updateSite } = useSiteData()
|
||||
|
||||
return useCallback(
|
||||
@@ -154,9 +178,50 @@ export function useFieldSaver() {
|
||||
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(apply)
|
||||
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],
|
||||
[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],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,12 +16,18 @@ type Draft = {
|
||||
id: number
|
||||
slug: string
|
||||
title: string
|
||||
title_en: string
|
||||
date: string
|
||||
date_en: string
|
||||
category: string
|
||||
category_en: string
|
||||
image: string
|
||||
read: string
|
||||
read_en: string
|
||||
excerpt: string
|
||||
excerpt_en: string
|
||||
body: ArticleBodyDoc
|
||||
body_en: ArticleBodyDoc
|
||||
status: 'draft' | 'published'
|
||||
seo_keyphrase: string
|
||||
}
|
||||
@@ -37,7 +43,7 @@ const STATUS_TEXT: Record<string, string> = {
|
||||
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
|
||||
*/
|
||||
export function ArticleWritePage() {
|
||||
const { isAuthenticated, saveStatus, runSave } = useAdmin()
|
||||
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang } = useAdmin()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { data, updateSite } = useSiteData()
|
||||
@@ -50,12 +56,18 @@ export function ArticleWritePage() {
|
||||
id: 0,
|
||||
slug: `article-${Date.now()}`,
|
||||
title: '',
|
||||
title_en: '',
|
||||
date: new Date().toLocaleDateString('fa-IR'),
|
||||
date_en: '',
|
||||
category: 'یادداشت',
|
||||
category_en: '',
|
||||
image: '',
|
||||
read: '۵ دقیقه',
|
||||
read_en: '',
|
||||
excerpt: '',
|
||||
excerpt_en: '',
|
||||
body: EMPTY_DOC,
|
||||
body_en: EMPTY_DOC,
|
||||
status: 'draft',
|
||||
seo_keyphrase: '',
|
||||
})
|
||||
@@ -90,9 +102,14 @@ export function ArticleWritePage() {
|
||||
// وقتی مقاله موجود شناخته شد، فرم و شناسه ساختهشده یکبار مقداردهی میشود
|
||||
useEffect(() => {
|
||||
if (!article || createdRef.current) return
|
||||
const rawEnBody = (article as unknown as { body_en?: unknown }).body_en
|
||||
setDraft({
|
||||
...article,
|
||||
...(article as unknown as Draft),
|
||||
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc,
|
||||
body_en:
|
||||
rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody)
|
||||
? (rawEnBody as ArticleBodyDoc)
|
||||
: EMPTY_DOC,
|
||||
})
|
||||
setCreatedId(article.id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -190,6 +207,9 @@ export function ArticleWritePage() {
|
||||
queueSave({ [name]: e.target.value }),
|
||||
})
|
||||
|
||||
// در زبان انگلیسی، فیلدهای متنی به ستونهای _en میروند؛ اسلاگ/تصویر/وضعیت مشترکاند
|
||||
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
|
||||
|
||||
return (
|
||||
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
|
||||
{/* نوار بالا */}
|
||||
@@ -205,6 +225,18 @@ export function ArticleWritePage() {
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleEditLang}
|
||||
title="تعیین میکند متن مقاله روی کدام زبان ذخیره شود"
|
||||
className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${
|
||||
editLang === 'en'
|
||||
? 'bg-copper text-char'
|
||||
: 'border border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
|
||||
}`}
|
||||
>
|
||||
{editLang === 'fa' ? 'ویرایش: فارسی' : 'Edit: English'}
|
||||
</button>
|
||||
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
||||
{saveStatus === 'idle' && <CheckIcon className="h-3.5 w-3.5 text-emerald-400" />}
|
||||
{STATUS_TEXT[saveStatus]}
|
||||
@@ -239,7 +271,7 @@ export function ArticleWritePage() {
|
||||
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیشنویس'}
|
||||
</button>
|
||||
<Link
|
||||
to={`/blog/${draft.slug}`}
|
||||
to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`}
|
||||
data-admin-link
|
||||
className="flex items-center gap-1.5 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/70 transition-colors hover:border-copper hover:text-copper"
|
||||
>
|
||||
@@ -270,11 +302,11 @@ export function ArticleWritePage() {
|
||||
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال میشود'}
|
||||
</p>
|
||||
|
||||
{/* عنوان */}
|
||||
{/* عنوان — در ویرایش انگلیسی، متن فارسی فعلی جاینگهدار میماند */}
|
||||
<input
|
||||
type="text"
|
||||
{...textField('title')}
|
||||
placeholder="عنوان مقاله…"
|
||||
{...textField(suf('title'))}
|
||||
placeholder={editLang === 'en' ? draft.title || 'English article title…' : 'عنوان مقاله…'}
|
||||
aria-label="عنوان مقاله"
|
||||
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
|
||||
/>
|
||||
@@ -292,11 +324,11 @@ export function ArticleWritePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* متن */}
|
||||
{/* متن — ویرایشگر روی سند زبان جاری کار میکند */}
|
||||
<div className="mt-8">
|
||||
<ArticleEditor
|
||||
doc={draft.body as unknown as Record<string, unknown>}
|
||||
onChange={(json) => queueSave({ body: json })}
|
||||
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
|
||||
onChange={(json) => queueSave({ [suf('body')]: json })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -328,7 +360,8 @@ export function ArticleWritePage() {
|
||||
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده میشود)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
{...textField('excerpt')}
|
||||
{...textField(suf('excerpt'))}
|
||||
placeholder={editLang === 'en' ? draft.excerpt || 'English excerpt…' : undefined}
|
||||
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
@@ -337,7 +370,7 @@ export function ArticleWritePage() {
|
||||
<label className="text-xs font-light text-ivory/50">دسته</label>
|
||||
<input
|
||||
type="text"
|
||||
{...textField('category')}
|
||||
{...textField(suf('category'))}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
@@ -345,7 +378,7 @@ export function ArticleWritePage() {
|
||||
<label className="text-xs font-light text-ivory/50">تاریخ</label>
|
||||
<input
|
||||
type="text"
|
||||
{...textField('date')}
|
||||
{...textField(suf('date'))}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
@@ -353,7 +386,7 @@ export function ArticleWritePage() {
|
||||
<label className="text-xs font-light text-ivory/50">زمان مطالعه</label>
|
||||
<input
|
||||
type="text"
|
||||
{...textField('read')}
|
||||
{...textField(suf('read'))}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import Swal from 'sweetalert2'
|
||||
import { useSiteData } from '../api'
|
||||
import { useAdmin, useFieldSaver } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import { editableFields } from './entities'
|
||||
import type { EntityConfig, FieldConfig } from './entities'
|
||||
import { ImageField } from '../components/admin/ImageField'
|
||||
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||
@@ -20,11 +21,16 @@ type EntityDrawerProps = {
|
||||
|
||||
export function EntityDrawer({ config, item, onClose, deletable = false }: EntityDrawerProps) {
|
||||
const save = useFieldSaver()
|
||||
const { runSave } = useAdmin()
|
||||
const { runSave, editLang } = useAdmin()
|
||||
const { reload } = useSiteData()
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const flushRef = useRef<() => void>(() => {})
|
||||
|
||||
// در ویرایش انگلیسی فقط فیلدهای قابل ترجمه با نام انگلیسی رندر میشوند؛
|
||||
// متن فارسی همان فیلد بهعنوان جاینگهدار نشان داده میشود تا مترجم زمینه را ببیند
|
||||
const fields = editableFields(config, editLang)
|
||||
const faBase = (name: string) => name.replace(/_en$/, '')
|
||||
|
||||
const closeWithFlush = () => {
|
||||
flushRef.current()
|
||||
onClose()
|
||||
@@ -111,11 +117,12 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||
<div className="grid gap-6">
|
||||
{config.fields.map((field) => (
|
||||
{fields.map((field) => (
|
||||
<FieldControl
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={item[field.name]}
|
||||
placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined}
|
||||
onSave={(value) => saveField(field.name, value)}
|
||||
registerFlush={
|
||||
field.type === 'wysiwyg'
|
||||
@@ -150,11 +157,14 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
||||
function FieldControl({
|
||||
field,
|
||||
value,
|
||||
placeholder,
|
||||
onSave,
|
||||
registerFlush,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
/** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی بهعنوان جاینگهدار نمایش داده میشود */
|
||||
placeholder?: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
registerFlush?: (fn: () => void) => void
|
||||
}) {
|
||||
@@ -171,11 +181,19 @@ function FieldControl({
|
||||
}
|
||||
|
||||
if (field.type === 'wysiwyg') {
|
||||
return <WysiwygField field={field} value={value} onSave={onSave} registerFlush={registerFlush} />
|
||||
return (
|
||||
<WysiwygField
|
||||
field={field}
|
||||
value={value}
|
||||
onSave={onSave}
|
||||
registerFlush={registerFlush}
|
||||
hint={placeholder ? 'خالی = بازدیدکننده متن فارسی را میبیند' : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'list') {
|
||||
return <ListField field={field} value={value} onSave={onSave} />
|
||||
return <ListField field={field} value={value} onSave={onSave} hint={placeholder ? 'خالی = بازدیدکننده فهرست فارسی را میبیند' : undefined} />
|
||||
}
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
@@ -185,6 +203,7 @@ function FieldControl({
|
||||
<textarea
|
||||
dir="auto"
|
||||
rows={4}
|
||||
placeholder={placeholder ? String(placeholder) : undefined}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commitText}
|
||||
@@ -201,6 +220,7 @@ function FieldControl({
|
||||
<input
|
||||
dir={field.type === 'number' ? 'ltr' : 'auto'}
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
placeholder={placeholder ? String(placeholder) : undefined}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commitText}
|
||||
@@ -215,10 +235,12 @@ function ListField({
|
||||
field,
|
||||
value,
|
||||
onSave,
|
||||
hint,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
hint?: string
|
||||
}) {
|
||||
const [lines, setLines] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
|
||||
|
||||
@@ -231,6 +253,7 @@ function ListField({
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
{hint && <p className="mt-1 text-[10px] font-light text-copper/70">{hint}</p>}
|
||||
<div className="mt-2 grid gap-2">
|
||||
{lines.map((line, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
@@ -278,11 +301,13 @@ function WysiwygField({
|
||||
value,
|
||||
onSave,
|
||||
registerFlush,
|
||||
hint,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
registerFlush?: (fn: () => void) => void
|
||||
hint?: string
|
||||
}) {
|
||||
const [doc, setDoc] = useState<Record<string, unknown>>(() =>
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
@@ -328,6 +353,7 @@ function WysiwygField({
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{hint && <p className="mt-1 text-[10px] font-light text-copper/70">{hint}</p>}
|
||||
<p className="mt-1 text-[10px] font-light text-ivory/40">
|
||||
ذخیره خودکار — تصویر را داخل متن بچسبانید یا بکشید
|
||||
</p>
|
||||
|
||||
@@ -7,6 +7,8 @@ export type FieldConfig = {
|
||||
label: string
|
||||
type: FieldType
|
||||
hint?: string
|
||||
/** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمیشوند */
|
||||
shared?: boolean
|
||||
}
|
||||
|
||||
export type EntityConfig = {
|
||||
@@ -24,11 +26,12 @@ export type EntityConfig = {
|
||||
defaults: Record<string, unknown>
|
||||
}
|
||||
|
||||
const F = (name: string, label: string, type: FieldType = 'text', hint?: string): FieldConfig => ({
|
||||
const F = (name: string, label: string, type: FieldType = 'text', hint?: string, shared = false): FieldConfig => ({
|
||||
name,
|
||||
label,
|
||||
type,
|
||||
hint,
|
||||
shared,
|
||||
})
|
||||
|
||||
/** پیکربندی همه منابع قابل ویرایش — هم مجموعهها و هم رکوردهای یگانه */
|
||||
@@ -36,16 +39,16 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
services: {
|
||||
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('icon', 'آیکون', 'text', 'draft / tower / mall / villa / renew / manage / invest / landscape')],
|
||||
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('icon', 'آیکون', 'text', 'draft / tower / mall / villa / renew / manage / invest / landscape', true)],
|
||||
defaults: { title: 'خدمت جدید', desc: '', icon: 'draft' },
|
||||
},
|
||||
projects: {
|
||||
resource: 'projects', dataKey: 'projects', title: 'پروژهها', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [
|
||||
F('title', 'عنوان'), F('slug', 'نشانی (slug)'), F('location', 'موقعیت'),
|
||||
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress'),
|
||||
F('image', 'تصویر شاخص', 'image'), F('gallery', 'گالری (هر خط یک URL)', 'list'),
|
||||
F('title', 'عنوان'), F('slug', 'نشانی (slug)', 'text', undefined, true), F('location', 'موقعیت'),
|
||||
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress', true),
|
||||
F('image', 'تصویر شاخص', 'image', undefined, true), F('gallery', 'گالری (هر خط یک URL)', 'list', undefined, true),
|
||||
F('area', 'زیربنا'), F('floors', 'طبقات'), F('units', 'واحدها'), F('client', 'کارفرما'),
|
||||
F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'),
|
||||
],
|
||||
@@ -58,11 +61,11 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
F('title', 'عنوان'),
|
||||
F('body', 'متن مقاله', 'wysiwyg'),
|
||||
F('excerpt', 'چکیده', 'textarea'),
|
||||
F('image', 'تصویر شاخص', 'image'),
|
||||
F('image', 'تصویر شاخص', 'image', undefined, true),
|
||||
F('category', 'دسته'),
|
||||
F('date', 'تاریخ'),
|
||||
F('read', 'زمان مطالعه'),
|
||||
F('slug', 'نشانی (slug)'),
|
||||
F('slug', 'نشانی (slug)', 'text', undefined, true),
|
||||
],
|
||||
defaults: { title: 'مقاله جدید', slug: `article-${Date.now()}`, date: '', category: '', image: '', read: '', excerpt: '', body: { type: 'doc', content: [{ type: 'paragraph' }] } },
|
||||
},
|
||||
@@ -87,13 +90,13 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
timeline: {
|
||||
resource: 'timeline', dataKey: 'timeline', title: 'تایملاین', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('year', 'سال'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('image', 'تصویر', 'image')],
|
||||
fields: [F('year', 'سال'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('image', 'تصویر', 'image', undefined, true)],
|
||||
defaults: { year: '۱۴۰۰', title: 'رویداد جدید', desc: '', image: '' },
|
||||
},
|
||||
stats: {
|
||||
resource: 'stats', dataKey: 'stats', title: 'آمار', kind: 'collection',
|
||||
titleKey: 'label',
|
||||
fields: [F('label', 'برچسب'), F('value', 'عدد', 'number'), F('suffix', 'پسوند (مثل + یا هزار)')],
|
||||
fields: [F('label', 'برچسب'), F('value', 'عدد', 'number', undefined, true), F('suffix', 'پسوند (مثل + یا هزار)')],
|
||||
defaults: { label: 'شاخص جدید', value: 0, suffix: '' },
|
||||
},
|
||||
why_us: {
|
||||
@@ -123,13 +126,13 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
gallery: {
|
||||
resource: 'gallery', dataKey: 'gallery', title: 'گالری تصاویر', kind: 'collection',
|
||||
titleKey: 'alt',
|
||||
fields: [F('src', 'تصویر', 'image'), F('alt', 'متن جایگزین')],
|
||||
fields: [F('src', 'تصویر', 'image', undefined, true), F('alt', 'متن جایگزین')],
|
||||
defaults: { src: '', alt: 'تصویر گالری' },
|
||||
},
|
||||
nav: {
|
||||
resource: 'nav', dataKey: 'nav', title: 'منوی ناوبری', kind: 'collection',
|
||||
titleKey: 'label',
|
||||
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)')],
|
||||
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)],
|
||||
defaults: { label: 'آیتم جدید', href: '/' },
|
||||
},
|
||||
|
||||
@@ -137,7 +140,7 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
|
||||
titleKey: 'page_key',
|
||||
fields: [
|
||||
F('page_key', 'کلید صفحه', 'text', 'home / about / services / projects / investment / blog / faq / contact'),
|
||||
F('page_key', 'کلید صفحه', 'text', 'home / about / services / projects / investment / blog / faq / contact', true),
|
||||
F('title', 'عنوان سئو (عنوان تب مرورگر و گوگل)'),
|
||||
F('description', 'توضیح سئو (متا دیسکریپشن)', 'textarea'),
|
||||
],
|
||||
@@ -148,25 +151,25 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
brand: {
|
||||
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
|
||||
titleKey: 'name',
|
||||
fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image'), F('hero', 'تصویر هیرو', 'image'), F('contact_bg', 'پسزمینه بخش تماس', 'image'), F('phone', 'تلفن نمایشی'), F('phone_href', 'لینک تلفن'), F('email', 'ایمیل'), F('address', 'نشانی', 'textarea')],
|
||||
fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image', undefined, true), F('hero', 'تصویر هیرو', 'image', undefined, true), F('contact_bg', 'پسزمینه بخش تماس', 'image', undefined, true), F('phone', 'تلفن نمایشی', 'text', undefined, true), F('phone_href', 'لینک تلفن', 'text', undefined, true), F('email', 'ایمیل', 'text', undefined, true), F('address', 'نشانی', 'textarea')],
|
||||
defaults: {},
|
||||
},
|
||||
hero: {
|
||||
resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('title_highlight', 'واژه مسیِ تیتر'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی'), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم'), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')],
|
||||
fields: [F('title_highlight', 'واژه مسیِ تیتر'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی', 'text', undefined, true), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم', 'text', undefined, true), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')],
|
||||
defaults: {},
|
||||
},
|
||||
story: {
|
||||
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('quote', 'نقلقول مدیرعامل', 'textarea'), F('quote_author', 'استناد نقلقول'), F('stat1_value', 'آمار ۱ — عدد'), F('stat1_label', 'آمار ۱ — برچسب'), F('stat2_value', 'آمار ۲ — عدد'), F('stat2_label', 'آمار ۲ — برچسب'), F('link_label', 'متن دکمه'), F('link_href', 'لینک دکمه')],
|
||||
fields: [F('title', 'عنوان بخش'), F('paragraphs', 'پاراگرافهای معرفی', 'list'), F('quote', 'نقلقول مدیرعامل', 'textarea'), F('quote_author', 'استناد نقلقول'), F('stat1_value', 'آمار ۱ — عدد'), F('stat1_label', 'آمار ۱ — برچسب'), F('stat2_value', 'آمار ۲ — عدد'), F('stat2_label', 'آمار ۲ — برچسب'), F('link_label', 'متن دکمه'), F('link_href', 'لینک دکمه', 'text', undefined, true)],
|
||||
defaults: {},
|
||||
},
|
||||
before_after: {
|
||||
resource: 'before-after', dataKey: 'before_after', title: 'بخش قبل/بعد', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('before', 'تصویر قبل', 'image'), F('after', 'تصویر بعد', 'image'), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
|
||||
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('before', 'تصویر قبل', 'image', undefined, true), F('after', 'تصویر بعد', 'image', undefined, true), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
|
||||
defaults: {},
|
||||
},
|
||||
contact_section: {
|
||||
@@ -197,6 +200,21 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
},
|
||||
}
|
||||
|
||||
/** آیا فیلد در منبع داده قابل ترجمه است؟ (فیلدهای مشترک و پسوندهای انگلیسی قبلی خیر) */
|
||||
export function isTranslatable(resource: string, name: string): boolean {
|
||||
if (name.endsWith('_en')) return false
|
||||
const config = ENTITIES[resource]
|
||||
return !!config?.fields.some((f) => f.name === name && !f.shared)
|
||||
}
|
||||
|
||||
/** فیلدهای قابل ویرایش یک منبع در زبان دادهشده — انگلیسی: بدون فیلدهای مشترک و با پسوند _en */
|
||||
export function editableFields(config: EntityConfig, lang: 'fa' | 'en'): FieldConfig[] {
|
||||
if (lang === 'fa') return config.fields
|
||||
return config.fields
|
||||
.filter((f) => !f.shared)
|
||||
.map((f) => ({ ...f, name: `${f.name}_en` }))
|
||||
}
|
||||
|
||||
/** دسترسی به آرایه/شیء منبع از درخت داده سایت */
|
||||
export function getEntityData(data: SiteData, config: EntityConfig): Record<string, unknown> & { id: number }[] | (Record<string, unknown> & { id: number }) {
|
||||
return data[config.dataKey as keyof SiteData] as never
|
||||
|
||||
Reference in New Issue
Block a user