Merge branch 'bilingual-en'

# Conflicts:
#	frontend/src/admin/ArticleWritePage.tsx
#	frontend/src/admin/EntityDrawer.tsx
#	frontend/src/admin/entities.ts
#	frontend/src/components/ArticleCard.tsx
#	frontend/src/index.css
#	frontend/src/pages/ArticleDetail.tsx
This commit is contained in:
MMD
2026-09-13 18:45:06 +03:30
52 changed files with 2731 additions and 321 deletions
+53 -19
View File
@@ -15,8 +15,8 @@ import type { Article, ArticleBodyDoc } from '../data/site'
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
/** پیش‌نویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته */
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc }
/** پیش‌نویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته دو زبانه */
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc; body_en: ArticleBodyDoc }
/** داده‌های قدیمی ممکن است هشتگ را رشته کاماشده داشته باشند */
function normalizeTags(value: unknown): string[] {
@@ -36,7 +36,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()
@@ -49,16 +49,22 @@ export function ArticleWritePage() {
id: 0,
slug: `article-${Date.now()}`,
title: '',
title_en: '',
date: new Date().toISOString().split('T')[0],
date_en: '',
category: ARTICLE_CATEGORIES[0],
category_en: '',
tags: [],
image: '',
image_alt: '',
author: '',
meta_description: '',
read: '۵ دقیقه',
read_en: '',
excerpt: '',
excerpt_en: '',
body: EMPTY_DOC,
body_en: EMPTY_DOC,
status: 'draft',
seo_keyphrase: '',
})
@@ -93,10 +99,15 @@ export function ArticleWritePage() {
// وقتی مقاله موجود شناخته شد، فرم و شناسه ساخته‌شده یک‌بار مقداردهی می‌شود
useEffect(() => {
if (!article || createdRef.current) return
const rawEnBody = (article as unknown as { body_en?: unknown }).body_en
setDraft({
...article,
tags: normalizeTags(article.tags),
...(article as unknown as Draft),
tags: normalizeTags((article as unknown as { tags?: unknown }).tags),
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
@@ -195,6 +206,8 @@ export function ArticleWritePage() {
})
const metaLength = (draft.meta_description ?? '').trim().length
// در زبان انگلیسی، فیلدهای متنی به ستون‌های _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">
@@ -211,6 +224,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]}
@@ -245,7 +270,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"
>
@@ -276,11 +301,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"
/>
@@ -304,16 +329,21 @@ export function ArticleWritePage() {
/>
</div>
{/* متن */}
{/* متن — ویرایشگر روی سند زبان جاری کار می‌کند */}
<div className="mt-8">
<ArticleEditor
doc={draft.body as unknown as Record<string, unknown>}
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
onChange={(json) => {
// محاسبه خودکار زمان مطالعه بر اساس تعداد کلمات متن
// محاسبه خودکار زمان مطالعه بر اساس تعداد کلمات متن — به زبان جاری
const textContent = JSON.stringify(json)
const wordCount = textContent.split(/\s+/).length
const calculatedReadTime = Math.max(1, Math.ceil(wordCount / 200)) + ' دقیقه'
queueSave({ body: json, read: calculatedReadTime })
if (editLang === 'en') {
const minutes = Math.max(1, Math.ceil(wordCount / 200))
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
} else {
const calculatedReadTime = Math.max(1, Math.ceil(wordCount / 200)) + ' دقیقه'
queueSave({ body: json, read: calculatedReadTime })
}
}}
/>
</div>
@@ -373,8 +403,8 @@ export function ArticleWritePage() {
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده می‌شود)</label>
<textarea
rows={2}
{...textField('excerpt')}
placeholder="خلاصه کوتاه مقاله برای کارت‌های وبلاگ…"
{...textField(suf('excerpt'))}
placeholder={editLang === 'en' ? draft.excerpt || 'English excerpt…' : 'خلاصه کوتاه مقاله برای کارت‌های وبلاگ…'}
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
/>
</div>
@@ -428,9 +458,13 @@ export function ArticleWritePage() {
<label className="text-xs font-light text-ivory/50">زمان مطالعه (محاسبه خودکار)</label>
<input
type="text"
disabled
{...textField('read')}
className="mt-2 w-full cursor-not-allowed border border-ivory/15 bg-char/50 px-3 py-2 text-sm font-light text-ivory/50"
disabled={editLang !== 'en'}
{...(editLang === 'en' ? textField('read_en') : { value: String(draft.read ?? '') })}
className={`mt-2 w-full text-sm font-light ${
editLang === 'en'
? 'border border-ivory/15 bg-char px-3 py-2 text-ivory focus:border-copper focus:outline-none'
: 'cursor-not-allowed border border-ivory/15 bg-char/50 px-3 py-2 text-ivory/50'
}`}
/>
</div>
<div>